Dan Tech Academy
After this lesson you will
  • Put each responsibility in the right layer: UI, Route, ViewModel, reducer, use case, repository, service
  • Tell a repository contract (data access) apart from a service contract (capability) and the data implementation
  • Read the codebase's naming conventions to guess a file's location and role before opening it
Duration7 min
DifficultyBeginner

Layer Responsibilities And How To Read File Names

Don't mix UI, business logic and backend code. And learn to guess where a file lives from its name alone.

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

In a real app, writing a new feature is half the job. The other half is knowing which layer that responsibility belongs in.

This lesson helps you tell apart the responsibilities of the UI, Route, ViewModel, reducer/state machine, use case, repository, service, data implementation and shared UI layers.

The second half of the lesson teaches a skill people rarely talk about: reading a file name to guess its role and location. This codebase names things very consistently. Once you know the conventions, you find code much faster than by searching blindly.

In a Kotlin Multiplatform project, the question "which layer" comes with a second one: does this code run on every platform, or only on Android or iOS? Part 5 answers that.

The Real Problem

In a small app you can write almost everything into a ViewModel and the app still runs. In a production app, that starts to hurt.

You fix a small sign-in bug but have to open the UI, the ViewModel, the Supabase client, analytics, navigation and local storage. You add a button and accidentally touch a business rule. You change the AI hint provider and the game screen has to change too.

At that point the layers no longer have clear ownership.

This is also a common bottleneck with vibe coding: you build the app very fast, but the prompt never states what each layer is responsible for, so the code piles up in one place.

Part 1: The Layer Map

Use this table when you're not sure where code should go:

LayerMain responsibilityWhat it should not do
UI composableRender UiState, take input, call callbacks.Call the backend, hold business rules, pick the data source itself.
Route composableConnect the ViewModel to the screen, collect one-time effects, wire navigation.Stuff in layout or business logic.
ViewModelHold screen state, take UI events, dispatch actions, call use cases/services.Create its own HTTP client or know Supabase/Firebase SDK details.
Reducer/state machineTurn the current state plus an action into the next state and effects, predictably.Call I/O, navigation, analytics or the backend directly.
Use caseExpress one specific business operation.Render UI or know Compose classes.
Repository contractDefine what data the app needs to read/write.Contain SDK/provider implementation details.
Service contractDefine a capability such as auth, AI hint, audio, haptics, subscription.Return UI state or depend on a screen.
Data implementationTalk to Supabase, Firebase, RevenueCat, DataStore, Ktor and each platform's audio SDK.Decide the UI flow.
:coreShared route contracts, base types, feature registries.Own feature UI.
:kernelThe lowest technical primitives, for example QzDispatchers.Depend on other app modules.
:ui-components and :themeReusable composables, design tokens, spacing, typography, colors.Call a ViewModel or the backend.

Learners often miss this point: a repository and a service are different things.

A repository answers "how is this data stored and read?". A service answers "how is this capability carried out?".

For example, LeaderboardRepository reads the leaderboard, while AuthService handles sign-in and sign-out. Both have their contract in :domain and their implementation in :data.

The features' reducers, side effect handlers and ViewModels are built on an in-house library in two modules, :dre-core and :dre-viewmodel. MenuReducer implements the Reducer interface from :dre-core, the handlers implement SideEffectHandler, and MenuViewModel extends DreStoreViewModel from :dre-viewmodel. Because every feature shares the same skeleton, the ViewModel folders in Part 4 look so alike.

Part 2: Example, Email Sign In

Trace this flow:

text
Menu screen
-> MenuViewModel
-> SignInWithEmailUseCase
-> AuthService
-> AuthServiceSupabaseImpl
-> Supabase

The right ownership is:

PieceOwner
Email form UI:ui-components auth components or :feature:menu screen code
User taps sign inA MenuRoute or MenuScreen callback into MenuViewModel
Loading/error/dialog stateMenuViewModel and the reducer
Business operationSignInWithEmailUseCase in :domain
Auth capabilityThe AuthService contract in :domain
Supabase detailsAuthServiceSupabaseImpl in :data

If MenuViewModel created its own Supabase client, it would be carrying too much: screen state, the business flow and transport details.

The consequences are concrete: change the auth provider and the menu has to change. Add a retry policy and the menu has to know about it. Reuse sign-in in onboarding and you copy the logic.

Part 3: Example, AI Hint

Trace this flow:

text
Game feature
-> GameViewModel
-> lifeline/action handling
-> AIHintService
-> AIHintServiceImpl
-> GeminiHintProvider or LocalHintProvider
-> GeminiHintClient (SupabaseGeminiHintClient), on the Gemini branch only
-> the gemini-hint Edge Function on Supabase
-> Gemini API

To the user this is one action: "give me a hint". In the codebase it passes through several layers:

ResponsibilitySensible layer
Render the Ask AI buttonThe game screen composable
Take the user's intentGameViewModel
Decide whether the game state allows a hintThe reducer/state machine
Express the "get hint" capabilityThe AIHintService contract
Choose Gemini or the local fallbackThe data implementation and provider wiring

The screen should render the Ask AI button. The ViewModel should coordinate state. Only the data layer knows where the request goes and what happens when it fails. GeminiHintProvider runs only when the feature_ai_hints flag in Remote Config is on and the player is signed in. It sends the question and its options through SupabaseGeminiHintClient to the gemini-hint Edge Function, and on a transient error retries up to max_retries times (read from Remote Config). If the call fails, AIHintServiceImpl switches to LocalHintProvider. The Gemini API key is not in the app: it is a Supabase secret that only the gemini-hint function can read.

Part 4: Read File Names Like A Map

This is the fastest way to navigate a production codebase. Each file name suffix tells you the file's role and module before you open it.

Prefixes

PrefixMeaningModule
Qzds*A shared design system component or token:ui-components or :theme
I*ViewModelA ViewModel interface, used for tests and previews:feature:*
*ImplA concrete implementation of a contractusually :data

The Qzds prefix shows up in a lot of files. When you see it, you know right away: this is shared, it doesn't belong to any one feature, and changing it affects many screens.

Suffixes Inside A Feature

SuffixRole
*RouteDataA route contract, @Serializable, lives in :core
*NavigationThe feature navigation provider; registers destinations
*RouteA composable that connects the ViewModel to the screen and handles one-time effects
*ScreenA composable that renders purely from UiState
*UiStateAn immutable data class describing what the screen shows
*MapperTurns internal state into UiState
*ContractDeclares a screen's State, Action and SideEffect
*ReducerA pure function that turns state plus an action into new state
*SideEffectHandler, *UiEffectHandlerWhere effects actually run, with I/O
*ViewModelThe coordinator that ties all of the above together

Open feature/menu/.../viewmodel/ and you see exactly this set: MenuContract.kt, MenuReducer.kt, MenuViewModel.kt, plus a handler/ folder with 4 side effect handlers.

Then open feature/menu/.../settings/viewmodel/. The same set again: SettingContract.kt, SettingReducer.kt, SettingUiEffectHandler.kt, SettingViewModel.kt.

Then leaderboard/viewmodel/. Still the same set.

The codebase doesn't have ten ways of organizing ten screens. It has exactly one shape, repeated everywhere.

Once you understand that shape, you can read every other feature. And when you write a new feature, you already know which files to create.

Part 5: Layers And Platforms

Every layer in the Part 1 table lives in commonMain: UI composables, Routes, ViewModels, reducers, use cases, contracts, and most data implementations. Android and iOS share one copy.

Only implementations that must call a platform's own SDK are split out. For example, the AudioManager contract lives in commonMain, and the implementation comes in two versions:

Contract (commonMain)Android (DataModule.android.kt)iOS (DataModule.ios.kt)
AudioManagerSoundPoolAudioManagerAvAudioManager
HapticManagerVibratorHapticManagerUiKitHapticManager
AnalyticsManagerFirebaseAnalyticsManagerNoOpAnalyticsManager

The feature only knows AudioManager. Koin picks the implementation per platform. The shared UI has no if (platform) branches: one composable, two Koin bindings.

When you add code that touches a platform SDK, put the contract in commonMain and the two implementations in androidMain/iosMain. This tip applies to every KMP project, not just this repo.

Part 6: The ViewModel Doesn't Do Everything

A common misunderstanding: since the ViewModel coordinates the reducer and the handlers, everything has to go into the ViewModel.

The ViewModel only coordinates: it takes UI events, exposes UiState, dispatches actions, calls the reducer, and executes effects through injected collaborators.

Complex business rules still belong in use cases. Data access still goes through repository/service contracts. Provider details still live in :data.

Bad Smells

SmellBetter direction
A composable imports Supabase or Firebase classesMove the service call behind the domain/data layers.
A ViewModel creates its own HttpClientInject a repository/service implementation.
A reducer or state machine calls I/O directlyEmit a side effect and execute it in a handler.
A repository returns UiStateReturn a domain model and map it to UI in the feature layer.
A service implementation lives in :domainKeep the domain as contracts; implementations go in :data.
A feature copies a button another screen could reuseExtract it to :ui-components with the Qzds prefix.
Feature A imports Feature B's screenUse route contracts and callbacks.
A composable branches by platformPut the contract in commonMain and bind the implementation per platform through Koin.

Checkpoint

Open feature/menu/.../viewmodel/ and feature/menu/.../settings/viewmodel/ side by side.

You are done with this lesson when you can answer:

  • What shape do those two folders share?
  • Which file is the only one allowed to call I/O in that shape?
  • From the name LeaderboardUiState.kt alone, can you guess what it contains and what it doesn't?
  • How do AIHintService and AIHintServiceImpl differ, and which module does each live in?
  • If you need a button shared by the menu and the game, where do you put it and what do you call it?
  • Why does AudioManager have two implementations, and does the feature need to know?
Sign in