Dan Tech Academy
After this lesson you will
  • 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
Duration25 min
DifficultyIntermediate

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.

Video in production

Lesson video is on the way

While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.

Recording is in post-production

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:

kotlin
// 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:

LayerKnows aboutMust not know about
UIViewModel, UI stateRepositories, network SDKs, databases
ViewModelUse cases, domain modelsSupabase, DataStore, Firebase
DomainItself (models, interfaces, rules)Compose, Android, Koin, Ktor, Supabase
DataDomain interfaces to implementUI, 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:

LayerWhere it livesReal examples
UIThe screen package in :feature:game, :feature:menu, :feature:paywall, plus :ui-components and :themeGameRoute, GameScreenCompose
ViewModelThe viewmodel package in the feature modulesGameViewModel, MenuViewModel
DomainThe :domain moduleGameStateMachine, UseCase, the QuestionRepository interface
DataThe :data moduleMockQuestionRepository, 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:

kotlin
/**
 * 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:

kotlin
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:

kotlin
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:

kotlin
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

  1. Find the class in each layer: UI, ViewModel, Domain, Data.
  2. Point out which interface lives in Domain and which class implements it.
  3. Answer: in which layer is the rank calculated?
  4. Answer: why can't LeaderboardViewModel import LeaderboardRepositoryImpl?
LayerClassReason
UILeaderboardPopup, LeaderBoardList in :feature:menuOnly renders LeaderBoardListUiState
ViewModelLeaderboardViewModelCalls FetchLeaderboardUseCase, maps LeaderboardEntry to UserProfileUiState
DomainFetchLeaderboardUseCase, the LeaderboardRepository interface, the LeaderboardEntry modelImports no SDK
DataLeaderboardRepositoryImpl, LeaderboardRowCalls 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

  1. settings.gradle.kts: the list of 14 modules.
  2. domain/build.gradle.kts: the KDoc spells out what the domain must not contain.
  3. feature/game/build.gradle.kts: the feature doesn't depend on :data.
  4. GameViewModel: the constructor takes use cases.
  5. GameStateMachine: handleStartGame and handleSubmitAnswer.
  6. DataModule: which interface is bound to which class.

Signs To Avoid

SmellBetter direction
The ViewModel takes a SupabaseClient or a repository implementationTake use cases; implementations are bound at the composition root
The domain imports androidx.*, Compose or KoinKeep the domain in commonMain with pure Kotlin
A feature module adds a :data dependency for convenienceAdd an interface to :domain and implement it in :data
A composable checks whether the answer is right or wrong itselfSend the intent to the ViewModel; the reducer decides
Adding a layer just to have all four layersEach layer has to isolate one reason to change

References

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.

Sign in