Architecture — Android / KMP
The shape of how features are built. Coding Standards is the law (what's allowed); this is the shape (how to build it). Read the section for the layer you're touching — you don't need the whole page for a one-line change.
1. Layers
UI (Compose) → Presentation (ViewModel/State/Intent/Effect) → Domain (entities, use cases, repo interfaces)
↑
Data (repo impls, local store, remote, mappers) → Domain
Dependencies point inward. Domain depends on nothing platform. UI/Data depend on Domain, never the reverse. Features never depend on each other — shared types go to Domain.
| Layer | Owns | Never contains |
|---|---|---|
| UI | Stateless composables, theme/tokens | Business logic, VM below the Route |
| Presentation | ViewModel, State, Intent, Effect | I/O, platform SDKs, DB/network |
| Domain | Entities, value objects, use cases, repo interfaces | Android, Compose, DB, network, DI annotations |
| Data | Repo impls, local store, remote, mappers, DTOs | UI/presentation types |
2. DI strategy
- Constructor injection everywhere. No service locators, no static singletons holding state.
- Composition root is the only place that knows concrete impls — bind interface → impl there.
- Scope deliberately: singletons for stateless/shared (repos, DB, dispatchers); per-screen for ViewModels.
- Pick one per project: Hilt (Android-only), Koin (KMP, DSL), kotlin-inject (KMP, compile-time).
// Domain/data class — DI-agnostic, just a constructor:
class CartRepositoryImpl(
private val local: CartLocalDataSource,
private val queue: OfflineMutationQueue,
private val dispatchers: DispatcherProvider,
) : CartRepository
// Composition root binds it (example: Koin)
single<CartRepository> { CartRepositoryImpl(get(), get(), get()) }
3. Offline-first data layer
The single hardest part to get right. The contract: the app is fully usable with no network, ever.
3.1 The three-tier stack
Repository (data) ── reads ──> Local store (Room/SQLDelight) ← single source of truth for the UI
│ writes
↓
Local store ──then──> OfflineMutationQueue.enqueue(mutation)
│ (background)
↓
SyncEngine drains queue ──> RemoteSyncDataSource (push/pull)
│
↓ pulled records written back to Local store → Flow re-emits → UI updates
3.2 Hard rules
- Every read comes from the local store as a
Flow. No use case / ViewModel / composable ever reads remote. - Every write goes local first, then enqueues a
SyncMutation. The UI reflects the write instantly. - Remote is isolated behind
RemoteSyncDataSource. Swapping backend = new impl + DI rebind, zero other changes. - Sync is invisible to upper layers. Pulled data lands in the local store; the existing Flow emits.
- Conflicts resolved in one place (the SyncEngine / a
ConflictResolver), by an explicit policy. Never silently in a repository.
3.3 Behaviour matrix
| Scenario | Behaviour |
|---|---|
| No network ever | 100% functional via local store |
| Write offline | Saved locally now; queued; UI updates immediately |
| Network returns | SyncEngine drains queue; remote updated |
| Remote changed elsewhere | Pulled next cycle; conflict policy applied; local updated |
| Auth expired | Sync pauses; local CRUD unaffected; resumes after re-auth |
| Backend retired | Swap RemoteSyncDataSource; local data untouched |
If a project has no remote backend, drop the queue/sync tier — the local store is simply the source of truth. Everything above the repository is identical.
4. Layer wiring (end-to-end, one feature)
Composable(state, onIntent)
└─ onIntent(Intent) ─> ViewModel
├─ reduce(state,intent) → state (pure)
└─ handleSideEffects → UseCase(params) (async)
└─ Repository (interface)
└─ Local store (read Flow / write+enqueue)
result re-enters ViewModel as a new Intent ─> reduce → state ─> StateFlow ─> Composable recomposes
one-shot Effect ─> Channel ─> HandleEffects (nav/toast)
One vertical slice per feature: Screen + ViewModel + State + Intent + Effect in presentation; UseCases in domain; RepositoryImpl + DataSource + Mapper in data.
5. Android-only vs KMP — the deltas (same architecture)
The architecture is identical. Only the mechanism differs:
| Concern | Android-only | KMP |
|---|---|---|
| Layer boundary | Packages or Gradle modules | Gradle modules (:domain, :data, :feature:*) |
| Platform code | Direct | expect/actual in commonMain + androidMain/iosMain |
| Local store | Room | SQLDelight (or Room KMP) |
| DI | Hilt or Koin | Koin or kotlin-inject |
| ViewModel | androidx.lifecycle.ViewModel | Same — shared in commonMain |
| Dispatchers.IO | available | available on JVM/Android/Native; abstract via DispatcherProvider |
6. Abstractions catalog — reuse, do NOT rewrite
Every project provides these in its :core:common (KMP) / core package. Extend them; never reimplement. An agent that writes its own MVI base, result type, or use-case base is in violation — find the existing one first.
| Type | Purpose |
|---|---|
MviViewModel<S,I,E> | MVI base: state StateFlow, effect Channel, pure reduce, onIntent |
UiState / UiIntent / UiEffect | Marker interfaces for the contracts |
AppResult<T> / AppError | Sealed result + error for every async/fallible call |
UseCase<P,R> / FlowUseCase<P,R> | Single-shot / streaming business operation, dispatcher-bound |
DispatcherProvider | Injectable dispatchers (io / default / main) |
safeCall / safeFlow | Wrap I/O into AppResult |
OfflineMutationQueue, SyncMutation, RemoteSyncDataSource | Offline-first sync contracts |
7. Error handling
- Every async/fallible call returns
AppResult<T>(orFlow<AppResult<T>>). No exceptions cross layer boundaries. - Errors are state, not crashes — a failure becomes a visible
state.error, never a silent swallow. - Every error state has a recovery path (a retry Intent). No dead ends.
- Map low-level exceptions to a small
AppErrorset (Network, Storage, NotFound, Validation, Unauthorized, Unknown) at the data boundary.
8. Performance
- State stability: State is an immutable data class of stable types. Avoid unstable raw
List/recreated lambdas. - Derived values are computed
get()props orderivedStateOf— never recomputed inline in composition. - Lazy lists: always provide stable keys; avoid allocating in the item lambda.
- Collect state at the Route; pass slices down so unrelated changes don't recompose whole subtrees.
- No work in composition — no I/O, sorting, filtering, or Dispatchers launches in a composable body.
- Coroutines: correct dispatcher, structured concurrency only, cancel with lifecycle.
Laws → Coding Standards. Tests → Testing.