Dependency Injection in KMP: Koin, Metro and Manual DI

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Every non-trivial app has the same wiring problem: TrailListViewModel needs a TrailRepository, which needs a TrailApiClient, a TrailMateDatabase and a clock; somebody has to build these objects once, share them where they must be shared, and hand them to the screens. On Android the answer is usually Hilt. In Kotlin Multiplatform that answer is not available, because Dagger generates Java code through annotation processing and cannot be compiled for iOS, desktop-native or web targets. This page explains what you use instead, and when.

The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4, Ktor 3.6.0, SQLDelight 2.4.0 and Koin 4.2.2; for iOS interop SKIE 0.10.15 and KMP-NativeCoroutines 1.0.6 (Swift export and SwiftPM import are Alpha; Wasm and Compose Multiplatform for web are Beta); for tests Mokkery 3.5.0 and Turbine 1.2.1. Metro 1.4.5 and kotlin-inject 0.9.0 are the compile-time alternatives covered. Every example is a slice of TrailMate Everywhere, the multiplatform version of the Android section’s TrailMate app (package com.example.trailmate). The Android-only treatment of Dagger, Hilt, Koin and Metro is on Dependency Injection: Hilt and Koin; this page does not repeat it and concentrates on what changes once code is shared.

Why Hilt and Dagger Do Not Apply

Hilt and Dagger are JVM tools. Dagger’s annotation processor (kapt or KSP) emits Java factories and components, Hilt additionally rewrites Android classes with a Gradle bytecode transform, and both rely on javax.inject and Android lifecycle components such as @AndroidEntryPoint. None of that exists in commonMain: Kotlin/Native produces no JVM bytecode, there is no Activity on iOS, and a common source set cannot reference Java types.

Aspect Why it fails in shared code What you need instead

Code generation

Dagger emits Java sources and needs a JVM classpath.

A multiplatform runtime library (Koin), or a Kotlin compiler plugin or KSP processor that emits Kotlin (Metro, kotlin-inject).

Android components

@HiltAndroidApp, @AndroidEntryPoint and @HiltViewModel bind to Application, Activity and Android’s ViewModel hierarchy.

Constructor injection in plain Kotlin classes, with the platform entry point (an Application, a SwiftUI App, main()) starting the graph.

Platform objects

A Context or a CLLocationManager cannot be provided from common code.

A per-platform module, supplied through expect/actual or a parameter, as in the expect/actual page.

iOS consumers

Swift cannot see Dagger components at all.

A small Kotlin facade that exposes the already-built objects to Swift.

A project may keep Hilt in androidApp for Android-only classes (a Worker, a Service), but anything in sharedLogic must be wired with something multiplatform. Mixing two containers in one app is possible, yet it doubles the places where a binding can live and is rarely worth it.

Option Zero: Manual Constructor Injection

Dependency injection is a pattern, not a framework: a class declares what it needs as constructor parameters and never builds or looks up its collaborators. The simplest correct DI for a multiplatform app is therefore a hand-written composition root — one place that creates the object graph.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/AppContainer.kt
package com.example.trailmate.di

import com.example.trailmate.data.OfflineFirstTrailRepository
import com.example.trailmate.data.TrailApiClient
import com.example.trailmate.data.TrailRepository
import com.example.trailmate.db.TrailMateDatabase
import com.example.trailmate.presentation.TrailListViewModel
import io.ktor.client.HttpClient

class AppContainer(
    httpClient: HttpClient,
    database: TrailMateDatabase,
) {
    private val apiClient = TrailApiClient(httpClient, baseUrl = "https://api.example.com/v1/")

    val trailRepository: TrailRepository =
        OfflineFirstTrailRepository(api = apiClient, database = database)

    // A factory method: every screen gets its own ViewModel instance.
    fun trailListViewModel() = TrailListViewModel(trailRepository)
}

The two platform-specific inputs, the HttpClient (with its engine) and the TrailMateDatabase (with its driver), arrive as constructor parameters. Android builds them with OkHttp and AndroidSqliteDriver, iOS with Darwin and NativeSqliteDriver; AppContainer neither knows nor cares.

Manual DI has real strengths: it is explicit, compiles to ordinary Kotlin, has no startup cost and no runtime failure mode (a missing dependency is a compile error). It also scales further than people expect. Its weaknesses appear around 30 to 50 bindings: the container grows by hand, scoping (per-screen, per-session) must be coded manually, and forgetting to share one instance silently creates two.

Koin 4.2: the Default Choice

Koin is a lightweight DI framework written in pure Kotlin with a small DSL. It supports Android, iOS, desktop JVM, Kotlin/JS and Wasm, which is why it is the most common DI choice in KMP projects. It resolves dependencies at runtime: the DSL registers factory lambdas in a registry, and the first request for a type runs the lambda. That makes it trivial to set up and very flexible, at the price of errors surfacing at runtime unless you verify the graph in a test (see below).

// gradle/libs.versions.toml (excerpt)
[versions]
koin = "4.2.2"

[libraries]
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-android = { module = "io.insert-koin:koin-android" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
koin-test = { module = "io.insert-koin:koin-test" }
// sharedLogic/build.gradle.kts (excerpt)
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
}

kotlin {
    android { namespace = "com.example.trailmate.shared"; compileSdk = 37; minSdk = 24 }
    iosArm64(); iosSimulatorArm64(); jvm("desktop"); wasmJs { browser() }

    sourceSets {
        commonMain.dependencies {
            implementation(project.dependencies.platform(libs.koin.bom))
            api(libs.koin.core) // api: the iOS framework and Swift need KoinComponent
        }
        androidMain.dependencies { implementation(libs.koin.android) }
        commonTest.dependencies { implementation(libs.koin.test) }
    }
}

koin-core is the multiplatform artifact; koin-android adds androidContext() and Android lifecycle scopes; koin-compose and koin-compose-viewmodel belong in sharedUI (shown later). The BOM keeps the versions aligned. Declaring koin-core with api matters on iOS: Swift code that wants to resolve objects through Koin needs the Koin classes to be exported in the framework (see Gradle Configuration).

Modules and Definitions

A Koin module is a named set of definitions. Each definition tells Koin how to build a type and with which lifetime:

Keyword Lifetime Typical TrailMate use

single

One instance for the lifetime of the Koin container (a singleton).

HttpClient, TrailApiClient, TrailMateDatabase, TrailRepository.

factory

A new instance on every request.

Small stateless helpers, use cases, a formatter configured per call site.

viewModel

A new ViewModel per owner, cached by the platform’s ViewModelStore rather than by Koin.

TrailListViewModel, TrailDetailViewModel.

scoped

One instance per Koin scope, which you open and close explicitly.

Per-login-session data, or a per-screen cache (see the Android page for scopes).

TrailMate’s module is written once in commonMain and used by every platform.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/TrailMateModule.kt
package com.example.trailmate.di

import com.example.trailmate.data.OfflineFirstTrailRepository
import com.example.trailmate.data.TrailApiClient
import com.example.trailmate.data.TrailRepository
import com.example.trailmate.presentation.TrailDetailViewModel
import com.example.trailmate.presentation.TrailListViewModel
import io.ktor.client.HttpClient
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind
import org.koin.dsl.module

val trailMateModule = module {
    // Explicit lambda form: full control over constructor arguments.
    single { TrailApiClient(client = get<HttpClient>(), baseUrl = "https://api.example.com/v1/") }

    // Constructor DSL: Koin resolves each constructor parameter by type.
    singleOf(::OfflineFirstTrailRepository) bind TrailRepository::class

    viewModelOf(::TrailListViewModel)
    viewModel { params -> TrailDetailViewModel(trailId = params.get(), repository = get()) }
}

Reading the module top to bottom: the first line shows the lambda form, where get() asks the container for a dependency (the type is inferred from the parameter). singleOf(::Constructor) is the constructor DSL: Koin inspects the constructor and calls get() for every parameter, which removes the boilerplate; bind registers the same instance under the interface TrailRepository, so consumers ask for the interface and tests can replace it. viewModelOf is the same idea for ViewModels, and the last line shows parameters: params.get() receives the trailId passed at the call site (koinViewModel(parameters = \{ parametersOf(id) })).

Rules of thumb: inject interfaces, not implementations; prefer singleOf and viewModelOf unless an argument needs a literal; never call get() outside a definition lambda (that turns Koin into a service locator spread across your code).

Platform Modules Through expect/actual

Some bindings exist only per platform: the SQLDelight driver, the Ktor engine, LocationProvider. The standard idiom is an expected module, implemented once per platform, which the shared module list then includes. The expect/actual mechanics are covered on expect/actual and Calling Platform APIs; here the module also supplies the database and the HTTP engine.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/PlatformModule.kt
package com.example.trailmate.di

import org.koin.core.module.Module

expect val platformModule: Module
// sharedLogic/src/androidMain/kotlin/com/example/trailmate/di/PlatformModule.android.kt
package com.example.trailmate.di

import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.example.trailmate.db.TrailMateDatabase
import com.example.trailmate.location.AndroidLocationProvider
import com.example.trailmate.location.LocationProvider
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module

actual val platformModule = module {
    single<HttpClient> { HttpClient(OkHttp) }
    single<SqlDriver> { AndroidSqliteDriver(TrailMateDatabase.Schema, androidContext(), "trailmate.db") }
    single<LocationProvider> { AndroidLocationProvider(androidContext()) }
}
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/di/PlatformModule.ios.kt
package com.example.trailmate.di

import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.example.trailmate.db.TrailMateDatabase
import com.example.trailmate.location.IosLocationProvider
import com.example.trailmate.location.LocationProvider
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import org.koin.dsl.module

actual val platformModule = module {
    single<HttpClient> { HttpClient(Darwin) }
    single<SqlDriver> { NativeSqliteDriver(TrailMateDatabase.Schema, "trailmate.db") }
    single<LocationProvider> { IosLocationProvider() }
}

Because the actual for iOS lives in iosMain, a single declaration serves iosArm64 and iosSimulatorArm64. desktopMain and wasmJsMain need their own actuals as well, otherwise those targets fail to compile — a typical "works on Android and iOS, breaks on desktop" mistake. A common-code single { TrailMateDatabase(get()) } in trailMateModule then consumes the SqlDriver without knowing which one it is.

One Entry Function, Three Platforms

Each platform must start Koin exactly once, before the first dependency is requested. Put the common part in a function and let the platform pass whatever extra it has (the Android Context, a logger).

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/KoinInit.kt
package com.example.trailmate.di

import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration

fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
    appDeclaration()
    modules(trailMateModule, platformModule)
}
// androidApp/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
package com.example.trailmate

import android.app.Application
import com.example.trailmate.di.initKoin
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger

class TrailMateApp : Application() {
    override fun onCreate() {
        super.onCreate()
        initKoin {
            androidLogger()
            androidContext(this@TrailMateApp)
        }
    }
}

On Android the Application is the composition root; register it in AndroidManifest.xml with android:name=".TrailMateApp" (see App Components, Manifest and Intents). On iOS the Kotlin function initKoin is exported under a different name: Kotlin/Native’s Objective-C export prefixes functions that start with init to avoid clashing with the Objective-C init constructor family, so Swift sees it as doInitKoin. Defining the function in a file called KoinInit.kt makes Swift see it as a method of KoinInitKt.

// iosApp/iosApp/TrailMateApp.swift
import SwiftUI
import SharedLogic

@main
struct TrailMateApp: App {
    init() {
        // Kotlin initKoin(appDeclaration) is exported to Swift as doInitKoin.
        KoinInitKt.doInitKoin(appDeclaration: { _ in })
    }

    var body: some Scene {
        WindowGroup { TrailListView() }
    }
}

With Swift export (Alpha) the names and the lambda handling differ; the framework, function names and examples here describe the Objective-C based export, which is the Stable default.

Resolving Dependencies from Swift

Swift code cannot call Kotlin’s inline reified get<T>(), so give it a Kotlin facade that is a KoinComponent. The facade is the only place in the iOS layer that talks to the container, which keeps Swift free of the service locator.

// sharedLogic/src/iosMain/kotlin/com/example/trailmate/di/TrailMateDependencies.kt
package com.example.trailmate.di

import com.example.trailmate.presentation.TrailDetailViewModel
import com.example.trailmate.presentation.TrailListViewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf

object TrailMateDependencies : KoinComponent {
    fun trailListViewModel(): TrailListViewModel = get()
    fun trailDetailViewModel(trailId: String): TrailDetailViewModel =
        get { parametersOf(trailId) }
}
// iosApp/iosApp/TrailListView.swift
import SwiftUI
import SharedLogic

struct TrailListView: View {
    // The shared ViewModel is created by Koin and kept alive by the view.
    @StateObject private var holder = ViewModelHolder(TrailMateDependencies.shared.trailListViewModel())

    var body: some View {
        List(holder.trails, id: \.id) { trail in
            Text(trail.name)
        }
    }
}

How ViewModelHolder observes the StateFlow<TrailListUiState> (through SKIE or KMP-NativeCoroutines) is covered on Architecture, Shared ViewModels and State and the iOS interop pages. A ViewModel handed to Swift this way is not cleared automatically: call its clear() equivalent (or use the ViewModelStoreOwner pattern from the architecture page) when the screen goes away.

Koin in Compose Multiplatform

When the UI is shared, Compose can host the container itself. KoinApplication (from koin-compose) starts Koin and provides it to the composition, and koinViewModel() (from koin-compose-viewmodel) resolves a ViewModel declared with viewModel/viewModelOf and ties it to the composition’s ViewModelStoreOwner.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/App.kt
package com.example.trailmate.ui

import androidx.compose.runtime.Composable
import com.example.trailmate.di.platformModule
import com.example.trailmate.di.trailMateModule
import org.koin.compose.KoinApplication
import org.koin.compose.viewmodel.koinViewModel

@Composable
fun App() {
    KoinApplication(application = {
        modules(trailMateModule, platformModule)
    }) {
        TrailMateTheme {
            TrailListScreen(viewModel = koinViewModel())
        }
    }
}

@Composable
fun TrailListScreen(viewModel: TrailListViewModel = koinViewModel()) {
    // collect viewModel.uiState: StateFlow<TrailListUiState> ...
}

There are two ways to start Koin and you should pick one: either startKoin in the platform entry point (Android Application, Swift App.init) or KoinApplication inside App(). Doing both fails with a "KoinApplication has already been started" error. KoinApplication is the simpler choice for fully shared Compose UI, because desktop, web and the iOS MainViewController all call the same App(); but a platform module that needs the Android Context then needs KoinApplication to receive it (androidContext through the application lambda on Android only). The startKoin route is better when native SwiftUI screens and shared Compose screens must share one container.

koinViewModel() lives in org.koin.compose.viewmodel and relies on the multiplatform Jetpack lifecycle-viewmodel-compose port; the older org.koin.androidx.compose.koinViewModel import is Android only and does not compile in commonMain. Passing a navigation argument works with koinViewModel(parameters = \{ parametersOf(trailId) }), matching the viewModel { params → …​ } definition above.

Testing the Graph with verify()

Because Koin resolves at runtime, a missing binding crashes only when the screen is first opened. verify() from koin-test closes that gap: it walks every definition’s constructor and checks that each parameter type is defined in the module, without instantiating anything. It relies on reflection, so it runs on the JVM (an Android host test or the desktop target), not on iOS or web.

// sharedLogic/src/androidHostTest/kotlin/com/example/trailmate/di/KoinModulesTest.kt
package com.example.trailmate.di

import android.content.Context
import com.example.trailmate.location.LocationProvider
import io.ktor.client.HttpClient
import kotlin.test.Test
import org.koin.test.verify.verify

class KoinModulesTest {
    @Test
    fun trailMateModuleIsComplete() {
        // Types that the module needs but that the platform module supplies at runtime.
        trailMateModule.verify(
            extraTypes = listOf(HttpClient::class, Context::class, LocationProvider::class),
        )
    }
}

extraTypes lists dependencies that are legitimately provided elsewhere (the platform module, androidContext(), or injected parameters). The test fails when someone adds class TrailListViewModel(val analytics: Analytics) and forgets to register Analytics. Pair it with a ViewModel test that supplies a fake through koinTestRule or by passing the fake directly: because constructor injection is plain Kotlin, most unit tests (for example TrailListViewModelTest with Turbine and a Mokkery or hand-written FakeTrailRepository) need no container at all.

Compile-Time Alternatives: Metro and kotlin-inject

Runtime resolution is Koin’s trade-off. If you want the graph validated by the compiler, with no registry lookups and no reflection, use a compile-time framework. Both of the following are third-party and multiplatform.

Metro

Metro (dev.zacsweers.metro, by Zac Sweers, 1.4.5 at the baseline) is a Kotlin compiler plugin. It combines ideas from Dagger, Anvil and kotlin-inject: you write annotations such as @Inject, @Provides and @DependencyGraph, and the plugin generates the wiring during compilation, reporting a missing binding as a build error with a readable trace. Because it is a compiler plugin rather than an annotation processor, it needs no KSP or kapt and adds little build time. It is a young, third-party project: pin its version and check its compatibility with your Kotlin version on every upgrade, since compiler plugins depend on compiler internals.

Apply the plugin with id("dev.zacsweers.metro") version "1.4.5" in the shared module.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/TrailMateGraph.kt
package com.example.trailmate.di

import com.example.trailmate.data.OfflineFirstTrailRepository
import com.example.trailmate.data.TrailRepository
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient

@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
@Inject
class OfflineFirstTrailRepository(/* api, database */) : TrailRepository

@DependencyGraph(AppScope::class)
interface TrailMateGraph {
    val trailRepository: TrailRepository

    @Provides
    @SingleIn(AppScope::class)
    fun provideHttpClient(): HttpClient = HttpClient()
}

Obtain the graph with createGraph<TrailMateGraph>() from dev.zacsweers.metro and keep it in one holder.

The mapping to Koin is direct: @Inject on a class replaces singleOf, @Provides replaces a lambda definition, @SingleIn(AppScope::class) replaces single, and @ContributesBinding replaces bind. Platform-specific objects enter through a graph factory that takes them as parameters (@DependencyGraph.Factory) — the platform entry point creates the graph and passes the Context or the LocationProvider. The exact annotation names and the ViewModel integration (multibindings into a map) evolve between releases; treat the snippet as the shape, not a copy-paste, and read the Metro documentation for your version. Metro is also the framework the Android page mentions as its forward-looking option.

kotlin-inject

kotlin-inject (me.tatarka.inject, by Evan Tatarka, 0.9.0 at the baseline, still pre-1.0) is a KSP-based compile-time DI library modelled on Dagger but designed for Kotlin and multiplatform. Components are abstract classes; the processor generates the implementation, and create() extension functions are generated per target.

// sharedLogic/src/commonMain/kotlin/com/example/trailmate/di/AppComponent.kt
package com.example.trailmate.di

import com.example.trailmate.data.OfflineFirstTrailRepository
import com.example.trailmate.data.TrailRepository
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
import me.tatarka.inject.annotations.Scope

@Scope
annotation class AppScope

@AppScope
@Component
abstract class AppComponent {
    abstract val trailRepository: TrailRepository

    @Provides
    @AppScope
    protected fun repository(impl: OfflineFirstTrailRepository): TrailRepository = impl
}

Gradle needs the KSP plugin and one ksp configuration per target (kspCommonMainMetadata, kspAndroid, kspIosArm64, kspIosSimulatorArm64, and so on), as described on Dependencies, klibs.io, cinterop and SwiftPM Import. That per-target KSP setup is the main practical cost compared with Metro’s compiler plugin.

Comparing the Options

Manual DI Koin 4.2 Metro kotlin-inject

Resolution

Compile time (plain Kotlin)

Runtime (registry of lambdas)

Compile time (compiler plugin)

Compile time (KSP)

Missing binding

Compile error

Runtime crash, unless verify() runs in a test

Build error with a trace

Build error

Setup effort

None

Small: one dependency per layer

Small: one Gradle plugin

Medium: KSP per target

Android lifecycle integration

By hand

Built in (androidContext, scopes)

Via your own glue

Via your own glue

Compose Multiplatform

By hand (CompositionLocal)

KoinApplication, koinViewModel()

By hand or community glue

By hand

Swift access

Expose the container object

KoinComponent facade

Expose the graph object

Expose the component object

Maturity

Always available

Widest KMP adoption and documentation

Young but active, 1.x

Pre-1.0, stable in practice

Good fit

Small apps, libraries, learning

Most apps, fastest start

Larger graphs, compile-time guarantees

Teams already used to Dagger-style code

Which to choose: start with manual DI if the graph has fewer than about twenty bindings; choose Koin when you want the lowest-friction, best-documented route and accept runtime resolution backed by a verify() test; choose Metro when the graph is large or the team wants Dagger-like safety without KSP; choose kotlin-inject mostly when it is already in the codebase. Libraries that other apps consume should avoid imposing a container at all: accept dependencies as constructor parameters and let the app wire them.

Common Mistakes

  • Starting Koin twice. startKoin in the Android Application and KoinApplication in App() both succeed separately and crash together. Pick one entry point per process.

  • Forgetting an actual platformModule on one target. The shared module list compiles on Android and iOS but desktop or web fails; remember desktopMain and wasmJsMain.

  • Calling get() or inject() from business classes. That is a service locator: dependencies become invisible, and tests need a running container. Use constructor parameters.

  • Using single for ViewModels. A singleton ViewModel outlives its screen and keeps stale state; use viewModel/viewModelOf.

  • Importing org.koin.androidx.compose.koinViewModel in commonMain. Use org.koin.compose.viewmodel.koinViewModel.

  • Expecting Swift to see default arguments or reified functions. Add explicit overloads or a facade object.

  • Not exporting Koin to the iOS framework while calling KoinComponent types from Swift: declare koin-core with api and export it in the framework configuration.

  • Skipping verify(). Without it a typo in a module shows up as a crash on a user’s device.

Summary

Hilt and Dagger stay on Android; shared code is wired with manual constructor injection or a multiplatform framework. Koin 4.2 gives the shortest path (trailMateModule in commonMain, an expected platformModule, startKoin from the Android Application or KoinApplication in App(), doInitKoin from Swift, and a JVM verify() test), while Metro and kotlin-inject move the graph check to compile time at the cost of annotations and build setup. Whichever you choose, keep classes plain with constructor parameters so the framework stays at the edge. The next topic, Essential Multiplatform Libraries, surveys what to put into those modules.