Drop the Clutch: Three Metro DI Patterns Every KMP Developer Should Know - Part 1

Drop the Clutch: Three Metro DI Patterns Every KMP Developer Should Know - Part 1

6
calendar_today agoschedule8 min read

In a GT3 car, you have a choice. You can drive with a traditional manual gearbox: clutch pedal, H-pattern shifter, full control over every gear change. Or you can switch to the sequential paddle shifters that most modern GT3 cars run. The car is still yours to drive. The racing line, the braking points, the tyre management. All of that stays with you. The only thing that changes is that you stop managing something that was never really the point.

I've been running Koin in a multi-module Compose Multiplatform project for a while, and for a long time it felt like the manual gearbox. Not painful exactly, just present. Every new dependency meant another module { } block somewhere, another get() in the factory lambda, another place where the compiler had nothing to say if I forgot to wire something. The error came at runtime, and usually in a context that made it harder to trace than it should have been.

Metro is the paddle shifters. You annotate your classes, define a graph interface, and the Kotlin compiler plugin generates the wiring at build time. If something is missing from the graph, the build fails. Not the app launch. The build.

I already covered Koin Annotations in a previous article. What follows isn't a migration guide. It's a look at three specific patterns that come up in every real KMP project and where Metro's behaviour changed how I think about dependency injection. This is Part 1 — setup, the basics of Metro's model, and the binding pattern you'll use for most features. Part 2 covers platform-specific graphs and scoped lifetimes.


What Metro is

Metro is a compile-time dependency injection framework for Kotlin Multiplatform. It ships as a Kotlin compiler plugin, not an annotation processor or a KSP plugin. Code generation happens in the compiler's FIR/IR pipeline directly.

The mental model draws from three places: @Inject, @Provides, and the scope system come from Dagger; the interface-based graph definition comes from kotlin-inject; and the contribution aggregation system comes from Anvil. If you've worked with any of those three, the ideas transfer quickly.


Metro, Koin Annotations, and Dagger

Before getting into the patterns, it's worth placing Metro relative to the tools you've probably already used.

Koin Annotations

The comparison people reach for first is Koin Annotations, because both claim compile-time safety. I want to be clear about something: these are not competing answers to the same question. They solve the same problem, both do it well, and the choice comes down to what your project actually needs.

Koin Annotations uses KSP to generate source files during the build. It catches graph errors in the KSP phase, which is earlier than runtime, but the Koin service locator container still exists underneath. If something slips through, the app can still throw on startup. The generated .kt files land in build/generated/ksp/, and you need KSP configured and on the right classpath. For most projects, that's no trouble at all. Koin is mature, widely used, and well documented.

Metro works at a different level. It's a compiler plugin operating on the IR representation of your code, the same layer where kotlinx.serialization and the Compose compiler plugin live. There are no generated source files, no build/generated/ directory, no KSP classpath to manage. The output is direct constructor calls baked into bytecode. Metro has no runtime container. If the graph is invalid, the build doesn't produce a binary.

The trade-offs are real on both sides. Metro is strictly static, so dynamic or conditional bindings that Koin handles without friction are outside Metro's scope. And Metro is a newer library, primarily maintained by one person. Koin has years of production use behind it.

My take: if your team already knows Koin, or you need dynamic binding flexibility, stick with Koin Annotations. If you want the strictest compile-time guarantee with no runtime container underneath, Metro is the right call.

If you know Dagger

If you've used Dagger before, Metro will feel familiar in the right places. @Inject, @Provides, scope annotations, and compile-time graph validation are all there. The graph interface replaces Dagger's @Component, @DependencyGraph.Factory replaces @Component.Builder, and @ContributesBinding replaces the manual @Binds plus module wiring you'd write by hand in Dagger.

Metro is a compiler plugin rather than an annotation processor, so no KAPT, no KSP, no generated source files to track. The interface-based graph definition is cleaner than Dagger's abstract class components. And Metro supports Kotlin Multiplatform natively. Dagger never did. If you've used Hilt, the @ContributesBinding aggregation pattern will look familiar: Metro borrows it from Anvil, which is what Hilt builds on.

Where Dagger still has an edge: it's been used in large Android codebases for over a decade, the tooling is more mature, and the error messages are better when something goes wrong. If you're on a large Android-only project with an existing Dagger graph, there's rarely a reason to migrate.


The demo

The three patterns I want to show are easier to understand in context than in isolation. To make them concrete, I built a fake real-time chat app: a conversations list, a notification permission screen, and a chat room with a scoped WebSocket. No server, no real backend. Just hardcoded data and coroutine timers. Each screen demonstrates one Metro concept, and together they cover the situations you'll hit in almost any real KMP project.

The article focuses entirely on the DI wiring. The UI is minimal by design.


Setup

# gradle/libs.versions.toml
[versions]
metro = "1.0.0"

[plugins]
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }

[libraries]
metro-viewmodel = { module = "dev.zacsweers.metro:metrox-viewmodel", version.ref = "metro" }
metro-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" }
// build.gradle.kts (root)
plugins {
    alias(libs.plugins.metro) apply false
}

Apply the plugin to every module that contains Metro annotations and add the ViewModel extensions to the modules that need them:

// composeApp/build.gradle.kts
plugins {
    alias(libs.plugins.metro)
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.metro.viewmodel)
            implementation(libs.metro.viewmodel.compose)
        }
    }
}

metrox-viewmodel and metrox-viewmodel-compose are what give you ViewModelGraph, LocalMetroViewModelFactory, and @ContributesIntoMap for lifecycle-aware ViewModel injection. The demo uses all three.

One Gradle plugin, two extra libraries. No annotation processor configuration, no generated source directories to wire into your source sets.

The project organises code by package rather than separate Gradle modules. Metro's contribution system works at the annotation level, not the module boundary, so packages are enough. A scope marker is all you need to get started:

// commonMain/di/AppScope.kt
import dev.zacsweers.metro.Scope

@Scope
annotation class AppScope

Feature 1: Conversations list

This is the baseline. The pattern you'll use for most features in any KMP project.

The interface lives in the domain layer:

// commonMain/feature/conversations/domain/ConversationRepository.kt
interface ConversationRepository {
    suspend fun getConversations(): List<Conversation>
}

The implementation attaches itself to the graph:

// commonMain/feature/conversations/data/FakeConversationRepository.kt
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.delay

@Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class FakeConversationRepository : ConversationRepository {

    override suspend fun getConversations(): List<Conversation> {
        delay(300) // fake network latency
        return listOf(
            Conversation(id = "1", name = "Garage channel", lastMessage = "Tyres are warm."),
            Conversation(id = "2", name = "Pit wall", lastMessage = "Box this lap.")
        )
    }
}

@ContributesBinding(AppScope::class) tells Metro: this class implements ConversationRepository and belongs to AppScope. The root graph never needs to declare it. Metro aggregates everything contributed to a scope automatically at compile time. @SingleIn(AppScope::class) keeps one instance alive for the app's lifetime. Remove it and Metro creates a new repository on every injection, which is almost never what you want for a repository.

This is the pattern that makes multi-feature wiring low-ceremony. Each feature declares its own contributions and the root graph stays small.

Lifecycle-aware ViewModel injection

The ViewModel follows the same constructor injection pattern, but contributing it uses a map:

// commonMain/feature/conversations/presentation/ConversationsViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.ViewModelKey
import dev.zacsweers.metro.binding
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

@Inject
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
@ViewModelKey(ConversationsViewModel::class)
class ConversationsViewModel(
    private val repository: ConversationRepository
) : ViewModel() {

    private val _conversations = MutableStateFlow<List<Conversation>>(emptyList())
    val conversations: StateFlow<List<Conversation>> = _conversations

    init {
        viewModelScope.launch {
            _conversations.value = repository.getConversations()
        }
    }
}

@ContributesIntoMap puts this ViewModel into a map that Metro generates for the scope. The @ViewModelKey annotation sets the key. That map feeds into MetroViewModelFactory, an abstract class from Metro's ViewModel extensions that you subclass once per scope.

Metro won't provide MetroViewModelFactory automatically. It's abstract and has no @Inject constructor. You bridge the gap with a small file that subclasses it for each scope:

// commonMain/di/ViewModelFactory.kt
@Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AppViewModelFactory(
    override val viewModelProviders: Map<KClass<out ViewModel>, () -> ViewModel>,
    override val assistedFactoryProviders: Map<KClass<out ViewModel>, () -> ViewModelAssistedFactory>,
    override val manualAssistedFactoryProviders: Map<KClass<out ManualViewModelAssistedFactory>, () -> ManualViewModelAssistedFactory>,
) : MetroViewModelFactory()

The class is @Inject, so Metro can construct it. The three maps are populated from @ContributesIntoMap contributions in AppScope. @ContributesBinding wires AppViewModelFactory to the MetroViewModelFactory binding in the scope. (You'll see the ChatViewModelFactory equivalent in Part 2, when we introduce a second scope.)

ViewModelGraph then exposes metroViewModelFactory, which you provide through a CompositionLocal:

// commonMain/App.kt
@Composable
fun App(graph: RootGraph) {
    CompositionLocalProvider(LocalMetroViewModelFactory provides graph.metroViewModelFactory) {
        AppTheme { AppEntry() }
    }
}

Inside any screen below that provider, metroViewModel<ConversationsViewModel>() resolves from the map, scoped to the back stack entry, cleaned up when the screen pops. The Metro wiring doesn't touch any of that: it just provides the factory.

A quick note on metroViewModelFactory: Compose's viewModel() function uses reflection by default, which doesn't work with Metro since there's no runtime container. metroViewModel<T>() from metrox-viewmodel-compose reads LocalMetroViewModelFactory and constructs ViewModels from the contributed map instead. The MetroViewModelFactory subclass you write in ViewModelFactory.kt is the bridge: it takes the generated provider maps as constructor arguments and wires them into the factory. Everything else works exactly as you'd expect from the Jetpack ViewModel API.

The thing that clicked for me: @ContributesBinding contributes a single binding, @ContributesIntoMap contributes an entry to a map. Both are aggregated automatically. Neither requires touching the root graph.


What's next

Part 1 covers the foundation: how Metro differs from Koin and Dagger, how to wire up a simple feature with @ContributesBinding, and how ViewModel injection works through contributed maps.

[Part 2]([PART 2 LINK]) is where it gets more interesting. It covers two patterns that every multiplatform project eventually runs into: how to split platform-specific graph implementations correctly (spoiler: the common interface carries no Metro annotations at all), and how to create a scoped graph for dependencies with a real lifecycle — including how a runtime value like a conversation ID flows into a child scope through a factory parameter.

The full demo for this article is available on GitHub.

The KMP Bits app is available on App Store and Google Play, built entirely with KMP.

155 Points6 Badges6
2Posts
0Comments
Mobile Dev & KMP enthusiast. Fueling technical insights with racing analogies. Sharing stories to build better apps, one pit stop at a time at KMP Bits.
Build your own developer journey
Track progress. Share learning. Stay consistent.
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Drop the Clutch: Three Metro DI Patterns Every KMP Developer Should Know - Part 2

kmp-bits - Jun 5

5 EF Core Features Every Enterprise Developer Should Know

Spyros - Jun 5

Beyond the CLI: Mastering Lambda Invocation Patterns with Terraform

tuni56 - Apr 29

JetBrains Just Changed KMP Structure. Here's What They Didn't Tell You.

numq - May 18

FLIP: Modular Architecture for KMP

numq - May 16
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!