Kotlin Multiplatform project setup (Compose UI, Clean Architecture, MVI)
Covers Gradle module structure, Clean Architecture layers, naming conventions, error propagation, and the MVI-based presentation pattern.
Follow a Feature-based Modularization approach, complemented by core and
service modules to promote separation of concerns and scalability.
core:*: Shared infrastructure and utilities. Key modules include:
core:navigation — Typed navigation system using AppDestination.core:assets — Shared resources (strings, icons, etc.) generated by Compose Resources.core:presentation — Base classes for UI, including ScreenBuilderViewModel.core:domain — Fundamental domain models and logic shared across features.feature:*: Business logic and UI for specific application features.service:*: Low-level technical services or external API wrappers (e.g., Ktor API client, analytics, push notifications). These are dependency-free modules that expose narrow, domain-agnostic interfaces consumed exclusively by :data.Every project follows a consistent top-level layout:
assembly/ # App assembly — Compose Multiplatform module that wires features
app/
android/ # Android application shell (Activity, manifest, resources)
desktop/ # Desktop entry point (Main.kt, native distribution config)
core/
assets/ # Shared Compose resources (strings, icons, fonts)
domain/ # Fundamental domain models and business logic
navigation/ # Typed navigation system (AppDestination, ScreenComposable)
presentation/ # Base UI classes (ScreenBuilderViewModel, etc.)
feature/
<name>/api # Navigation destinations
<name>/domain # Pure business logic, use cases, repository interfaces
<name>/data # Repository implementations
<name>/presentation # UI components and ViewModels
service/
<name>/ # Technical services (Ktor API client, analytics, etc.)
Library-focused projects (e.g., design, storage, navigation) omit
assembly/ and app/, publishing their core/ modules to Maven
instead. Application projects include all directories above.
Each feature module is typically subdivided into layers to enforce architectural boundaries:
:api: Navigation destinations for app-shell wiring. Not a cross-feature contract.:domain: Pure business logic, use cases, and repository interfaces. The sharable business capability layer — any feature may depend on another feature's :domain to reuse its behavior. Depends only on core:domain.:data: Repository implementations internal to the feature. When local caching is needed alongside the :service layer, the repository may split into an internal cache data source (in-memory store, DataStore, or similar) that it coordinates with the service. Without a cache, the repository calls the service directly — no extra layers. Depends on its own :domain only for the repository interfaces it implements, plus :service:* modules for technical infrastructure.:presentation: UI components and ViewModels internal to the feature. Depends on its own :domain and :api, plus core:presentation.
flowchart LR
Data[":data"] --> Domain[":domain"]
Presentation[":presentation"] --> Domain
Presentation --> Api[":api"]
The following diagram illustrates the typical dependency flow:
flowchart TB
subgraph Presentation["Presentation Layer"]
direction LR
P[":feature:<name>:presentation"] --> A[":api"]
P --> CP[":core:presentation"]
P --> CN[":core:navigation"]
end
subgraph Domain["Domain Layer"]
direction LR
Dom[":feature:<name>:domain"] --> CD[":core:domain"]
end
subgraph Data["Data Layer"]
direction LR
D[":feature:<name>:data"] --> S[":service:*"]
end
P --- Dom
Dom --- D
Features may depend on each other in specific, architecturally valid ways. The patterns differ based on whether only domain logic is reused, or presentation models are reused as well.
Domain reuse only — the consumer has no :domain of its own:
%%{init: {'flowchart': {'curve': 'linear'}}}%%
flowchart LR
subgraph Source["feature:source"]
direction TB
SDOM[":domain"]
SDATA[":data"]
SPRES["(:presentation)"]
SAPI[":api"]
SDOM --- SDATA
SDOM --- SPRES
end
subgraph Consumer["feature:consumer"]
direction TB
CPRES[":presentation"]
end
CPRES --> SDOM
CPRES --> SAPI
Domain and presentation reuse — the consumer has both
:domain (depending on source's domain) and :presentation
(reusing source presentation models):
%%{init: {'flowchart': {'curve': 'linear'}}}%%
flowchart LR
subgraph Source["feature:source"]
direction TB
SDOM[":domain"]
SDATA[":data"]
SPRES["(:presentation)"]
SAPI[":api"]
SDOM --- SDATA
SDOM --- SPRES
end
subgraph Consumer["feature:consumer"]
direction TB
CPRES[":presentation"]
CDOM[":domain"]
CDOM --- CPRES
end
CDOM --> SDOM
CPRES --> SDOM
CPRES --> SAPI
CPRES -.-> SPRES
| Consumer → Source | Allowed | Rationale |
|---|---|---|
:domain → :domain | ✅ | Inner→inner. One bounded context reuses another's business capability. |
:presentation → :domain | ✅ | Outer→inner. Saves boilerplate wrappers — API changes propagate regardless. |
:presentation → :presentation | ✅ | Pragmatic model/mapping reuse over duplication or polluting core:. |
:presentation → :api | ✅ | Navigation destination access for the consumer's UI layer. |
:data → :domain | ❌ | Data implements its own feature's repository contracts only. Cross-feature orchestration belongs in :domain. |
Any → :data | ❌ | Data is an internal implementation layer. |
:domain → :api | ❌ | :api is for navigation destinations consumed by the presentation layer, not domain logic. |
:domain → :presentation | ❌ | Domain is pure business logic and must not depend on volatile UI modules. |
Expected failures cross layer boundaries as typed sealed types, never as exceptions.
Use Arrow's Either<L, R> for all fallible operations, with a two-level
error model.
| Layer | Error type | Role |
|---|---|---|
:service | ResponseError | Raw HTTP/API outcomes (HttpError, ApiError, NetworkError, UnknownError) |
core:domain | FailureDomainData | Domain-agnostic failure categories (Network, Server, Unknown), each wrapping a Throwable |
safeApiCall() wraps every Ktor call, returning Either<ResponseError, T>..mapError() to convert ResponseError → FailureDomainData. Local errors use Either.Left(FailureDomainData.Unknown(...))..flatMap or Arrow's either { } builder with .bind() for imperative-style short-circuit propagation..onLeft { } / .onRight { } or .fold(). Domain errors are mapped to per-screen FormError enums or read directly from failure.throwable.message.
FailureDomainData is a sealed interface that can be extended when the app
needs more failure categories — add new subclasses alongside the existing Network,
Server, and Unknown variants.
Build configuration is centralized in a shared gradle-conventions
repository — a standalone Gradle project with convention plugins and a curated version catalog.
All projects include it via settings.gradle.kts to:
conv) for all dependencies (Kotlin, Compose, Koin, Arrow, Ktor, etc.).FeatureDomainConventionPlugin adds core:domain,
and FeaturePresentationConventionPlugin wires its sibling :api and :domain modules.Consistent naming helps in navigating the codebase and understanding the role of each component.
| Component | Pattern | Example |
|---|---|---|
| Navigation Destination | {ScreenName}Destination | FeatureDestination |
| Component | Pattern | Example |
|---|---|---|
| Repository Implementation | {Feature}RepositoryImpl | FeatureRepositoryImpl |
| Data/DTO Model | {Name} | Item |
| Cache Model | {Name}Entity | ItemEntity |
| Component | Pattern | Example |
|---|---|---|
| Immediate Use Case | Get{Name}UseCase | GetFeatureDetailsUseCase |
| Reactive Use Case | Observe{Name}UseCase | ObserveFeatureStatusUseCase |
| Repository Interface | {Feature}Repository | FeatureRepository |
| Domain Model | {Name}DomainData | ItemDomainData |
| Static Domain Type | {Name}DomainType | SortingDomainType |
| Component | Pattern | Example |
|---|---|---|
| Screen Composable | {Name}Screen | FeatureScreen |
| Screen Events | {Name}ScreenEvents | FeatureScreenEvents |
| Presentation State | {Name}ScreenState | FeatureListScreenState |
| Stateless UI | {Name}ScreenUi | FeatureScreenUi |
| ViewModel | {Name}ScreenViewModel | FeatureScreenViewModel |
| Presentation Model | {Name}ViewData | FeatureViewData |
| Static View Type | {Name}ViewType | LayoutViewType |
| Component | Pattern | Example |
|---|---|---|
| DI Module | {Feature}{Layer}DiModule | FeatureDomainDiModule |
| Mapping Extensions | {Feature}{Layer}MappingExtensions | FeatureDataMappingExtensions |
Follow a Single-State Architecture (MVI-pattern) for the presentation layer.
MutableState).@Immutable to assist Compose compiler optimizations.State is reserved primarily for UI State (e.g., ThemeState).StateFlow) to the UI.Separate state management from UI rendering to promote stateless UI components and improve testability.
<Name>Screen: A stateful root-level composable that collects the UI state and maps events to the ViewModel.<Name>ScreenUi: A stateless composable that receives the state and an events data class, focusing solely on rendering.<Name>ScreenEvents: A data class that encapsulates all UI-to-ViewModel interactions, scoping every callback into a single type and avoiding the callback hell that arises when a Composable accumulates dozens of lambda parameters.Design Note (data class vs. sealed events): A sealed-class-per-event approach was considered but not adopted. Data class lambda properties are plain variables — this gives superior IDE navigation for finding entry/exit points, tracking dependencies, and following the event flow. In a sealed approach those connections are lost once the event object is passed as a parameter. Data classes do not suit reusable components that need to share or delegate event handling; they excel for screens with a static structure. For reusable components used within a static screen, their events are passed as lambda parameters within the screen's data class.
Unit, processed asynchronously by the ViewModel. Examples: onBack: () -> Unit, onNextClick: () -> Unit.onSearchQueryChange: (query: String) -> String.Caveat: Transformer events are an opt-in escape hatch for high-frequency UI transforms (keystroke formatting, character filtering). They are not a replacement for UDF — if the ViewModel needs the transformed value for business logic, the raw input must also arrive via a separate fire-and-forget event that updates screen state.
data class SampleScreenEvents(
val onBack: () -> Unit,
val onNextClick: () -> Unit,
val onPreviousClick: () -> Unit,
val onSearchQueryChange: (query: String) -> String,
)
@Composable
fun SampleScreen(
destination: SampleDestination,
viewModel: SampleScreenViewModel = koinViewModel(parameters = { parametersOf(destination) })
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val events = remember {
SampleScreenEvents(
onBack = viewModel::onBack,
onNextClick = viewModel::onNextClick,
onPreviousClick = viewModel::onPreviousClick,
onSearchQueryChange = viewModel::formatSearchQuery,
)
}
SampleScreenUi(
state = state,
events = events,
)
}
@Composable
fun SampleScreenUi(
state: SampleScreenState,
events: SampleScreenEvents,
modifier: Modifier = Modifier,
) {
// UI rendering using state and events
}
Use a typed navigation system where each screen is associated with a specific destination type.
Screen composables receive their destination as a parameter and pass it to the ViewModel using Koin parameters:
@Composable
fun FeatureScreen(
destination: FeatureDestination,
viewModel: FeatureScreenViewModel = koinViewModel(parameters = { parametersOf(destination) })
) {
ScreenComposable(viewModel)
}
ViewModels receive the destination via the @InjectedParam annotation:
@KoinViewModel
class FeatureScreenViewModel(
@InjectedParam private val destination: FeatureDestination,
@Provided private val getDetailsUseCase: GetFeatureDetailsUseCase,
) {
// ... use destination.itemId
}
ViewModels can use a navigation function to trigger navigation to other screens via the destination type.
To maintain consistency in business logic implementation, use cases follow these rules:
operator fun invoke.Either, Result, or plain data) must be prefixed with Get (e.g., GetFeatureDetailsUseCase).Flow) must be prefixed with Observe (e.g., ObserveFeatureStatusUseCase).
To maintain isolation between layers, use extension functions to map models between different architectural layers.
These mapping functions are typically grouped into MappingExtensions files within the layer that performs the mapping.
{Feature}{Layer}MappingExtensions pattern.as{TargetType} pattern (e.g., asDomainData(), asViewData(), asParameter()).internal visibility for mapping functions when they are only used within the same module.fun FeatureEntity.asDomainData(): FeatureDomainData {
return FeatureDomainData(
id = id,
title = title,
durationMs = duration,
)
}
Use Koin with Koin Annotations for dependency injection. This approach reduces boilerplate by using compile-time code generation for module definitions.
@Module: Defines a Koin module class.@ComponentScan: Automatically scans the package for annotated components.@Single: Declares a singleton component.@Factory: Declares a component that is recreated every time it's requested.@KoinViewModel: Declares a ViewModel for use in Compose.Typically, each Clean Architecture layer in a feature has its own DI module:
FeatureDomainDiModule (in :domain)FeatureDataDiModule (in :data)FeaturePresentationDiModule (in :presentation)For dependencies that require platform-specific implementations (e.g., SQLDelight drivers), use the expect/actual pattern:
expect val module: Module in the commonMain source set.actual implementation in platform source sets (e.g., androidMain, jvmMain), including platform-specific declarations using @Module and standard Koin DSL module { ... }.includes parameter of the @Module annotation.