Project Architecture

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.

Modularization Strategy

The project follows a Feature-based Modularization approach, complemented by core and service modules to promote separation of concerns and scalability.

Module Types

Clean Architecture Layers

Each feature module is typically subdivided into layers to enforce architectural boundaries:

Layer Dependency Overview

Standard Clean Architecture dependency flow between layers:

flowchart LR
    UI["UI / Presentation"]
    Data["Data / Infrastructure"]
    Dom["Domain / Business Logic"]

    UI --> Dom
    Data --> Dom

Dependency Rules

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

Cross-Feature Dependencies

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 → SourceAllowedRationale
: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.

Convention Plugins

Use a build-logic module with Convention Plugins to:

Naming Conventions

Consistent naming helps in navigating the codebase and understanding the role of each component.

API

ComponentPatternExample
Navigation Destination{ScreenName}DestinationFeatureDestination

Data

ComponentPatternExample
Repository Implementation{Feature}RepositoryImplFeatureRepositoryImpl
Data/DTO Model{Name}Item
Cache Model{Name}EntityItemEntity

Domain

ComponentPatternExample
Immediate Use CaseGet{Name}UseCaseGetFeatureDetailsUseCase
Reactive Use CaseObserve{Name}UseCaseObserveFeatureStatusUseCase
Repository Interface{Feature}RepositoryFeatureRepository
Domain Model{Name}DomainDataItemDomainData
Static Domain Type{Name}DomainTypeSortingDomainType

Presentation

ComponentPatternExample
Screen Composable{Name}ScreenFeatureScreen
Screen Events{Name}ScreenEventsFeatureScreenEvents
Presentation State{Name}ScreenStateFeatureListScreenState
Stateless UI{Name}ScreenUiFeatureScreenUi
ViewModel{Name}ScreenViewModelFeatureScreenViewModel
Presentation Model{Name}ViewDataFeatureViewData
Static View Type{Name}ViewTypeLayoutViewType

Other

ComponentPatternExample
DI Module{Feature}{Layer}DiModuleFeatureDomainDiModule
Mapping Extensions{Feature}{Layer}MappingExtensionsFeatureDataMappingExtensions

Presentation State Management (MVI)

The project follows a Single-State Architecture (MVI-pattern) for the presentation layer.

Zagart Design System

Most screens can leverage the zagart-design library for state construction and UI rendering.

Stateless UI and Event Handling

For screens not using the Zagart Design System, separate state management from UI rendering to promote stateless UI components and improve testability.

Event Types

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.

Example

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
}

Navigation Pattern

Use a typed navigation system where each screen is associated with a specific AppDestination (from the zagart-navigation library).

Screen Entry Point

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)
}

ViewModel Injection

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
}

Outgoing Navigation

ViewModels inheriting from ScreenBuilderViewModel use the navigate(destination) function to trigger navigation to other screens.

Use Cases

To maintain consistency in business logic implementation, use cases follow these rules:

Mapping

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.

Example

fun FeatureEntity.asDomainData(): FeatureDomainData {
    return FeatureDomainData(
        id = id,
        title = title,
        durationMs = duration,
    )
}

Dependency Injection

The project uses Koin with Koin Annotations for dependency injection. This approach reduces boilerplate by using compile-time code generation for module definitions.

Key Annotations

Module Structure

Typically, each Clean Architecture layer in a feature has its own DI module:

Platform-Specific Dependencies

For dependencies that require platform-specific implementations (e.g., SQLDelight drivers), use the expect/actual pattern:

  1. Define an expect val module: Module in the commonMain source set.
  2. Provide the actual implementation in platform source sets (e.g., androidMain, jvmMain), including platform-specific declarations using @Module and standard Koin DSL module { ... }.
  3. Include these modules in the main feature module using the includes parameter of the @Module annotation.
← Back