Core Project Architecture

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.

Modularization Strategy

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

Module Types

Project Structure

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.

Clean Architecture Layers

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

Layer Dependencies

flowchart LR
    Data[":data"] --> Domain[":domain"]
    Presentation[":presentation"] --> Domain
    Presentation --> Api[":api"]

Feature Dependencies

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:

%%{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 → 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.

Error Propagation

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.

LayerError typeRole
:serviceResponseErrorRaw HTTP/API outcomes (HttpError, ApiError, NetworkError, UnknownError)
core:domainFailureDomainDataDomain-agnostic failure categories (Network, Server, Unknown), each wrapping a Throwable

Flow

  1. Service layersafeApiCall() wraps every Ktor call, returning Either<ResponseError, T>.
  2. Data layer — repository calls the service, then applies .mapError() to convert ResponseErrorFailureDomainData. Local errors use Either.Left(FailureDomainData.Unknown(...)).
  3. Domain layer — use cases chain operations with .flatMap or Arrow's either { } builder with .bind() for imperative-style short-circuit propagation.
  4. Presentation layer — ViewModels consume the result via .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.

Convention Plugins

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:

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)

Follow a Single-State Architecture (MVI-pattern) for the presentation layer.

Stateless UI and Event Handling

Separate state management from UI rendering to promote stateless UI components and improve testability.

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.

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

Navigation Pattern

Use a typed navigation system where each screen is associated with a specific destination type.

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,
) {
    // ... use destination.itemId
}

Outgoing Navigation

ViewModels can use a navigation function to trigger navigation to other screens via the destination type.

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

Use 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