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

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/
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
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
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
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)
// ...
}
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>()
// ...
}
}
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
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
}
}
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
}
}
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.
Top comments (0)