DEV Community

Baharudin Maulana
Baharudin Maulana

Posted on

I Got Tired of Setting Up KMP Architecture From Scratch Every Time, So I Built a Template published

 Photo by <a href=Marc Reichelt on Unsplash"/>

A walkthrough of a production-ready Kotlin Multiplatform template I built after spending weeks on the same boilerplate for every project. Multi module, offline-first, Paging 3, the works

You know that feeling when you start a new project and you're like, "okay, let's do this properly this time"?
And then two weeks later you're still setting up Gradle Convention Plugins, wiring up token refresh logic, and copy pasting Room migration boilerplate from your last project.
Yeah. That was me. Three times in a row.
So I stopped the cycle and built a template. Then I decided to share it.
Here's what's inside and why I made the choices I did. πŸ‘‡

The Problem With Most KMP Starters
Most KMP starter projects you find online are... fine. For learning.
But they're usually single module, have a basic Ktor setup with zero error handling, no auth flow, no offline support, and definitely no Paging 3.
When you're building a real app the kind you're shipping to actual users you need all of that. And building it from scratch each time is just expensive developer hours wasted on things that should already be solved.

What I Built
KMP Production Starter PRO is a multi module Kotlin Multiplatform project targeting Android and iOS via Compose Multiplatform.
Here's the full breakdown:

:composeApp          β†’ UI entry point
:core:network        β†’ Ktor client, auth, token management
:core:database       β†’ Room + migrations
:core:datastore      β†’ Preferences + token storage
:core:common         β†’ Shared utilities, Result wrapper
:core:di             β†’ Koin modules
:feature:auth        β†’ Login / Register / Logout
:feature:home        β†’ Dashboard
:feature:items       β†’ Paginated list + detail + offline CRUD
:build-logic         β†’ Convention Plugins
Enter fullscreen mode Exit fullscreen mode

Each feature module follows Clean Architecture strictly:

data/
  β”œβ”€β”€ remote/     β†’ API interface + DTOs
  β”œβ”€β”€ local/      β†’ DAO + Entity
  β”œβ”€β”€ repository/ β†’ RepositoryImpl
  └── mapper/     β†’ Data ↔ Domain mapping

domain/
  β”œβ”€β”€ model/      β†’ Domain models
  β”œβ”€β”€ repository/ β†’ Repository interface
  └── usecase/    β†’ Use cases

presentation/
  β”œβ”€β”€ Screen.kt
  β”œβ”€β”€ ViewModel.kt
  └── UiState.kt

di/
  └── Module.kt   β†’ Koin module
Enter fullscreen mode Exit fullscreen mode

The real time saver is the Convention Plugins in build logic. Instead of repeating the same Gradle setup in every module:

// Before Convention Plugins 😫
// Every module needs this... copy pasted 8 times:
plugins {
    kotlin("multiplatform")
    id("com.android.library")
}

kotlin {
    androidTarget {
        compilations.all {
            kotlinOptions { jvmTarget = "17" }
        }
    }
    iosX64()
    iosArm64()
    iosSimulatorArm64()
    // ... 30 more lines
}
Enter fullscreen mode Exit fullscreen mode
// After Convention Plugins 😌
plugins { id("kmp.library") }
Enter fullscreen mode Exit fullscreen mode

One line. All targets, compile options, and common dependencies handled automatically.

🌐 Networking: Ktor with Proper Token Handling
The thing that kills me about most Ktor examples is they show you the happy path. No auth, no 401 handling, no token refresh.
Here's what the Error Interceptor in the template actually does:

install(HttpCallValidator) {
    handleResponseExceptionWithRequest { exception, request ->
        val response = (exception as? ResponseException)?.response 
            ?: throw exception

        when (response.status) {
            HttpStatusCode.Unauthorized -> {
                // Try to refresh the token
                val refreshed = tokenManager.refreshToken()
                if (refreshed) {
                    // Retry the original request with the new token
                    throw RetryableException(request)
                } else {
                    // Refresh failed β†’ user needs to log in again
                    tokenManager.clearTokens()
                    throw AppException.Unauthorized
                }
            }
            else -> throw AppException.Server(response.status.description)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Token expires β†’ refresh β†’ retry. The user never sees an error. That's the experience people expect.
Also included: a Network Connectivity Monitor using expect/actual:

Android: ConnectivityManager
iOS: NWPathMonitor

Clean cross platform API, no platform specific code leaking into your shared layer.

πŸ—„οΈ Room + Real Migrations
The template has Room set up with multiple entities and this is the part most examples skip actual migration examples:

val MIGRATION_1_2 = Migration(1, 2) { db ->
    db.execSQL(
        "ALTER TABLE items ADD COLUMN syncStatus TEXT NOT NULL DEFAULT 'SYNCED'"
    )
}

val MIGRATION_2_3 = Migration(2, 3) { db ->
    db.execSQL(
        "ALTER TABLE items ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0"
    )
}
Enter fullscreen mode Exit fullscreen mode

These aren't placeholder migrations. They're the pattern you actually use when your schema evolves in production and you can't just nuke the database.

πŸ“‘ The Offline-First Sync Engine
Okay, this is the part I'm most proud of because it's genuinely the hardest thing to get right.
The flow looks like this:

User Action (create/update/delete)
        ↓
    ViewModel
        ↓
    UseCase
        ↓
   Repository
    /        \
Room (local)   SyncManager
Write first!   Queue + Push
        \        /
     when online ↓
          API
           ↓
    ConflictResolver
    (server-wins / client-wins / merge)
           ↓
    Room updated with final state
           ↓
    UI updates via Flow βœ…
Enter fullscreen mode Exit fullscreen mode

The key insight: always write to Room first. The item gets marked SyncStatus.PENDING. SyncManager handles pushing to the API when connectivity is available. If there's a conflict, ConflictResolver decides the outcome.

class SyncManager(
    private val itemsDao: ItemsDao,
    private val itemsApi: ItemsApi,
    private val networkMonitor: NetworkMonitor,
    private val conflictResolver: ConflictResolver
) {
    suspend fun syncPending(): Result<Int> {
        if (!networkMonitor.isConnected()) 
            return Result.Error(AppException.NoInternet)

        val pending = itemsDao.getPendingItems()
        var synced = 0

        pending.forEach { item ->
            try {
                val serverItem = itemsApi.upsert(item.toDto())
                val resolved = conflictResolver.resolve(
                    local = item, 
                    remote = serverItem
                )
                itemsDao.update(resolved.copy(syncStatus = SyncStatus.SYNCED))
                synced++
            } catch (e: Exception) {
                itemsDao.update(item.copy(syncStatus = SyncStatus.FAILED))
            }
        }
        return Result.Success(synced)
    }
}
Enter fullscreen mode Exit fullscreen mode

The UI shows real time sync status via a SyncStatusBanner component. Users always know if they're offline, syncing, or fully synced.

🎨 UI: Compose Multiplatform + Material 3
The UI layer includes:

Full navigation graph (auth flow β†’ main flow)
Bottom navigation
Material 3 with Light & Dark theme
Reusable components: LoadingIndicator, ErrorDialog, EmptyState, SearchBar, SyncStatusBanner

Setup: Actually 5 Minutes

git clone <repo-url>
cd kmp-production-starter-pro
Enter fullscreen mode Exit fullscreen mode

Edit gradle.properties:

app.packageName=com.yourcompany.yourapp
app.name=YourApp
Enter fullscreen mode Exit fullscreen mode

Edit NetworkConstants.kt:

object NetworkConstants {
    const val BASE_URL = "https://your-api.com/api/v1/"
}
Enter fullscreen mode Exit fullscreen mode

Open in Android Studio β†’ Sync β†’ Run ▢️
That's it. The template compiles and runs against a demo API out of the box.

Honest Take: Who This Is For
This template is for KMP developers who already know the basics and are tired of spending the first two weeks of every project on the same setup.
It's opinionated. The architecture decisions are based on what I've found works in production apps, not what's "theoretically correct." If you disagree with some choices great, fork it and make it yours.
It's not for KMP beginners. If you're just learning KMP, start with the official docs first. Come back when you're ready to ship something real.

Where to Get It
If you're building KMP apps and want to skip two weeks of boilerplate, the template is available here:

β†’ KMP Production Starter PRO β€” Gumroad

Also on Codester if that's your preferred marketplace.

Got questions about the architecture? Drop them in the comments, I'm happy to dig into the reasoning behind any of the decisions.
What would you add to a KMP starter template? Curious what the community thinks is missing.

Top comments (0)