DEV Community

Cover image for How I Structured My First KMP App: Modules, Layers and Source Sets
Avik Sharma Chowdhury
Avik Sharma Chowdhury

Posted on Originally published at avik-sharma-chy.medium.com on

How I Structured My First KMP App: Modules, Layers and Source Sets

A walkthrough of the modules, source sets and Gradle setup behind a Kotlin Multiplatform app on Google Play.

Part 2 of “Building an Exam Prep App with Kotlin Multiplatform”. Part 1: From Idea to Google Play: The Challenges of My First KMP App

In Part 1, I shared the story of my first Kotlin Multiplatform app: why I built it, how the plan changed after the MVP, and how I ended up shipping a free version while keeping the paid features ready for later.

In this article, I’ll walk through how the project is organized: how the modules fit together, how the code is layered, which code is shared, and which code is platform-specific. I’ll include some code from the project along the way.

Project Overview

The project is split into a few Gradle modules. Each module has its own build file and its own dependencies, and it can only use code from the modules it depends on.

LebenInDeutschland/
├── composeApp/ main app module
├── iosApp/ Xcode project that launches the iOS app
├── libs/ library modules
│ ├── domain-api/ shared interfaces and models
│ ├── auth/ sign-in and cloud sync (Supabase)
│ ├── payment/ subscriptions (RevenueCat)
│ └── telemetry-core/ crash and analytics logging
└── build-logic/ shared Gradle setup
Enter fullscreen mode Exit fullscreen mode

composeApp is the main module. It holds the screens, ViewModels, business logic and local database. Most of that lives in commonMain, and each platform folder only holds what that platform needs. On Android, this module builds the app itself. On iOS, it builds a framework that the Xcode project in iosApp loads.

libs/ holds four smaller modules, each with its own build file. domain-api has only interfaces and data classes, with no SDKs. auth and payment implement those interfaces using Supabase and RevenueCat. telemetry-core wraps crash reporting and analytics.

Module diagram: composeApp depends on domain-api and telemetry-core. composeApp depends on auth and payment in the full build only. auth (sign-in and cloud sync with Supabase) and payment (subscriptions and paywall with RevenueCat) both depend on domain-api, which holds shared interfaces and models with no SDKs.
The app module depends on the shared interfaces in domain-api. The auth and payment modules implement those interfaces, and only the full build flavor includes them.

App Module Layers

Inside composeApp, the code follows Clean Architecture. That’s a way of organizing code into layers, where each layer has one job and only depends on the layers below it:

  • presentation: screens (Compose UI) and ViewModels. A ViewModel holds the screen’s state and reacts to what the user does.
  • domain: the rules of the app. For example, CalculateReadinessUseCase decides how ready you are for the exam based on your progress. This layer uses plain Kotlin and has no UI or database code.
  • data: where the data comes from. The local Room database (Room is Android’s database library, which now also works on iOS in KMP), the question file, and the repositories that combine them.
composeApp/src/commonMain/kotlin/.../
├── data/ local database, repositories, sync
├── domain/ models, repository interfaces, use cases
├── presentation/ screens and ViewModels, one folder per feature
├── di/ dependency injection setup
└── util/
Enter fullscreen mode Exit fullscreen mode

Horizontal vs Vertical Slicing

A question I was asked in a recent interview: is your project sliced horizontally or vertically? It’s a useful way to describe a structure, so here is what the two mean and where my app fits.

Horizontal slicing (also called package by layer) groups code by its technical layer first. There is one data folder, one domain folder and one presentation folder, and every feature adds its files to each of them:

data/
  repository/ QuestionRepositoryImpl, ProgressRepositoryImpl, ExamHistoryRepositoryImpl
domain/
  repository/ QuestionRepository, ProgressRepository, ExamHistoryRepository
  usecase/ CalculateReadinessUseCase
presentation/
  exam/ ExamScreen, ExamViewModel
  study/ StudyScreen, StudyViewModel
Enter fullscreen mode Exit fullscreen mode

Vertical slicing (also called package by feature) groups code by feature first. Each feature gets its own folder, or its own module, with all three layers inside it:

exam/
  data/ ExamHistoryRepositoryImpl
  domain/ ExamHistoryRepository
  presentation/ ExamScreen, ExamViewModel
study/
  data/ ProgressRepositoryImpl
  domain/ ProgressRepository
  presentation/ StudyScreen, StudyViewModel
Enter fullscreen mode Exit fullscreen mode

The first example is how my app module is organized, so it follows horizontal slicing. The presentation layer has one folder per feature, but data and domain are grouped by type (models, repositories, use cases). The second example shows how the same files would look if I had sliced the app vertically.

I chose this because the app is small, and most features share the same data. The exam, study and profile screens all read the same questions and the same progress. In a vertical structure, I’d have to decide which feature owns the progress data, or create a shared “core” module that most features depend on anyway.

Horizontal slicing has trade-offs too:

  • A change to one feature touches several folders. Adding a field to the exam results means editing data, domain and presentation.
  • Feature boundaries are not enforced. Nothing stops the study screen from using something meant only for the exam screen.
  • It’s harder to split up later. In a large app with several teams, vertical slices let each team own a feature and build it on its own.

My modules are a mix. Inside composeApp, the code is sliced horizontally. But auth and payment are separate modules, each owning one feature from its outside service up to the interfaces it provides. I didn’t split them out to follow vertical slicing. I split them out so the free build could leave them out. Still, it shows that the two approaches can live together: horizontal where features share most of their code, and separate modules where a feature needs a clear boundary.

If the app grows, the first step I’d take is to move each screen group (exam, study, profile) into its own feature module (vertical slicing), with a shared core module for the questions and progress data.

App Module Source Sets

Layers split the code by job. Source sets split it by platform.

A source set is a folder of code that is compiled for a specific set of targets. In a KMP project, the code you write in commonMain is compiled for every platform, while androidMain and iosMain are compiled only for their platform.

My app module has these source sets:

composeApp/src/
├── commonMain/ shared by Android and iOS (most of the app)
├── androidMain/ Android only
├── iosMain/ iOS only
├── androidFree/ Android, free build only
├── androidFull/ Android, full build only
Enter fullscreen mode Exit fullscreen mode

Shared Code in commonMain

Almost the whole app lives in commonMain: screens, ViewModels, exam logic, Room database, question data, navigation and localization. For DI, I used Koin, a DI library for Kotlin. Here is part of the shared Koin module:

val sharedModule = module {
    // Repositories
    single<QuestionRepository> { QuestionRepositoryImpl(get(), get(), get(), get(), get(), get()) }
    single<ProgressRepository> { ProgressRepositoryImpl(get(), get(), get(), get()) }

    // Use cases
    factoryOf(::CalculateReadinessUseCase)

    // ViewModels
    viewModelOf(::ExamViewModel)
    viewModelOf(::StudyViewModel)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Testing the Koin Setup

Koin works differently from Hilt, another popular DI library for Android. Hilt checks the dependency graph when you build the app. If a class needs something that nobody provides, the build fails. Koin connects everything while the app runs. A missing binding still compiles fine, and the app crashes only when a screen first asks for it. That could happen on a user’s phone.

To catch this early, I added a unit test that initializes the Koin configuration and verifies the dependency graph:

@RunWith(RobolectricTestRunner::class)
class KoinGraphTest {

    @After
    fun tearDown() = stopKoin()

    @Test
    fun everyBindingResolves() {
        val koin = startKoin {
            androidContext(ApplicationProvider.getApplicationContext())
            modules(listOf(androidModule, sharedModule))
        }.koin

        koin.get<QuestionRepository>()
        koin.get<ExamViewModel>()
        koin.get<StudyViewModel>()
        koin.get<SettingsViewModel>()
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The test uses Robolectric, a library that simulates the Android environment, so it can run as a normal unit test on your computer.

Platform Code with expect and actual

Some things only the platform can do. Asking a user to rate the app is one of them. Android uses Google Play’s In-App Review API, while iOS uses StoreKit. Kotlin handles this with expect and actual. You declare something in commonMain with expect and each platform provides its own version with actual.

// in commonMain
interface AppReviewController {
    suspend fun requestReview()
}

@Composable
expect fun rememberAppReviewController(): AppReviewController
Enter fullscreen mode Exit fullscreen mode

In androidMain, the Android version asks Google Play to show the prompt:

@Composable
actual fun rememberAppReviewController(): AppReviewController {
    val context = LocalContext.current
    return remember(context) { AndroidAppReviewController(context) }
}

private class AndroidAppReviewController(private val context: Context) : AppReviewController {
    override suspend fun requestReview() {
        // Android-specific in-app review code
    }
}
Enter fullscreen mode Exit fullscreen mode

In iosMain, the iOS version asks StoreKit:

@Composable
actual fun rememberAppReviewController(): AppReviewController =
    remember { IosAppReviewController() }

private class IosAppReviewController : AppReviewController {
    override suspend fun requestReview() {
        // iOS-specific in-app review code
    }
}
Enter fullscreen mode Exit fullscreen mode

The screen that shows the prompt after a passed exam lives in commonMain, and it doesn’t know which platform it’s running on.

A pattern I ended up using a lot: keep the expect part small. Here it’s one function, and the rest is a normal interface. In tests, I can replace the interface with a fake, so the shared code stays easy to test.

Wrapping Up

Most of my app lives in commonMain, and each platform only adds the few things it can do on its own. Layers keep the code organized by job, source sets keep it organized by platform, and a few separate modules keep the outside services apart.

In Part 3, I cover product flavors: how the same codebase builds a free and a full version of the app, and how the free version leaves the sign-in, sync and payment code out entirely.

Thanks for reading! If you’re preparing for the “Leben in Deutschland” test, you can try the app on Google Play.

See all parts of the series on Medium

Top comments (0)