Why Suspend Provider Scope Ownership Is Metro DI’s Real Design Gap
Every writeup of Metro’s suspend providers, mine included, walks through the same beats: mark a provider suspend, suspension propagates through the graph, scoped bindings get single-flight coalescing, don’t forget your own withContext. That’s the surface, and it’s the part that’s easy to demo in a release note. Underneath it sits a question nobody’s actually answering, and it’s not a syntax question — it’s a structured concurrency question that DI frameworks have spent a decade avoiding by never letting a graph hold a running coroutine in the first place. Metro just changed that, quietly, and the implications go deeper than a changelog entry.
Here’s the question: when a scoped suspend provider is in flight — ten coroutines awaiting one provideConfig() call — whose coroutine is actually doing the work? Who owns it? What happens to it if the caller that triggered it gets cancelled while nine other callers are still legitimately waiting? A dependency graph, in every DI framework that predates this feature, is a plain object graph with no lifecycle of its own. It doesn’t have a CoroutineScope. It’s not a thing that gets cancelled, paused, or torn down — it’s just references between objects, resolved once and then inert. Suspend providers quietly turn part of that graph into something with a lifecycle, without giving it an explicit owner. That mismatch is the actual story here, and it’s one I haven’t seen anyone name directly, either in the release notes or in the writeups that followed it.
An Object Graph Is Not a Coroutine Scope
Structured concurrency’s entire premise is that every coroutine has a parent, and no coroutine outlives the scope that launched it. That’s the rule that makes coroutines safe to reason about: cancellation flows down, exceptions flow up, nothing leaks. A @DependencyGraph interface, by contrast, has never needed this property, because until now nothing inside it actually ran concurrently with anything else. Construction was just method calls returning finished values.
@DependencyGraph(AppScope::class)
interface AppGraph {
@Provides @SingleIn(AppScope::class)
suspend fun provideConfig(api: ConfigApi): RemoteConfig =
withContext(Dispatchers.IO) { api.fetch() }
}
The moment that function is suspend and scoped, something has to actually launch and hold a coroutine somewhere to implement single-flight — the “ten callers, one call” guarantee doesn’t happen by accident, it requires an in-flight Deferred or equivalent living inside the graph’s internal state. But AppGraph was never designed to be a coroutine-owning object. It has no declared parent scope, no defined teardown, no cancellation boundary. Metro has to invent an implicit scope internally to make single-flight work at all, and that scope’s lifetime is now decoupled from anything a developer writes explicitly. This is the part that should make anyone with structured-concurrency instincts uncomfortable: a coroutine is running, and there’s no line of code anywhere in the application that says who’s responsible for it.
Compare this to how a coroutine gets launched anywhere else in a well-structured Kotlin codebase. You never write launch without a scope in hand — viewModelScope, lifecycleScope, a custom application-level scope tied deliberately to process lifetime. The entire discipline Kotlin coroutines were built around is “no orphan coroutines, ever.” A suspend provider’s internal single-flight coroutine is, functionally, an orphan: it exists because the framework needs it to, not because a developer reasoned about its parent and decided this was the right lifetime for it. That’s not a criticism of the implementation quality — it’s a statement about what kind of problem this actually is, and it’s a harder problem than “add a suspend keyword and let the compiler propagate it.”
Why Most Kotlin Developers Misuse Variables — And Pay for It at Runtime Standard Kotlin tutorials teach you val x = 5 and move on. What they skip is everything that actually matters: how Kotlin...
The Cancellation Question Nobody’s Forced to Answer Yet
Push on this a little and you hit a genuinely unresolved scenario. Ten screens independently request the same scoped RemoteConfig during cold start. Screen one triggers the actual fetch. The user backgrounds the app half a second later, and whatever coroutine scope screen one was running in gets cancelled — that’s completely normal, expected Android lifecycle behavior. The other nine callers are still suspended, waiting on the same in-flight result. What happens to them?
// Screen 1 triggers the fetch, then its own scope is cancelled
viewModelScope.launch {
val config = graph.config() // triggers provideConfig()
render(config)
}
// Screens 2 through 10, on different viewModelScopes,
// are awaiting the *same* underlying single-flight call
viewModelScope.launch {
val config = graph.config() // awaits, doesn't trigger
render(config)
}
If Metro propagates screen one’s cancellation into the shared in-flight coroutine, the other nine callers get cancelled too, through no fault of their own, because they happened to be unlucky enough to piggyback on the first caller instead of triggering their own independent fetch. That’s arguably worse than not having single-flight at all — at least with nine independent network calls, one user backgrounding their app only kills one of them. If it doesn’t propagate cancellation — which is the only sane behavior — then the underlying coroutine has to be running in a scope that outlives every individual caller’s scope. Which raises the follow-up question immediately: outlives them until when, exactly? Process death? App scope teardown? An idle timeout nobody configured? There’s no answer printed anywhere because the question has never had to be asked before in a compile-time DI framework. This isn’t a bug report waiting to happen; it’s a design decision that got made implicitly, as a side effect of implementing single-flight, rather than made deliberately and documented where a developer auditing the framework’s behavior could find it.
It gets murkier still with the failure path. Say the in-flight fetch throws, and Metro’s documented “retries after failure” behavior kicks in. Which caller’s context does that retry inherit — the original triggering coroutine, which may already be gone by the time the retry fires, or some internal graph-owned context that was never surfaced to application code at all? If it’s the latter, the retry is running detached from any lifecycle the developer controls, which is precisely the failure mode structured concurrency exists to prevent. I’d want to see this behavior specified explicitly, with a test suite exercising exactly this scenario, before trusting a scoped suspend binding anywhere near a screen with aggressive navigation and cancellation patterns.
Why Koin’s Scope Model Sidesteps This and Metro’s Doesn’t
It’s worth comparing this against how runtime DI frameworks that already deal with lifecycle handle the same problem, because the contrast is instructive. Koin has an explicit scope system — Scope objects tied to activities, view models, or custom lifecycle boundaries, opened and closed by the developer, with defined semantics for what happens to anything held inside when the scope closes. It’s runtime, not compile-time, and it comes with its own tradeoffs, but the ownership question Metro’s suspend providers raise is one Koin’s scope model was built to answer from day one, because runtime frameworks had to confront lifecycle-bound state long before async binding resolution existed.
// Koin-style: the scope's lifetime is explicit and developer-owned
val screenScope = getKoin().createScope()
val repo = screenScope.get()
// closing is explicit too
screenScope.close()
Metro, being compile-time and static by design, doesn’t have an equivalent concept — a @DependencyGraph is either process-scoped or manually created and discarded, with nothing in between that maps cleanly onto “this suspend binding’s lifetime should track this specific screen.” Compile-time DI’s whole value proposition is that the graph is validated and wired at build time with no runtime container tracking object lifetimes. Suspend providers are the first feature that genuinely wants runtime lifecycle tracking, and they’re being bolted onto a framework philosophy that was explicitly built to avoid needing it. That tension, not the syntax, is the interesting engineering story in this release.
Kotlin Jetpack Compose Keyboard Shortcuts: Handling Hotkeys with DS Kotlin Jetpack Compose keyboard shortcuts often fail not because of broken APIs, but because of incorrect assumptions about focus and event propagation. Most developers attach onKeyEvent,...
It’s not an accident that Koin ended up here first. A runtime container already has to track object instances at runtime to support features like scope-bound singletons and manual teardown, so adding lifecycle-aware coroutine ownership on top of that is a natural extension of infrastructure it already has. Metro’s entire performance pitch is that it avoids exactly that kind of runtime bookkeeping — no hashmap lookups, no service locator, direct calls baked into bytecode at compile time. Suspend providers are, structurally, the first feature in Metro that wants some of Koin’s runtime machinery back, just for the narrow slice of the graph that’s suspend-tainted. Whether that’s a contained exception or the start of compile-time DI slowly re-growing the runtime container it was designed to eliminate is a genuinely open architectural question, and it’s more interesting than anything in the feature’s syntax.
The Distributed-Systems Parallel That Actually Matters
Once a DI graph can hold in-flight coroutines with retry semantics, it stops behaving like an object graph and starts behaving like a small distributed system, whether or not anyone designed it that way on purpose. Single-flight coalescing plus automatic retry-after-failure is precisely the shape of a client-side circuit breaker — except embedded invisibly inside dependency resolution instead of sitting explicitly in a networking layer where engineers know to reason about retry storms and backoff.
// Two independently-scoped suspend bindings,
// both retrying against the same failing downstream service
@Provides @SingleIn(AppScope::class)
suspend fun provideConfig(api: ConfigApi): RemoteConfig =
withContext(Dispatchers.IO) { api.fetch() } // retries on failure
@Provides @SingleIn(AppScope::class)
suspend fun provideFeatureFlags(api: FlagsApi): FeatureFlags =
withContext(Dispatchers.IO) { api.fetch() } // also retries, same host
If ConfigApi and FlagsApi happen to hit the same backend during an incident, and both scoped bindings are independently retrying with whatever backoff Metro applies internally, the DI layer has become a source of retry amplification that no one on the team is watching, because nobody thinks of dependency injection as a place where retry storms originate. Backend teams instrument and rate-limit client retries at the networking layer precisely because this failure mode is well understood there. Burying retry behavior inside a compiler plugin’s binding resolution, invisible to APM tooling and outside the mental model of anyone debugging an incident, moves a known distributed-systems risk into a layer of the stack that has no tooling built to see it.
This is the kind of failure mode that doesn’t show up in a demo and doesn’t show up in the first month of production either. It shows up during a backend incident six months in, when someone on-call is staring at a dashboard trying to figure out why client-side load on a degraded service isn’t dropping even after the mobile team supposedly added backoff everywhere they could find it — because two of the retry loops live inside DI providers nobody thought to check, since nobody expects a dependency graph to be a place where network retry policy lives at all. The fix isn’t complicated once you know where to look, but knowing where to look is exactly the problem when the behavior is undocumented and unconfigurable.
Kotlin K2 Compiler Migration: What Breaks and Why Kotlin K2 compiler migration is the upgrade most teams put off until they couldn't anymore — and now they're dealing with the consequences. K2 became the default...
What a Structurally Sound Version of This Would Actually Look Like
None of this means suspend providers are a bad idea — it means the feature is solving a real problem with an incomplete answer to a question it implicitly raised. A structurally sound version would make scope ownership explicit rather than implicit: graphs that want suspend providers would declare a parent CoroutineScope up front, single-flight coroutines would be children of that declared scope with documented cancellation semantics, and retry policy would be a visible, configurable parameter rather than an internal default nobody can inspect.
// Hypothetical explicit-scope API, not what exists today
@DependencyGraph(AppScope::class)
interface AppGraph {
val suspendScope: CoroutineScope // explicit owner, explicit lifetime
@Provides @SingleIn(AppScope::class)
suspend fun provideConfig(api: ConfigApi): RemoteConfig =
withContext(Dispatchers.IO) { api.fetch() }
}
This is roughly the shape of the fix, and I’d expect something resembling it to land as the feature matures out of experimental status — probably paired with configurable retry limits and an explicit timeout, both of which are also missing today. Until that lands, the honest position is that Metro has shipped the ergonomic half of async DI — the part developers see and write — without shipping the lifecycle half, which is the part that determines whether this is safe to depend on under real production failure conditions.
What This Means for Teams Adopting It Today
Practically, this changes how I’d frame the rollout advice compared to treating it as just another experimental flag to try in a spike. Don’t put suspend providers on any dependency where an uncontrolled retry against a failing backend has real cost — payment initialization, auth token refresh, anything metered. Do put them on dependencies where “eventually resolves or the screen shows a retry state” is an acceptable outcome, like non-critical feature flags. And instrument it: wrap suspend providers with your own logging around entry, cancellation, and retry so you have visibility the framework doesn’t currently give you, rather than trusting an opaque internal mechanism during an incident.
@Provides @SingleIn(AppScope::class)
suspend fun provideConfig(api: ConfigApi): RemoteConfig =
withContext(Dispatchers.IO) {
logger.d("config fetch started")
try {
api.fetch().also { logger.d("config fetch resolved") }
} catch (e: CancellationException) {
logger.d("config fetch cancelled, propagation unclear")
throw e
}
}
The syntax Metro shipped is genuinely good, and single-flight coalescing solves a real bug class better than the hand-rolled mutex patterns most teams carry today. But the framework has, for the first time, put a coroutine inside a data structure that was never designed to own one, and it hasn’t yet answered who’s responsible for that coroutine’s lifetime. That’s not a documentation gap — it’s an open design question, and it’s the thing worth watching in the next few point releases far more than the feature list.
Written by: