Welcome to Part 4 of our KMP series — Build a Real App. In the next four tutorials, we build a complete cross-platform Notes app from scratch. This article covers the planning phase: architecture decisions, tech stack, project structure, and the initial setup that makes everything work on both Android and iOS.

By the end of this tutorial, you will have a working project skeleton with shared business logic, a SQLDelight database, Koin dependency injection, and basic UI on both platforms.

What We Are Building

A cross-platform Notes app with these features:

  • Create, edit, and delete notes
  • Search notes by title and content
  • Favorite notes with a toggle
  • Color-coded notes (7 colors)
  • Offline-first — everything works without internet
  • Shared business logic — one codebase for Android and iOS

The app uses native UI: Jetpack Compose on Android, SwiftUI on iOS. The business logic, database, and ViewModels live in the shared module.

Architecture Overview

We follow the same clean architecture pattern from KMP Tutorial #11:

┌─────────────────────────────────────────────┐
│                  UI Layer                    │
│   Android: Jetpack Compose                  │
│   iOS: SwiftUI                              │
└──────────────────┬──────────────────────────┘
┌──────────────────▼──────────────────────────┐
│             Domain Layer (shared)            │
│   NotesViewModel, NoteDetailViewModel       │
└──────────────────┬──────────────────────────┘
┌──────────────────▼──────────────────────────┐
│              Data Layer (shared)             │
│   NoteRepository → LocalNoteDataSource      │
│   SQLDelight database                       │
└─────────────────────────────────────────────┘

What is shared:

  • Data models (Note, NoteColor)
  • Database schema and queries (SQLDelight)
  • Data source and repository
  • ViewModels with business logic
  • Dependency injection modules

What stays platform-specific:

  • UI (Compose on Android, SwiftUI on iOS)
  • Database driver (Android SQLite driver, iOS Native driver)
  • Application entry point

Tech Stack

LayerLibraryWhy
DatabaseSQLDelightSQL-first, generates type-safe Kotlin from .sq files
DIKoinWorks in commonMain, no annotation processing needed
NetworkingKtorFor future sync feature (Part 16)
Serializationkotlinx-serializationJSON encoding for API and data classes
Date/Timekotlinx-datetimeCross-platform date handling
ViewModelandroidx.lifecycleOfficial multiplatform ViewModel support

We covered each of these libraries in earlier tutorials (Ktor #6, SQLDelight #7, Koin #9, Shared ViewModel #10).

Project Structure

Here is the module layout:

kmp-tutorial/
├── shared/                          # Shared Kotlin module
│   └── src/
│       ├── commonMain/
│       │   ├── kotlin/.../
│       │   │   ├── model/           # Data classes
│       │   │   │   └── Note.kt
│       │   │   ├── data/
│       │   │   │   ├── local/       # SQLDelight data source
│       │   │   │   │   ├── LocalNoteDataSource.kt
│       │   │   │   │   └── NoteMapper.kt
│       │   │   │   └── repository/
│       │   │   │       └── NoteRepository.kt
│       │   │   ├── domain/          # ViewModels
│       │   │   │   ├── NotesViewModel.kt
│       │   │   │   └── NoteDetailViewModel.kt
│       │   │   ├── di/              # Koin modules
│       │   │   │   ├── SharedModule.kt
│       │   │   │   └── DatabaseDriverFactory.kt
│       │   │   └── util/
│       │   │       └── FlowCollector.kt
│       │   └── sqldelight/.../db/
│       │       └── Note.sq          # Database schema
│       ├── androidMain/             # Android-specific
│       │   └── kotlin/.../di/
│       │       ├── DatabaseDriverFactory.android.kt
│       │       └── PlatformModule.android.kt
│       └── iosMain/                 # iOS-specific
│           └── kotlin/.../di/
│               ├── DatabaseDriverFactory.ios.kt
│               ├── PlatformModule.ios.kt
│               └── KoinHelper.kt
├── composeApp/                      # Android app (Compose UI)
│   └── src/androidMain/
│       ├── kotlin/.../
│       │   ├── App.kt              # Main Compose screen
│       │   ├── MainActivity.kt
│       │   └── NotesApplication.kt  # Koin initialization
│       └── AndroidManifest.xml
└── iosApp/                          # iOS app (SwiftUI)
    └── iosApp/
        ├── iOSApp.swift             # Koin initialization
        ├── ContentView.swift        # Main SwiftUI screen
        └── NotesViewModelWrapper.swift

Step 1: Add Dependencies

First, add the library versions to gradle/libs.versions.toml:

[versions]
# ... existing versions ...
sqldelight = "2.2.1"
koin = "4.1.1"
ktor = "3.4.0"
kotlinxSerialization = "1.10.0"
kotlinxDatetime = "0.7.1"
kotlinxCoroutines = "1.10.2"

[libraries]
# SQLDelight
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }

# Koin
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }

# Ktor
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }

# Kotlinx
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }

[plugins]
# ... existing plugins ...
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }

Then update the shared module’s build.gradle.kts:

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.kotlinSerialization)
    alias(libs.plugins.sqldelight)
}

kotlin {
    // ... targets ...

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.kotlinx.datetime)
            implementation(libs.sqldelight.runtime)
            implementation(libs.sqldelight.coroutines)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.contentNegotiation)
            implementation(libs.ktor.serialization.json)
            implementation(libs.koin.core)
            implementation(libs.androidx.lifecycle.viewmodel)
        }

        androidMain.dependencies {
            implementation(libs.sqldelight.android)
            implementation(libs.ktor.client.okhttp)
        }

        iosMain.dependencies {
            implementation(libs.sqldelight.native)
            implementation(libs.ktor.client.darwin)
        }
    }
}

sqldelight {
    databases {
        create("NotesDatabase") {
            packageName.set("com.kemalcodes.kmptutorial.db")
        }
    }
}

Step 2: Define the Data Model

Our Note data class lives in commonMain. It is shared between Android and iOS:

// shared/src/commonMain/.../model/Note.kt
@Serializable
data class Note(
    val id: Long = 0,
    val title: String,
    val content: String,
    val createdAt: Instant,
    val updatedAt: Instant,
    val isFavorite: Boolean = false,
    val color: NoteColor = NoteColor.DEFAULT
)

@Serializable
enum class NoteColor {
    DEFAULT, RED, ORANGE, YELLOW, GREEN, BLUE, PURPLE
}

The @Serializable annotation lets us encode notes as JSON later (for the sync feature in Part 16). We use kotlin.time.Instant for timestamps — in Kotlin 2.3+, Instant and Clock moved from kotlinx.datetime to kotlin.time in the standard library.

Step 3: Create the Database Schema

SQLDelight uses .sq files to define tables and queries. The SQL runs on both Android (SQLite) and iOS (native SQLite):

-- shared/src/commonMain/sqldelight/.../db/Note.sq

CREATE TABLE NoteEntity (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    is_favorite INTEGER NOT NULL DEFAULT 0,
    color TEXT NOT NULL DEFAULT 'DEFAULT'
);

getAllNotes:
SELECT * FROM NoteEntity ORDER BY updated_at DESC;

getNoteById:
SELECT * FROM NoteEntity WHERE id = ?;

searchNotes:
SELECT * FROM NoteEntity
WHERE title LIKE '%' || ? || '%' OR content LIKE '%' || ? || '%'
ORDER BY updated_at DESC;

getFavoriteNotes:
SELECT * FROM NoteEntity WHERE is_favorite = 1 ORDER BY updated_at DESC;

insertNote:
INSERT INTO NoteEntity (title, content, created_at, updated_at, is_favorite, color)
VALUES (?, ?, ?, ?, ?, ?);

getLastInsertId:
SELECT last_insert_rowid();

updateNote:
UPDATE NoteEntity SET title = ?, content = ?, updated_at = ?, is_favorite = ?, color = ?
WHERE id = ?;

deleteNote:
DELETE FROM NoteEntity WHERE id = ?;

We store timestamps as INTEGER (epoch milliseconds) because SQLite does not have a native date type. The color column is a TEXT storing the enum name.

SQLDelight generates a NoteEntity class and type-safe query functions from this file. We map NoteEntity to our domain Note model:

// shared/src/commonMain/.../data/local/NoteMapper.kt
fun NoteEntity.toNote(): Note = Note(
    id = id,
    title = title,
    content = content,
    createdAt = Instant.fromEpochMilliseconds(created_at),
    updatedAt = Instant.fromEpochMilliseconds(updated_at),
    isFavorite = is_favorite == 1L,
    color = try {
        NoteColor.valueOf(color)
    } catch (_: IllegalArgumentException) {
        NoteColor.DEFAULT
    }
)

Step 4: Build the Data Layer

The LocalNoteDataSource wraps SQLDelight queries and returns Flow for reactive updates:

// shared/src/commonMain/.../data/local/LocalNoteDataSource.kt
class LocalNoteDataSource(private val db: NotesDatabase) {
    private val queries = db.noteQueries

    fun getAllNotes(): Flow<List<Note>> =
        queries.getAllNotes()
            .asFlow()
            .mapToList(Dispatchers.IO)
            .map { entities: List<NoteEntity> -> entities.map { it.toNote() } }

    fun searchNotes(query: String): Flow<List<Note>> =
        queries.searchNotes(query, query)
            .asFlow()
            .mapToList(Dispatchers.IO)
            .map { entities: List<NoteEntity> -> entities.map { it.toNote() } }

    fun insertNote(title: String, content: String, color: NoteColor = NoteColor.DEFAULT): Long {
        val now = Clock.System.now().toEpochMilliseconds()
        queries.insertNote(
            title = title, content = content,
            created_at = now, updated_at = now,
            is_favorite = 0L, color = color.name
        )
        return queries.getLastInsertId().executeAsOne()
    }

    // ... updateNote, deleteNote, getNoteById, getFavoriteNotes
}

The NoteRepository sits on top:

class NoteRepository(private val localDataSource: LocalNoteDataSource) {
    fun getAllNotes(): Flow<List<Note>> = localDataSource.getAllNotes()
    fun searchNotes(query: String): Flow<List<Note>> = localDataSource.searchNotes(query)
    fun createNote(title: String, content: String, color: NoteColor = NoteColor.DEFAULT): Long =
        localDataSource.insertNote(title, content, color)
    fun updateNote(note: Note) = localDataSource.updateNote(note)
    fun deleteNote(id: Long) = localDataSource.deleteNote(id)
    // ...
}

Right now, the repository just delegates to the local data source. In Part 16, we add a remote data source for syncing.

Step 5: Create the Shared ViewModel

The NotesViewModel lives in commonMain and works on both platforms:

class NotesViewModel(private val repository: NoteRepository) : ViewModel() {

    private val _searchQuery = MutableStateFlow("")
    val searchQuery: StateFlow<String> = _searchQuery

    val notes: StateFlow<List<Note>> = _searchQuery
        .flatMapLatest { query ->
            if (query.isBlank()) repository.getAllNotes()
            else repository.searchNotes(query)
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun onSearchQueryChange(query: String) {
        _searchQuery.value = query
    }

    fun createNote(title: String, content: String, color: NoteColor = NoteColor.DEFAULT) {
        viewModelScope.launch { repository.createNote(title, content, color) }
    }

    fun toggleFavorite(note: Note) {
        viewModelScope.launch {
            repository.updateNote(note.copy(isFavorite = !note.isFavorite))
        }
    }

    fun deleteNote(id: Long) {
        viewModelScope.launch { repository.deleteNote(id) }
    }
}

Key pattern: flatMapLatest automatically switches the Flow when the search query changes. If the user types “hello”, the previous query’s Flow is cancelled and a new one starts.

Step 6: Set Up Dependency Injection

Platform-specific database drivers

The expect/actual pattern provides different SQLite drivers per platform:

// commonMain
expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

// androidMain
actual class DatabaseDriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver =
        AndroidSqliteDriver(NotesDatabase.Schema, context, "notes.db")
}

// iosMain
actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver =
        NativeSqliteDriver(NotesDatabase.Schema, "notes.db")
}

Koin modules

The shared module defines the DI graph:

// commonMain
val sharedModule = module {
    singleOf(::LocalNoteDataSource)
    singleOf(::NoteRepository)
    factoryOf(::NotesViewModel)
    factoryOf(::NoteDetailViewModel)
}

// androidMain
val platformModule = module {
    single { DatabaseDriverFactory(get()) }
    single { NotesDatabase(get<DatabaseDriverFactory>().createDriver()) }
}

// iosMain
val platformModule = module {
    single { DatabaseDriverFactory() }
    single { NotesDatabase(get<DatabaseDriverFactory>().createDriver()) }
}

Initialization

Android — initialize Koin in the Application class:

class NotesApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@NotesApplication)
            modules(platformModule, sharedModule)
        }
    }
}

iOS — initialize Koin from Swift via a helper:

// iosMain
object KoinHelper {
    private lateinit var koin: Koin

    fun start() {
        koin = startKoin {
            modules(platformModule, sharedModule)
        }.koin
    }

    fun getNoteRepository(): NoteRepository = koin.get()
}

fun initKoinIos() { KoinHelper.start() }
// iOSApp.swift
@main
struct iOSApp: App {
    init() {
        KoinHelperKt.doInitKoinIos()
    }
    // ...
}

Step 7: Consuming Flows from SwiftUI

Kotlin Flow does not work directly in SwiftUI. We need a helper that collects the Flow and calls back to Swift:

// commonMain/.../util/FlowCollector.kt
interface Closeable {
    fun close()
}

class FlowCollector<T>(
    private val flow: Flow<T>,
    private val onEach: (T) -> Unit
) : Closeable {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
    private val job = scope.launch { flow.collect { onEach(it) } }

    override fun close() { job.cancel() }

    companion object {
        fun <T> collect(flow: Flow<T>, onEach: (T) -> Unit): Closeable =
            FlowCollector(flow, onEach)
    }
}

Then in Swift, we create a wrapper that bridges between the shared repository and SwiftUI’s @Published properties:

class NotesViewModelWrapper: ObservableObject {
    private let repository: NoteRepository
    @Published var notes: [Note] = []
    @Published var searchQuery: String = "" {
        didSet { observeNotes() }
    }
    private var collector: Shared.Closeable?

    init() {
        self.repository = KoinHelper.shared.getNoteRepository()
        observeNotes()
    }

    private func observeNotes() {
        collector?.close()
        let flow = searchQuery.isEmpty
            ? repository.getAllNotes()
            : repository.searchNotes(query: searchQuery)
        collector = FlowCollectorCompanion.shared.collect(flow: flow) { [weak self] value in
            let notesList = value as? [Note] ?? []
            DispatchQueue.main.async { self?.notes = notesList }
        }
    }

    func createNote() {
        _ = repository.createNote(title: "New Note", content: "Tap to edit...", color: .default_)
        observeNotes()
    }

    // ... toggleFavorite, deleteNote
}

Notice .default_ — Kotlin’s DEFAULT enum case becomes default_ in Swift because default is a reserved keyword.

Step 8: iOS Build Configuration

When using SQLDelight’s NativeSqliteDriver on iOS, you need to link the SQLite3 library. In your Xcode project settings, add to Other Linker Flags:

-lsqlite3

Without this, the iOS build succeeds at compile time but fails at link time with Undefined symbols for architecture arm64: _sqlite3_*.

Build and Run

Both platforms should now build successfully:

Android:

./gradlew :composeApp:assembleDebug

iOS (shared framework):

./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

Then open the Xcode project and build from there, or run:

xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' build

What We Built

In this tutorial, we set up:

  • A shared Note model with serialization support
  • A SQLDelight database with full CRUD queries
  • A reactive data layer using Flow
  • Shared ViewModels with search, create, favorite, and delete
  • Koin dependency injection for both platforms
  • A FlowCollector utility for iOS Flow consumption
  • Basic UI on both Android (Compose) and iOS (SwiftUI)

The app already works — you can create, search, favorite, and delete notes. But we need to flesh out the data layer (Part 16) and the UI (Part 17) before publishing.

Source Code

Full source code for this tutorial: GitHub — tutorial-15-notes-app-planning

What’s Next?

In the next tutorial, we build out the shared data layer — adding a Ktor API client for remote sync, implementing the offline-first pattern, and handling data conflicts.