- Explain the dependency rule: outer layers know inner layers, inner layers don't know outer layers
- Map the four layers UI, ViewModel, Domain and Data onto the repo's real modules
- Trace one game start from GameRoute to MockQuestionRepository and back
Clean Architecture And The 4-Layer Model
The dependency rule, the four layers UI, ViewModel, Domain and Data mapped onto the repo's real modules, and one game start traced through all four layers.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
Lesson Goal
After this lesson, you can explain what problem Clean Architecture solves, read the dependency rule, and point out which modules hold the four layers UI, ViewModel, Domain and Data in the kotlin-accelerator-ai repo.
The end goal: look at any class and know which layer it belongs to and what it's allowed to import.
Part 1: The Problem Clean Architecture Solves
Let's start with a ViewModel that calls the database itself:
// Illustrative example, not in the repo
class GameViewModel(private val supabase: SupabaseClient) : ViewModel() {
fun loadQuestions() = viewModelScope.launch {
val rows = supabase.from("questions").select().decodeList<QuestionRow>()
// mapping, filtering, error handling... all of it here
}
}
This code works, but it has three costs:
- To test the ViewModel, you need a real Supabase or a very complicated mock.
- Changing the data source (for example from Supabase to a JSON file) means editing the ViewModel.
- Business logic (picking questions, calculating prize money) gets mixed up with network details.
Clean Architecture separates those concerns into layers, and sets one single rule for the direction of dependencies between them.
Part 2: The Dependency Rule
The original rule in Robert C. Martin's article: source code dependencies can only point inward, toward higher-level policy. An inner layer knows nothing about the names of anything in an outer layer.
Applied to a mobile app:
| Layer | Knows about | Must not know about |
|---|---|---|
| UI | ViewModel, UI state | Repositories, network SDKs, databases |
| ViewModel | Use cases, domain models | Supabase, DataStore, Firebase |
| Domain | Itself (models, interfaces, rules) | Compose, Android, Koin, Ktor, Supabase |
| Data | Domain interfaces to implement | UI, ViewModel |
The most important point is in the Data row. Data depends on Domain, and never the other way around. Domain declares the QuestionRepository interface, and Data provides the class that implements it. This technique of inverting the dependency is called Dependency Inversion.
Part 3: The Four Layers In The Repo
The repo has 14 Gradle modules (declared in settings.gradle.kts). The four layers map onto them like this:
| Layer | Where it lives | Real examples |
|---|---|---|
| UI | The screen package in :feature:game, :feature:menu, :feature:paywall, plus :ui-components and :theme | GameRoute, GameScreenCompose |
| ViewModel | The viewmodel package in the feature modules | GameViewModel, MenuViewModel |
| Domain | The :domain module | GameStateMachine, UseCase, the QuestionRepository interface |
| Data | The :data module | MockQuestionRepository, LeaderboardRepositoryImpl, DataModule |
Besides the four layers, two modules do the assembly: :shared-app (the Koin wiring shared by Android and iOS) and :app (the Android composition root). They're the only places allowed to see both Domain and Data at once.
Part 4: The Module Graph Enforces The Rule For You
In KMP, the dependency rule is written into the Gradle files as well as being a convention. Here's the KDoc at the top of domain/build.gradle.kts:
/**
* Pure Kotlin. Coroutines, the DRE reducer, and persistent collections.
*
* No Compose, navigation, Compose resources, Koin, Ktor, or Supabase.
* Koin wiring for these types lives in `:shared-app`.
*/
The commonMain of :domain declares only three dependencies: kotlinx-coroutines-core, :dre-core and kotlinx-collections-immutable. There's no androidMain and no iosMain. So the domain code runs exactly the same on both platforms and can be tested with commonTest.
On the feature side, feature/game/build.gradle.kts declares :core, :domain, :kernel, :theme, :ui-components. There's no :data. If you try to import MockQuestionRepository in GameViewModel, the build reports an unresolved reference right away. The compiler is enforcing the dependency rule in place of code review.
Part 5: Follow One Round Through The Four Layers
Let's follow the start-game action from the UI down to Data and back up.
UI: GameRoute calls gameViewModel.ensureGameStarted(), then collects gameScreenUiState and passes it to GameScreenCompose. The composable doesn't know where the questions come from.
ViewModel: GameViewModel dispatches GameAction.StartGame. Its constructor takes use cases, and no repositories:
class GameViewModel(
reducer: GameStateMachine,
// ...
logAnalytics: LogAnalyticsEventUseCase,
loadGameQuestions: LoadGameQuestionsUseCase,
lifelineHelper: LifelineHelper,
recordGameStats: RecordGameStatsUseCase,
dispatchers: QzDispatchers,
// ...
) : DreStoreViewModel<GameState, GameAction, GameSideEffect, GameAsyncOp>(
reducer = reducer,
dispatchContext = dispatchers.mainImmediate,
), IGameViewModel
Domain: GameStateMachine is a pure reducer. The handleStartGame function returns a new state along with GameAsyncOp.LoadGame. The reducer does no I/O; it only describes the work to be done.
The ViewModel runs the async op through GameAsyncOpExecutor.loadGame(), which calls LoadGameQuestionsUseCase:
class LoadGameQuestionsUseCase(
private val questionRepository: QuestionRepository,
) : UseCase<Unit, List<QuizData>>() {
override suspend fun invoke(input: Unit): List<QuizData> = questionRepository.newGameQuestions()
}
Data: DataModule binds the interface to the implementation:
single<QuestionRepository> { MockQuestionRepository() }
MockQuestionRepository takes questions from SampleQuestions following the difficulty distribution in QuizGameConfig.QUESTION_DIFFICULTIES.
On the way up: the executor creates a CurrentGameSession (its id comes from the Kotlin stdlib's Uuid.random(), which runs on both Android and iOS), the ViewModel dispatches GameAction.GameLoaded, the reducer updates the state, and gameScreenUiState emits a new value to the UI.
When you swap MockQuestionRepository for an implementation that reads from Supabase, only the bind line in DataModule changes. GameViewModel, GameStateMachine and LoadGameQuestionsUseCase don't need any edits.
Practice Exercise
Task
Map the Leaderboard feature onto the four layers. For each layer, write down the real class name and why it belongs to that layer.
Requirements
- Find the class in each layer: UI, ViewModel, Domain, Data.
- Point out which interface lives in Domain and which class implements it.
- Answer: in which layer is the rank calculated?
- Answer: why can't
LeaderboardViewModelimportLeaderboardRepositoryImpl?
| Layer | Class | Reason |
|---|---|---|
| UI | LeaderboardPopup, LeaderBoardList in :feature:menu | Only renders LeaderBoardListUiState |
| ViewModel | LeaderboardViewModel | Calls FetchLeaderboardUseCase, maps LeaderboardEntry to UserProfileUiState |
| Domain | FetchLeaderboardUseCase, the LeaderboardRepository interface, the LeaderboardEntry model | Imports no SDK |
| Data | LeaderboardRepositoryImpl, LeaderboardRow | Calls Supabase, decodes rows, returns DomainResult |
The LeaderboardRepository interface lives in :domain, LeaderboardRepositoryImpl in :data implements it, and DataModule binds the two together.
The rank is calculated in the database. The leaderboard view in scripts/sql/setup.sql uses ROW_NUMBER() OVER (ORDER BY total_money_won DESC, level DESC, id). The repository only asks for ordering by the rank column and a row limit. The ViewModel uses the rank value it receives to pick the GOLD, SILVER or BRONZE tier.
LeaderboardViewModel lives in :feature:menu, and that module declares no dependency on :data. That import won't compile.
Code Reading Path
- settings.gradle.kts: the list of 14 modules.
- domain/build.gradle.kts: the KDoc spells out what the domain must not contain.
- feature/game/build.gradle.kts: the feature doesn't depend on
:data. - GameViewModel: the constructor takes use cases.
- GameStateMachine:
handleStartGameandhandleSubmitAnswer. - DataModule: which interface is bound to which class.
Signs To Avoid
| Smell | Better direction |
|---|---|
The ViewModel takes a SupabaseClient or a repository implementation | Take use cases; implementations are bound at the composition root |
The domain imports androidx.*, Compose or Koin | Keep the domain in commonMain with pure Kotlin |
A feature module adds a :data dependency for convenience | Add an interface to :domain and implement it in :data |
| A composable checks whether the answer is right or wrong itself | Send the intent to the ViewModel; the reducer decides |
| Adding a layer just to have all four layers | Each layer has to isolate one reason to change |
References
- The Clean Architecture (Robert C. Martin)
- Guide to app architecture (Android Developers)
- Domain layer (Android Developers)
Key Points
Clean Architecture is a rule about the direction of dependencies: outer layers know inner layers, and inner layers don't know outer layers.
In this repo, that rule is written into Gradle. :domain contains only pure Kotlin, feature modules can't see :data, and only :shared-app and :app get to see both in order to assemble them.
When you're unsure where a class belongs, ask: if the SDK or the UI changes, does this class have to change? If yes, it doesn't belong in the domain.