Back to Blog

Koin DI, Repository Pattern & ViewModel in Kotlin Compose Desktop | Lesson 16

Sandy LaneSandy Lane

Video: Koin DI, Repository Pattern & ViewModel in Kotlin Compose Desktop | Lesson 16 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Koin DI, Repository Pattern, and ViewModel: Build a Book Library

The architecture stack: interface BookRepository for data, LibraryViewModel with StateFlow for state, koinInject() for wiring. Decouple Composables from business logic so they're trivial to test.

So far our apps have kept state directly in Composables — fine for small things, but it gets messy as apps grow. This lesson introduces the standard Kotlin desktop architecture: Repository → ViewModel → UI, glued together by Koin dependency injection.

What we are building

A 900×600 book library: a left pane with a search bar, a "favourites only" toggle, and a list of books; a right pane with the selected book's details. Toggle favourites with a star button. Search filters in real time.

Project layout

src/main/kotlin/
├── Main.kt                  # KoinApplication wiring
├── AppContent.kt            # top-level Composable
├── BookList.kt              # left pane
├── BookDetail.kt            # right pane
├── BookItem.kt              # row composable
├── LibraryViewModel.kt      # state holder
├── BookRepository.kt        # data layer interface + impl
├── BookModels.kt            # Book data class + sample data
└── AppModule.kt             # Koin modules

The three layers

Repository owns the data:

interface BookRepository {
  fun getAllBooks(): List<Book>
  fun getBookById(id: Int): Book?
  fun searchBooks(query: String): List<Book>
}

class InMemoryBookRepository : BookRepository {
  private val books = sampleBooks
  override fun getAllBooks(): List<Book> = books
  override fun searchBooks(query: String): List<Book> {
    if (query.isBlank()) return books
    val lower = query.lowercase()
    return books.filter { it.title.lowercase().contains(lower) || it.author.lowercase().contains(lower) }
  }
}

ViewModel owns the state:

class LibraryViewModel(private val repository: BookRepository) {
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
  private val _state = MutableStateFlow(LibraryUiState())
  val state: StateFlow<LibraryUiState> = _state.asStateFlow()

  init {
    scope.launch {
      _state.update { it.copy(books = repository.getAllBooks()) }
    }
  }

  fun onSearchQueryChanged(query: String) {
    _state.update { it.copy(searchQuery = query, books = repository.searchBooks(query)) }
  }

  fun toggleFavorite(bookId: Int) { /* ... */ }
}

UI owns the rendering:

@Composable
fun AppContent(viewModel: LibraryViewModel = koinInject()) {
  val state by viewModel.state.collectAsState()
  // ... render based on state, call viewModel methods on user actions
}

The Composable doesn't know about the repository. The repository doesn't know about Compose. The ViewModel mediates. Each layer is testable in isolation.

Repository interface vs implementation

interface BookRepository { /* ... */ }
class InMemoryBookRepository : BookRepository { /* ... */ }

The interface defines what; the implementation defines how. Today InMemoryBookRepository just returns a hardcoded list. Tomorrow you swap in:

  • SqliteBookRepository (Lesson 9's Exposed) — same interface, different storage.
  • RemoteBookRepository (Lesson 8's Ktor) — same interface, fetches from a server.
  • CachedBookRepository — wraps another repository with a cache.

The ViewModel and Composables don't need changes. That's the whole point of the abstraction.

ViewModel: state and actions

data class LibraryUiState(
  val books: List<Book> = emptyList(),
  val searchQuery: String = "",
  val favoriteIds: Set<Int> = emptySet(),
  val showFavoritesOnly: Boolean = false,
  val selectedBookId: Int? = null,
)

class LibraryViewModel(private val repository: BookRepository) {
  private val _state = MutableStateFlow(LibraryUiState())
  val state: StateFlow<LibraryUiState> = _state.asStateFlow()

  fun toggleFavorite(bookId: Int) {
    _state.update { current ->
      val newFavorites = if (bookId in current.favoriteIds)
        current.favoriteIds - bookId
      else
        current.favoriteIds + bookId
      current.copy(favoriteIds = newFavorites)
    }
  }
}

A single MutableStateFlow<LibraryUiState> holds the entire UI state in one immutable value. Updates via _state.update { current -> current.copy(...) }.

The pattern:

  • One state class as a data class (so .copy works).
  • One private MutableStateFlow for writes.
  • One public StateFlow for reads.
  • Mutations always go through _state.update { ... .copy(...) }.

StateFlow vs Compose state

Compose has its own mutableStateOf<T>. Why use StateFlow here?

  • Lifecycle independence. A StateFlow lives in the ViewModel, not in a Composable. The state survives reconfiguration, navigation, even multiple windows reading the same VM.
  • Multi-platform. StateFlow is in kotlinx.coroutines; mutableStateOf is in Compose. Code using StateFlow stays portable.
  • Composable interop. viewModel.state.collectAsState() returns a State<T> Compose can read directly.

Within a Composable, prefer Compose state. In a ViewModel that might outlive a Composable, StateFlow.

Coroutine scope in the ViewModel

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

The ViewModel owns its own scope so async work survives composition changes. SupervisorJob() means failure in one child doesn't kill siblings — important when you have several concurrent flows.

Dispatchers.Default runs work off the main thread (good for CPU work and most repository calls). Dispatchers.IO for explicit I/O (file, DB, network) — Ktor and Exposed handle their own dispatchers internally.

For Android, viewModelScope ties the scope to the ViewModel lifecycle automatically. On desktop, you build the scope yourself; cancel it in a dispose() method when the app exits.

Koin modules

val dataModule = module {
  single<BookRepository> { InMemoryBookRepository() }
}

val uiModule = module {
  single { LibraryViewModel(get()) }
}

Koin's DSL is intentionally minimal. Two keywords:

  • single { ... } — singleton; one instance per Koin context.
  • factory { ... } — a new instance every time it's resolved.

Inside the lambda, get() looks up another binding. LibraryViewModel(get()) resolves get(): BookRepository to the singleton from dataModule.

The <BookRepository> type parameter on single<BookRepository> { ... } registers the binding under the interface type. Code asking for BookRepository gets InMemoryBookRepository. Swap the impl in one line — the rest of the app doesn't change.

KoinApplication wiring

fun main() = application {
  Window(/* ... */) {
    KoinApplication(application = { modules(dataModule, uiModule) }) {
      MaterialTheme(colorScheme = darkColorScheme()) {
        AppContent()
      }
    }
  }
}

KoinApplication { modules(...) } from koin-compose wraps your UI tree and makes Koin available to anything inside.

@Composable
fun AppContent(viewModel: LibraryViewModel = koinInject()) { /* ... */ }

koinInject<LibraryViewModel>() resolves the binding. Default-parameter syntax means tests can pass a hand-built LibraryViewModel(FakeRepository()) and skip Koin entirely.

Reading StateFlow in a Composable

val state by viewModel.state.collectAsState()

StateFlow.collectAsState() returns State<T>. Compose recomposes whenever the underlying flow emits. The by delegate reads the value as plain T.

Calling ViewModel methods

BookList(
  books = displayedBooks,
  onSearchQueryChanged = viewModel::onSearchQueryChanged,
  onToggleFavorite = viewModel::toggleFavorite,
  // ...
)

Method references (::onSearchQueryChanged) pass the ViewModel's methods as callbacks without allocating new lambdas on every recomposition. Good for hot paths.

Derived state in the Composable

val displayedBooks = remember(state.books, state.favoriteIds, state.showFavoritesOnly) {
  if (state.showFavoritesOnly) {
    state.books.filter { it.id in state.favoriteIds }
  } else {
    state.books
  }
}

The "show favourites only" filter is derived state — a function of three other state pieces. We could compute it in the ViewModel, but Compose's remember(keys) works just as well and keeps the VM simpler.

Rule of thumb: derived state that's used in multiple Composables → ViewModel. Derived state used in one place → remember in the Composable.

Testing the ViewModel

class LibraryViewModelTest {
  @Test fun `toggleFavorite adds book to favorites`() = runTest {
    val repo = FakeBookRepository(listOf(Book(1, "Foo", "Bar")))
    val vm = LibraryViewModel(repo)
    vm.toggleFavorite(1)
    assertEquals(setOf(1), vm.state.value.favoriteIds)
  }
}

No Compose, no UI host, no Koin. Just plain Kotlin — instantiate the ViewModel with a fake repository, call methods, assert against state.value. Fast and deterministic.

Common mistakes

Letting Composables call the repository directly. BookList doesn't know BookRepository exists, and shouldn't. The ViewModel is the boundary.

Mutating state outside _state.update. Direct field mutations don't emit, so collectors don't see the change. Always update via update { copy(...) }.

Forgetting asStateFlow(). Exposing MutableStateFlow directly lets consumers write to it, breaking the unidirectional flow. asStateFlow() returns a read-only view.

Using runBlocking inside the ViewModel. Blocks the calling thread. Use scope.launch for fire-and-forget; suspend the public function for awaitable work.

Putting too much in the ViewModel. The VM is for state and orchestration, not formatting. Helpers like formatPrice(d: Double): String belong in plain Kotlin functions.

What's next

Lesson 17: Markdown editor with live preview. Two-pane layout, real-time HTML preview, syntax-highlighted source — building on the editor patterns from Lesson 5 with full markdown rendering.

Recap

Three layers: interface BookRepository for data, class LibraryViewModel(repo) { val state: StateFlow<UiState> } for state, @Composable AppContent(vm = koinInject()) for UI. MutableStateFlow plus asStateFlow() for read-only exposure. state.value.copy(...) for immutable updates. Koin's single<T> { Impl() } to bind interface → implementation.

Next lesson: markdown editor with live preview.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.