You have learned Ktor for networking, SQLDelight for databases, Koin for dependency injection, and shared ViewModels. You have all the building blocks. But how do you organize them?

Without structure, your project becomes a mess. Networking code mixed with UI logic. Database queries inside ViewModels. Platform code scattered everywhere.

Clean Architecture solves this. It gives you clear layers with clear rules about what goes where and what can depend on what.

The Three Layers

Clean Architecture in KMP has three layers:

┌─────────────────────────────────┐
│         UI Layer                │  ← Platform-specific (Compose / SwiftUI)
│  Screens, Components, Theme    │
├─────────────────────────────────┤
│       Domain Layer              │  ← Shared (commonMain)
│  Models, Use Cases, Interfaces │
├─────────────────────────────────┤
│        Data Layer               │  ← Shared (commonMain) + platform parts
│  Repositories, API, Database   │
└─────────────────────────────────┘

The Dependency Rule

The most important rule: dependencies point inward. Each layer can only depend on the layer below it.

  • UI depends on Domain — yes
  • Domain depends on Data — no
  • Data depends on Domain — yes (implements interfaces defined in Domain)

Domain is the center. It knows nothing about Ktor, SQLDelight, Compose, or SwiftUI. It only contains pure Kotlin: models, interfaces, and business logic.

Layer 1: Domain

The domain layer contains your business logic. It has no external dependencies — no frameworks, no libraries, just Kotlin.

Models

// shared/src/commonMain/kotlin/domain/model/Note.kt

data class Note(
    val id: String,
    val title: String,
    val content: String,
    val createdAt: Long,
    val updatedAt: Long,
    val isFavorite: Boolean = false
)
// shared/src/commonMain/kotlin/domain/model/User.kt

data class User(
    val id: String,
    val name: String,
    val email: String,
    val avatarUrl: String? = null
)

These are plain data classes. No annotations, no framework dependencies. They can be used everywhere.

Repository Interfaces

The domain defines what it needs, not how it gets it:

// shared/src/commonMain/kotlin/domain/repository/NoteRepository.kt

interface NoteRepository {
    suspend fun getNotes(): List<Note>
    suspend fun getNoteById(id: String): Note?
    suspend fun createNote(title: String, content: String): Note
    suspend fun updateNote(note: Note): Note
    suspend fun deleteNote(id: String)
    fun observeNotes(): Flow<List<Note>>
}
// shared/src/commonMain/kotlin/domain/repository/UserRepository.kt

interface UserRepository {
    suspend fun getUsers(): List<User>
    suspend fun getUserById(id: String): User?
    suspend fun getCurrentUser(): User?
}

These are interfaces — no implementation. The data layer will implement them.

Use Cases

A use case represents a single action the user can take:

// shared/src/commonMain/kotlin/domain/usecase/GetNotesUseCase.kt

class GetNotesUseCase(
    private val noteRepository: NoteRepository
) {
    suspend operator fun invoke(): List<Note> {
        return noteRepository.getNotes()
            .sortedByDescending { it.updatedAt }
    }
}
// shared/src/commonMain/kotlin/domain/usecase/CreateNoteUseCase.kt

class CreateNoteUseCase(
    private val noteRepository: NoteRepository
) {
    suspend operator fun invoke(title: String, content: String): Result<Note> {
        if (title.isBlank()) {
            return Result.failure(IllegalArgumentException("Title cannot be empty"))
        }
        if (title.length > 200) {
            return Result.failure(IllegalArgumentException("Title too long"))
        }

        return try {
            val note = noteRepository.createNote(title, content.trim())
            Result.success(note)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}
// shared/src/commonMain/kotlin/domain/usecase/ToggleFavoriteUseCase.kt

class ToggleFavoriteUseCase(
    private val noteRepository: NoteRepository
) {
    suspend operator fun invoke(noteId: String): Result<Note> {
        return try {
            val note = noteRepository.getNoteById(noteId)
                ?: return Result.failure(IllegalArgumentException("Note not found"))

            val updated = note.copy(isFavorite = !note.isFavorite)
            val saved = noteRepository.updateNote(updated)
            Result.success(saved)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

Why use cases?

  • They encapsulate business logic in one place
  • They are easy to test (no UI, no framework)
  • They can be reused by different ViewModels
  • They validate input before reaching the data layer

When to Skip Use Cases

Not every action needs a use case. If the logic is just “call repository, return result” with no validation or transformation, you can call the repository directly from the ViewModel:

// Simple pass-through — a use case adds nothing
class DeleteNoteUseCase(private val repo: NoteRepository) {
    suspend operator fun invoke(id: String) = repo.deleteNote(id)
}

// Just call the repository directly instead
viewModelScope.launch {
    noteRepository.deleteNote(noteId)
}

Use “use cases” when there is actual business logic. Skip them when there is not.

Layer 2: Data

The data layer implements the interfaces from the domain layer. It talks to APIs, databases, and local storage.

Data Transfer Objects (DTOs)

DTOs are the shapes that come from your API. They map to domain models:

// shared/src/commonMain/kotlin/data/remote/dto/NoteDto.kt

import kotlinx.serialization.Serializable

@Serializable
data class NoteDto(
    val id: String,
    val title: String,
    val content: String,
    val created_at: Long,
    val updated_at: Long,
    val is_favorite: Boolean
)

// Mapper function
fun NoteDto.toDomain(): Note = Note(
    id = id,
    title = title,
    content = content,
    createdAt = created_at,
    updatedAt = updated_at,
    isFavorite = is_favorite
)

fun Note.toDto(): NoteDto = NoteDto(
    id = id,
    title = title,
    content = content,
    created_at = createdAt,
    updated_at = updatedAt,
    is_favorite = isFavorite
)

Why separate DTOs from domain models?

The API might use snake_case, have extra fields, or change its format. Your domain model stays clean and stable. The mapper handles the translation.

API Service

// shared/src/commonMain/kotlin/data/remote/NoteApiService.kt

class NoteApiService(private val client: HttpClient) {

    private val baseUrl = "https://api.example.com"

    suspend fun getNotes(): List<NoteDto> {
        return client.get("$baseUrl/notes").body()
    }

    suspend fun getNoteById(id: String): NoteDto {
        return client.get("$baseUrl/notes/$id").body()
    }

    suspend fun createNote(title: String, content: String): NoteDto {
        return client.post("$baseUrl/notes") {
            contentType(ContentType.Application.Json)
            setBody(mapOf("title" to title, "content" to content))
        }.body()
    }

    suspend fun updateNote(note: NoteDto): NoteDto {
        return client.put("$baseUrl/notes/${note.id}") {
            contentType(ContentType.Application.Json)
            setBody(note)
        }.body()
    }

    suspend fun deleteNote(id: String) {
        client.delete("$baseUrl/notes/$id")
    }
}

Repository Implementation

The repository combines remote (API) and local (database) data sources:

// shared/src/commonMain/kotlin/data/repository/NoteRepositoryImpl.kt

class NoteRepositoryImpl(
    private val api: NoteApiService,
    private val db: NoteDatabase,
    private val queries: NoteQueries
) : NoteRepository {

    override suspend fun getNotes(): List<Note> {
        return try {
            // Try remote first
            val remoteDtos = api.getNotes()
            // Save to local database
            remoteDtos.forEach { dto ->
                queries.insertNote(
                    id = dto.id,
                    title = dto.title,
                    content = dto.content,
                    createdAt = dto.created_at,
                    updatedAt = dto.updated_at,
                    isFavorite = dto.is_favorite
                )
            }
            remoteDtos.map { it.toDomain() }
        } catch (e: Exception) {
            // Offline — return local data
            queries.getAllNotes().executeAsList().map { entity ->
                Note(
                    id = entity.id,
                    title = entity.title,
                    content = entity.content,
                    createdAt = entity.createdAt,
                    updatedAt = entity.updatedAt,
                    isFavorite = entity.isFavorite
                )
            }
        }
    }

    override fun observeNotes(): Flow<List<Note>> {
        return queries.getAllNotes().asFlow().mapToList(Dispatchers.Default).map { entities ->
            entities.map { entity ->
                Note(
                    id = entity.id,
                    title = entity.title,
                    content = entity.content,
                    createdAt = entity.createdAt,
                    updatedAt = entity.updatedAt,
                    isFavorite = entity.isFavorite
                )
            }
        }
    }

    override suspend fun createNote(title: String, content: String): Note {
        val dto = api.createNote(title, content)
        queries.insertNote(
            id = dto.id,
            title = dto.title,
            content = dto.content,
            createdAt = dto.created_at,
            updatedAt = dto.updated_at,
            isFavorite = dto.is_favorite
        )
        return dto.toDomain()
    }

    override suspend fun getNoteById(id: String): Note? {
        return queries.getNoteById(id).executeAsOneOrNull()?.let { entity ->
            Note(
                id = entity.id,
                title = entity.title,
                content = entity.content,
                createdAt = entity.createdAt,
                updatedAt = entity.updatedAt,
                isFavorite = entity.isFavorite
            )
        }
    }

    override suspend fun updateNote(note: Note): Note {
        val dto = api.updateNote(note.toDto())
        queries.insertNote(
            id = dto.id,
            title = dto.title,
            content = dto.content,
            createdAt = dto.created_at,
            updatedAt = dto.updated_at,
            isFavorite = dto.is_favorite
        )
        return dto.toDomain()
    }

    override suspend fun deleteNote(id: String) {
        api.deleteNote(id)
        queries.deleteNote(id)
    }
}

This is the offline-first pattern: try the API, cache locally, fall back to cache when offline.

Layer 3: UI

The UI layer is platform-specific. On Android, it is Compose. On iOS, it is SwiftUI (or Compose Multiplatform if you share UI).

// composeApp/src/androidMain/kotlin/ui/NoteListScreen.kt

@Composable
fun NoteListScreen(
    viewModel: NoteListViewModel = koinViewModel()
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    Scaffold(
        floatingActionButton = {
            FloatingActionButton(onClick = { viewModel.onCreateNote() }) {
                Icon(Icons.Default.Add, contentDescription = "Create note")
            }
        }
    ) { padding ->
        when {
            state.isLoading -> LoadingIndicator()
            state.error != null -> ErrorMessage(
                message = state.error!!,
                onRetry = { viewModel.loadNotes() }
            )
            state.notes.isEmpty() -> EmptyState(message = "No notes yet")
            else -> NoteList(
                notes = state.notes,
                onNoteClick = { viewModel.onNoteSelected(it) },
                onFavoriteClick = { viewModel.toggleFavorite(it.id) },
                modifier = Modifier.padding(padding)
            )
        }
    }
}

The ViewModel (in commonMain) handles all logic. The screen just displays the state.

Wiring with Koin

Connect all layers using Koin:

// shared/src/commonMain/kotlin/di/DomainModule.kt

val domainModule = module {
    factory { GetNotesUseCase(get()) }
    factory { CreateNoteUseCase(get()) }
    factory { ToggleFavoriteUseCase(get()) }
}

// shared/src/commonMain/kotlin/di/DataModule.kt

val dataModule = module {
    single { NoteApiService(get()) }
    single<NoteRepository> { NoteRepositoryImpl(get(), get(), get()) }
}

// shared/src/commonMain/kotlin/di/ViewModelModule.kt

val viewModelModule = module {
    viewModelOf(::NoteListViewModel)
    viewModelOf(::CreateNoteViewModel)
}

Notice single<NoteRepository> { NoteRepositoryImpl(...) }. Koin registers the implementation (NoteRepositoryImpl) under the interface type (NoteRepository). When a use case asks for NoteRepository, it gets NoteRepositoryImpl.

Folder Structure

Here is the recommended folder structure for a KMP project with Clean Architecture:

shared/src/
├── commonMain/kotlin/
│   ├── domain/
│   │   ├── model/
│   │   │   ├── Note.kt
│   │   │   └── User.kt
│   │   ├── repository/
│   │   │   ├── NoteRepository.kt      (interface)
│   │   │   └── UserRepository.kt      (interface)
│   │   └── usecase/
│   │       ├── GetNotesUseCase.kt
│   │       ├── CreateNoteUseCase.kt
│   │       └── ToggleFavoriteUseCase.kt
│   ├── data/
│   │   ├── remote/
│   │   │   ├── dto/
│   │   │   │   └── NoteDto.kt
│   │   │   └── NoteApiService.kt
│   │   ├── local/
│   │   │   └── (SQLDelight .sq files)
│   │   └── repository/
│   │       ├── NoteRepositoryImpl.kt   (implements interface)
│   │       └── UserRepositoryImpl.kt
│   ├── di/
│   │   ├── AppModule.kt
│   │   ├── DomainModule.kt
│   │   ├── DataModule.kt
│   │   └── ViewModelModule.kt
│   └── viewmodel/
│       ├── NoteListViewModel.kt
│       └── CreateNoteViewModel.kt
├── androidMain/kotlin/
│   └── di/
│       └── PlatformModule.android.kt
└── iosMain/kotlin/
    └── di/
        └── PlatformModule.ios.kt

Everything business-related is in commonMain. Platform folders contain only platform-specific implementations (like database drivers and DI context).

When to Use Clean Architecture

Clean Architecture adds folders and files. For a small app, it can feel like overkill. Here is a guide:

Project SizeRecommendation
Small (1-3 screens)Skip use cases, use ViewModel → Repository directly
Medium (4-10 screens)Use cases for complex logic, skip for simple CRUD
Large (10+ screens, team)Full Clean Architecture with all layers

Start simple. Add layers when complexity justifies them. You can always refactor later.

Common Mistakes

Mistake 1: Domain Depends on Framework

// BAD — domain model depends on SQLDelight
data class Note(
    val id: String,
    val title: String,
) : NoteEntity  // SQLDelight generated class in domain = wrong

// GOOD — domain model is pure Kotlin
data class Note(
    val id: String,
    val title: String,
)

Mistake 2: Skipping Mappers

// BAD — using API response directly in UI
val state = apiResponse  // API changes break your UI

// GOOD — map to domain model
val state = apiResponse.toDomain()  // API changes only break the mapper

Mistake 3: Business Logic in ViewModel

// BAD — validation in ViewModel
class NoteViewModel : ViewModel() {
    fun create(title: String) {
        if (title.isBlank()) { /* error */ }
        if (title.length > 200) { /* error */ }
        // ... more validation
    }
}

// GOOD — validation in use case (reusable, testable)
class CreateNoteUseCase(private val repo: NoteRepository) {
    suspend operator fun invoke(title: String, content: String): Result<Note> {
        if (title.isBlank()) return Result.failure(...)
        // ViewModel just calls: createNoteUseCase(title, content)
    }
}

Source Code

The KMP tutorial project is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Navigation in Compose Multiplatform — type-safe routes, bottom navigation, and deep linking across Android and iOS.

See you there.