Koin DI, Repository Pattern & ViewModel in Kotlin Compose Desktop | Lesson 16
Video: Koin DI, Repository Pattern & ViewModel in Kotlin Compose Desktop | Lesson 16 by Taught by Celeste AI - AI Coding Coach
Koin DI, Repository Pattern, and ViewModel: Build a Book Library
The architecture stack:
interface BookRepositoryfor data,LibraryViewModelwithStateFlowfor 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.copyworks). - One private
MutableStateFlowfor writes. - One public
StateFlowfor 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
StateFlowlives in the ViewModel, not in a Composable. The state survives reconfiguration, navigation, even multiple windows reading the same VM. - Multi-platform.
StateFlowis inkotlinx.coroutines;mutableStateOfis in Compose. Code usingStateFlowstays portable. - Composable interop.
viewModel.state.collectAsState()returns aState<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.