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

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

6
calendar_today agoschedule9 min read

In Part 1, I covered Metro's fundamentals: how it differs from Koin Annotations and Dagger, the basic setup, and the @ContributesBinding pattern for everyday features. This part picks up where that left off, with two patterns that push past the basics — platform-specific graph splitting and scoped lifetimes with runtime values.


Feature 2: Notification permission

This is where Metro in a multiplatform project gets genuinely tricky, and where the constraint that makes it correct becomes obvious.

On Android, requesting notification permission requires Context, which goes to ActivityCompat.requestPermissions. On iOS, you use UNUserNotificationCenter. Neither type exists in commonMain. The interface for the provider lives there, but the implementations are entirely platform-specific. If you want a deep dive into cross-platform notifications with KMP, I covered the full setup in a previous article.

The instinct is to put @DependencyGraph on the common interface and let platform graphs extend it:

// WRONG: fails to compile on the iOS target
// commonMain/di/AppGraph.kt
@DependencyGraph(scope = AppScope::class)
interface AppGraph {
    @DependencyGraph.Factory
    fun interface Factory {
        fun create(@Provides context: Context): AppGraph // Context is an Android type
    }
}

The iOS compiler doesn't know what Context is. The build fails with an unresolved reference. After that error, we can try @GraphExtension. Metro provides it for extending an existing graph, but @GraphExtension.Factory is a different annotation from @DependencyGraph.Factory, and createGraphFactory only accepts the second one. You end up with a graph you can't instantiate.

I went through both of those dead ends before the correct pattern became clear: the common interface is just a plain Kotlin interface with no Metro annotations. The @DependencyGraph goes only on the platform-specific implementations.

// commonMain/di/RootGraph.kt
import dev.zacsweers.metrox.viewmodel.ViewModelGraph

// Plain Kotlin interface, no @DependencyGraph
interface RootGraph : ViewModelGraph {
    val platformNotificationProvider: PlatformNotificationProvider
}
// androidMain/di/AndroidRootGraph.kt
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Provides

@DependencyGraph(scope = AppScope::class)
interface AndroidRootGraph : RootGraph {

    @DependencyGraph.Factory
    fun interface Factory {
        fun create(@Provides context: Context): AndroidRootGraph
    }
}
// iosMain/di/IosRootGraph.kt
import dev.zacsweers.metro.DependencyGraph

@DependencyGraph(scope = AppScope::class)
interface IosRootGraph : RootGraph

Creating the graph on each platform:

// androidMain/App.kt
class App : Application() {
    val graph: RootGraph by lazy {
        createGraphFactory<AndroidRootGraph.Factory>().create(applicationContext)
    }
}
// iosMain/MainViewController.kt
fun MainViewController() = ComposeUIViewController {
    val graph = remember { createGraph<IosRootGraph>() }
    App(graph)
}

createGraph<T>() expects T to be a @DependencyGraph with no required external parameters. createGraphFactory<F>() expects F to be a @DependencyGraph.Factory inside a graph that needs those parameters. Both return a RootGraph, which is what the shared App() composable receives. The composable never needs to know which platform it's running on.

Platform implementations

The notification provider expect class lives in common:

// commonMain/core/domain/PlatformNotificationProvider.kt
import kotlinx.coroutines.flow.StateFlow

expect class PlatformNotificationProvider {
    val permissionGranted: StateFlow<Boolean>
    fun requestPermission()
    fun observeNotificationServicePermission()
}

The Android implementation uses Context and an ActivityHolder, a wrapper that MainActivity updates on onCreate/onDestroy so DI-managed classes can reach the current Activity without holding a direct reference to it:

// androidMain/feature/notifications/data/PlatformNotificationProviderImpl.kt
@Inject
@SingleIn(AppScope::class)
actual class PlatformNotificationProvider(
    private val context: Context,
    private val activityHolder: ActivityHolder
) {
    companion object {
        const val REQUEST_CODE_NOTIFICATIONS = 1001
    }

    private val _permissionGranted = MutableStateFlow(false)
    actual val permissionGranted: StateFlow<Boolean> = _permissionGranted

    actual fun requestPermission() {
        val activity = activityHolder.get() ?: return
        ActivityCompat.requestPermissions(
            activity,
            arrayOf(Manifest.permission.POST_NOTIFICATIONS),
            REQUEST_CODE_NOTIFICATIONS
        )
    }

    actual fun observeNotificationServicePermission() {
        _permissionGranted.value = ContextCompat.checkSelfPermission(
            context, Manifest.permission.POST_NOTIFICATIONS
        ) == PackageManager.PERMISSION_GRANTED
    }
}
Android iOS
Notification permission dialog being requested and granted Notification permission dialog being requested and granted

Android doesn't deliver permission results to DI-managed classes. They come back to Activity.onRequestPermissionsResult. The pattern that works: MainActivity calls observeNotificationServicePermission() in the callback, the provider re-reads the system state, and the MutableStateFlow updates. Everything else observes the flow.

// androidMain/MainActivity.kt
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String?>,
    grantResults: IntArray,
    deviceId: Int
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults, deviceId)
    if (requestCode == PlatformNotificationProviderImpl.REQUEST_CODE_NOTIFICATIONS) {
        graph.platformNotificationProvider.observeNotificationServicePermission()
    }
}

The iOS implementation uses UNUserNotificationCenter.

The error you get if you annotate the common graph isn't Metro being fussy. It's the compiler catching a design that would break the iOS build entirely. The plain interface isn't a workaround. It's the correct model.


Feature 3: Chat room

This is the pattern I wanted to understand before committing to Metro seriously. The first two features use AppScope for everything, which makes sense: repositories and providers live for the app's lifetime. But not everything does.

A WebSocket isn't a data object. It connects on creation and needs to be explicitly closed. Keeping it in AppScope means the socket stays open for the entire app lifetime, even when the user is on a completely different screen. That's almost never what you want for a real-time connection.

A scope is a lifetime, not a namespace. The ChatScope exists because the socket needs to be opened when the chat screen opens and closed when it leaves. And because every chat room is a different conversation, there's a second problem to solve: the conversation ID is only known at runtime, when the user taps a row. Metro needs to receive it and make it available to anything in the scope.

Here's how the two scopes relate in the demo:

AppScope  (AndroidRootGraph / IosRootGraph)
├── ConversationRepository       singleton, lives for the app's lifetime
├── PlatformNotificationProvider singleton, lives for the app's lifetime
├── AppViewModelFactory
│
└── ChatScope  (ChatGraph) ──── created via ChatGraph.Factory at navigation time
    ├── ConversationId           runtime value, injected through the factory parameter
    ├── ChatSocket               singleton within this scope
    └── ChatViewModelFactory

ChatScope is nested inside AppScope and inherits all of its bindings. ChatGraph uses @GraphExtension for exactly that reason. Anything in ChatScope can ask for ConversationRepository or PlatformNotificationProvider and Metro will resolve them from the parent graph. The reverse isn't true: AppScope knows nothing about ChatSocket or ConversationId.

The scope and the socket

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

@Scope
annotation class ChatScope

The conversation ID is a runtime value, so it gets a proper type:

// commonMain/feature/chat/domain/ConversationId.kt
@JvmInline
value class ConversationId(val value: String)

A value class, different from a raw String, keeps the binding unambiguous. Metro identifies bindings by type, so ConversationId and String are different things in the graph.

// commonMain/feature/chat/data/ChatSocket.kt
@Inject
@SingleIn(ChatScope::class)
class ChatSocket(val conversationId: ConversationId) {

    private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
    val messages: StateFlow<List<ChatMessage>> = _messages

    private val _isTyping = MutableStateFlow(false)
    val isTyping: StateFlow<Boolean> = _isTyping

    private var job: Job? = null

    init {
        job = CoroutineScope(Dispatchers.Default).launch {
            var lap = 1
            while (true) {
                delay(2_000)
                _isTyping.value = true
                delay(1_000)
                _isTyping.value = false
                _messages.update {
                    it + ChatMessage(sender = "Engineer", text = "Lap $lap: pace looks good.")
                }
                lap++
            }
        }
    }

    fun close() {
        job?.cancel()
        job = null
    }
}

ChatSocket declares ConversationId as a constructor parameter. Metro will inject it — no special annotation needed on the parameter itself. The binding just has to exist somewhere in the scope, and the factory is where it comes from.

@SingleIn(ChatScope::class) means one ChatSocket per ChatGraph. Not one per app, one per chat session. Navigate away and come back, and you get a fresh socket with its own conversation ID.

The graph extension

The chat graph uses @GraphExtension rather than @DependencyGraph. The difference is that @GraphExtension extends an existing parent graph and inherits all of its bindings. @DependencyGraph creates a standalone graph that knows nothing about its caller.

// commonMain/di/ChatGraph.kt
import dev.zacsweers.metro.GraphExtension
import dev.zacsweers.metro.Provides
import dev.zacsweers.metrox.viewmodel.ViewModelGraph

@GraphExtension(scope = ChatScope::class)
interface ChatGraph : ViewModelGraph {
    val chatSocket: ChatSocket

    @ContributesTo(AppScope::class)
    @GraphExtension.Factory
    fun interface Factory {
        fun create(@Provides conversationId: ConversationId): ChatGraph
    }
}

@Provides on the factory parameter is Metro's answer to runtime injection. When create(conversationId) is called, Metro takes that value and makes it available as a binding for the entire ChatScope. ChatSocket asks for ConversationId in its constructor; Metro delivers the one passed into the factory. No extra wiring, no manual passing.

ConversationId isn't being threaded through a call chain. It's a graph-level binding. Any class in ChatScope can list it as a constructor parameter and get the right one automatically. The ChatGraph instance is this specific conversation. Add a read-receipt tracker or a typing service later and they get the ID for free, for as long as the socket is alive.

@ContributesTo(AppScope::class) on the factory tells Metro to aggregate it into the root graph automatically, so the root graph exposes chatGraphFactory without needing to declare it explicitly.

The entry composable

// commonMain/feature/chat/presentation/ChatEntry.kt
@Composable
fun ChatEntry(
    factory: ChatGraph.Factory,
    conversationId: ConversationId,
    onBackClick: () -> Unit
) {
    val graph = remember { factory.create(conversationId) }

    DisposableEffect(Unit) {
        onDispose { graph.chatSocket.close() }
    }

    CompositionLocalProvider(LocalMetroViewModelFactory provides graph.metroViewModelFactory) {
        ChatScreen(onBackClick = onBackClick)
    }
}

remember { factory.create(conversationId) } creates the graph once for the lifetime of this composable. The conversationId comes from the navigation back-stack entry and is fixed for the session, which is why remember with no key is correct here. DisposableEffect with onDispose calls close() when the composable leaves the composition. CompositionLocalProvider swaps the ViewModel factory to the one backed by ChatScope, so metroViewModel() calls inside ChatScreen resolve from ChatGraph, not the root AppScope factory.

Chat screen: typing indicator followed by incoming message

The socket starts emitting as soon as ChatEntry enters the composition. While the screen is open, a new message arrives every three seconds, preceded by a one-second typing indicator. The moment you navigate away, onDispose fires, close() cancels the coroutine, and it stops. Navigate back to a different conversation and you get a fresh ChatGraph, a fresh ChatSocket, and a new ConversationId, all from a single factory.create(newId) call.

AppScope would mean the socket survives every screen transition for the app's entire lifetime. ChatScope means it lives exactly as long as the chat screen. The @Provides on factory parameter means runtime values flow into the scope cleanly, without leaking Android lifecycle details into the graph definition.


Gotchas

Most of these I hit personally while building the demo. Metro's error messages are generally good, but a few failure modes produce errors that point at the wrong place. You see the interface in the message, not the implementation that caused the problem. That's the pattern to watch for.

internal on @ContributesBinding classes.
If a contributed class is internal (usually in a multi-module project), Metro can't aggregate it across module boundaries. The reason: Metro's compiler plugin generates glue code that references your class by name. If that class is internal, the generated code sits outside the declaring module's visibility boundary and can't see it. The build succeeds, but you'll get a MissingBinding error naming the interface, not the implementation. This one is hard to trace the first time. Keep contributed classes public (the default) or at least within the same Gradle module as every class that needs them.

@DependencyGraph cannot extend @DependencyGraph.
Metro rejects this at compile time: "Graph class may not directly extend graph class." The plain-interface pattern from Feature 2 is the correct solution: annotate only the platform-specific leaf implementations.

createGraph vs createGraphFactory.
createGraph<T>() expects T to be a @DependencyGraph with no required external parameters. createGraphFactory<F>() expects F to be a @DependencyGraph.Factory. These are different annotations; mixing them produces a compiler error that points at the right place, but the fix isn't obvious until you understand the split.

@GraphExtension vs @DependencyGraph for child graphs.
Use @GraphExtension when the child graph should inherit parent bindings, which is the right choice for a scoped feature graph like ChatGraph. Use @DependencyGraph only for the root platform graphs. Mixing them up produces a graph that either can't see parent bindings or can't be instantiated correctly.

Scope mismatch.
A class scoped to ChatScope that gets injected into something in AppScope produces a MissingBinding error at compile time. The error names the interface, not the implementation. When you see it, check whether the implementation is scoped to a graph the requester can't reach.

Manifest permissions are a separate concern.
This is more an Android issue. Metro wires up a class that calls requestPermissions correctly. But if POST_NOTIFICATIONS isn't declared in AndroidManifest.xml, the dialog won't appear and there's no error anywhere. Always check the manifest first if a permission flow does nothing.


Wrapping up

Metro moves dependency graph validation from runtime to compile time, and that shift changes how you think about DI mistakes. The patterns here cover most of the ground: binding contributions for everyday features, the platform graph split for anything that needs Android or iOS types, and child scopes for dependencies with a real lifecycle, including runtime values like a conversation ID flowing in through a factory parameter. Get those three things in place and the rest of the wiring tends to solve itself.

The paddle shifters don't make you a faster driver. But they do free up your hands for the things that actually determine where you finish.


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 1

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)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!