Kotlin Multiplatform project setup (Compose UI, Clean Architecture, MVI)
Covers Gradle module structure, Clean Architecture layers, naming conventions, and the MVI-based presentation pattern, with Zagart libraries as a supporting component.
The project follows 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.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. 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.Standard Clean Architecture dependency flow between layers:
flowchart LR
UI["UI / Presentation"]
Data["Data / Infrastructure"]
Dom["Domain / Business Logic"]
UI --> Dom
Data --> Dom
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:
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):
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
CDOM[":domain"]
CPRES[":presentation"]
CDOM --- CPRES
end
CPRES --> SDOM
CDOM --> 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. |
Use a build-logic module with Convention Plugins to:
FeatureDomainConventionPlugin automatically adds core:domain dependency).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 |
The project follows 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.Most screens can leverage the zagart-design library for state construction and UI rendering.
ScreenBuilderViewModel use DSL-like builders (e.g., screen { ... }, loading { ... }) to construct the ScreenState.For screens not using the Zagart Design System, 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.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 onPrevious: () -> 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::onNext,
onPrevious = viewModel::onPrevious,
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 AppDestination (from the zagart-navigation library).
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,
) : ScreenBuilderViewModel() {
// ... use destination.itemId
}
ViewModels inheriting from ScreenBuilderViewModel use the navigate(destination) function to trigger navigation to other screens.
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,
)
}
The project uses 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.