Architecture & Standards

Architecture — Android / KMP

Suggested Reading Order: Architecture (this page) → Coding StandardsTesting

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.

LayerOwnsNever contains
UIStateless composables, theme/tokensBusiness logic, VM below the Route
PresentationViewModel, State, Intent, EffectI/O, platform SDKs, DB/network
DomainEntities, value objects, use cases, repo interfacesAndroid, Compose, DB, network, DI annotations
DataRepo impls, local store, remote, mappers, DTOsUI/presentation types

2. DI strategy

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

3.3 Behaviour matrix

ScenarioBehaviour
No network ever100% functional via local store
Write offlineSaved locally now; queued; UI updates immediately
Network returnsSyncEngine drains queue; remote updated
Remote changed elsewherePulled next cycle; conflict policy applied; local updated
Auth expiredSync pauses; local CRUD unaffected; resumes after re-auth
Backend retiredSwap 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:

ConcernAndroid-onlyKMP
Layer boundaryPackages or Gradle modulesGradle modules (:domain, :data, :feature:*)
Platform codeDirectexpect/actual in commonMain + androidMain/iosMain
Local storeRoomSQLDelight (or Room KMP)
DIHilt or KoinKoin or kotlin-inject
ViewModelandroidx.lifecycle.ViewModelSame — shared in commonMain
Dispatchers.IOavailableavailable 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.

TypePurpose
MviViewModel<S,I,E>MVI base: state StateFlow, effect Channel, pure reduce, onIntent
UiState / UiIntent / UiEffectMarker interfaces for the contracts
AppResult<T> / AppErrorSealed result + error for every async/fallible call
UseCase<P,R> / FlowUseCase<P,R>Single-shot / streaming business operation, dispatcher-bound
DispatcherProviderInjectable dispatchers (io / default / main)
safeCall / safeFlowWrap I/O into AppResult
OfflineMutationQueue, SyncMutation, RemoteSyncDataSourceOffline-first sync contracts

7. Error handling

8. Performance

Laws → Coding Standards. Tests → Testing.