Extending the DI Graph
The IDK uses Metro for dependency injection. Metro is a Kotlin compiler plugin that generates all DI wiring at compile time, so there is no runtime reflection and no annotation processing step like KSP. This makes it fully compatible with Kotlin Multiplatform, including native targets and GraalVM.
Every IDK application defines its own application graph (see Application Setup). This page explains how Metro works, how the IDK uses it, and how you can contribute your own services into the dependency graph.
The Metro Compiler Plugin
The Metro Gradle plugin must be applied to any module that defines or contributes to a dependency graph:
plugins {
id("dev.zacsweers.metro") version "<version>"
}
That's it: no annotation processor, no KSP plugin, no code generation task to configure. Metro hooks into the Kotlin compiler directly.
How Metro DI Works
Metro uses a small set of annotations to define dependency graphs at compile time. The compiler plugin reads these annotations, resolves the dependency tree, and generates the wiring code as part of normal Kotlin compilation. There is no service locator, no classpath scanning, and no runtime overhead.
The key concepts are:
- Dependency graphs define a set of services and how they are created
- Scopes control the lifetime of instances (singleton per scope)
- Contributions let modules declare bindings that are automatically merged into a graph
- Factories define the inputs needed to create a graph instance
Key Annotations
| Annotation | Purpose |
|---|---|
@DependencyGraph | Marks an abstract class as a dependency graph (the top-level container for a scope) |
@DependencyGraph.Factory | Interface for creating a graph instance with external parameters |
@GraphExtension | Defines a child scope graph that extends a parent graph |
@GraphExtension.Factory | Interface for creating a child scope instance |
@Provides | Marks a method or parameter that supplies a dependency |
@Inject | Marks a class for constructor injection |
@SingleIn(Scope::class) | Scopes an instance as a singleton within the given scope |
@ContributesTo(Scope::class) | Contributes an interface (with accessors) to a scope's graph |
@ContributesBinding(Scope::class) | Binds an implementation to its interface within a scope |
@ContributesIntoSet(Scope::class) | Contributes an implementation into a Set<T> multibinding |
@Named("name") | Distinguishes multiple bindings of the same type |
@ForScope(Scope::class) | Qualifies a binding to a specific scope (used with multibindings) |
@Multibinds | Declares a Set or Map multibinding that may have zero contributors |
Scope Markers
The IDK uses a three-level scope hierarchy:
| Scope | Marker Class | Lifetime |
|---|---|---|
| Application | AppScope | Entire application lifetime |
| User Context | UserScope | Per tenant + principal combination |
| Session | SessionScope | Per working session or request |
AppScope is provided by Metro. UserScope and SessionScope are defined by the IDK. When you annotate a class with @SingleIn(SessionScope::class), Metro ensures only one instance exists within each session.
Contributing Services to Existing Scopes
The most common extension pattern is contributing your own service implementations into an existing IDK scope. This lets your services participate in the same lifecycle and be injected alongside IDK services.
Single-Implementation Binding
Use @ContributesBinding when there is one implementation per interface:
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import com.sphereon.di.session.SessionScope
interface MyCredentialFormatter {
fun format(claims: Map<String, Any>): String
}
@Inject
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, binding = binding<MyCredentialFormatter>())
class JsonCredentialFormatter(
private val configProvider: ConfigProvider
) : MyCredentialFormatter {
override fun format(claims: Map<String, Any>): String {
// Format credentials as JSON
return claims.entries.joinToString("\n") { "${it.key}: ${it.value}" }
}
}
Metro automatically discovers this binding at compile time and includes it in the session scope graph. Any service in the session scope can now receive MyCredentialFormatter as a constructor parameter.
Set Multibinding
Use @ContributesIntoSet when multiple implementations contribute to a Set<T>:
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import dev.zacsweers.metro.AppScope
@Inject
@SingleIn(AppScope::class)
@ContributesIntoSet(AppScope::class, binding = binding<PropertySourceContribution>())
class MyCustomProviderContribution(
private val configService: AppConfigService
) : PropertySourceContribution {
override val configLevel = ConfigLevel.APP
override val providerId = "my-custom-provider"
override fun isEnabled(resolver: PropertyResolver): Boolean {
val disabled = resolver.getProperty(
"config.providers.my-custom-provider.enabled",
Boolean::class
)
return disabled != false
}
override fun getPropertySource(): PropertySource<*> =
MyCustomPropertySource(configService)
override fun getOrder(): Int = Order.MEDIUM.orderValue
}
This contributes your provider into the Set<PropertySourceContribution> that the IDK's configuration bootstrap process collects at startup.
Exposing Accessors on Scope Graphs
Use @ContributesTo to add accessor properties to an existing scope's graph. This makes your service available as a named property on the graph:
import dev.zacsweers.metro.ContributesTo
import com.sphereon.di.session.SessionScope
@ContributesTo(SessionScope::class)
interface MyCredentialFormatterAccessor {
val myCredentialFormatter: MyCredentialFormatter
}
With this in place, any code that has access to the session graph can call session.graph.myCredentialFormatter.
Defining the Application Graph
Every application defines its own @DependencyGraph(AppScope::class) class that extends AbstractAppGraph. At compile time, Metro merges all contributed bindings from the IDK modules on your classpath into this graph:
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.Named
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.createGraphFactory
import com.sphereon.di.app.AbstractAppGraph
import com.sphereon.di.app.RootScopeProvider
import com.sphereon.core.defaults.app.DefaultRootScopeProvider
@DependencyGraph(AppScope::class)
abstract class MyAppGraph : AbstractAppGraph() {
@DependencyGraph.Factory
fun interface Factory {
fun create(
@Provides application: Any,
@Provides @Named("appId") appId: String,
@Provides @Named("profile") profile: String,
@Provides @Named("version") version: String,
@Provides rootScopeProvider: RootScopeProvider,
): MyAppGraph
}
companion object {
fun init(
application: Any,
appId: String = "my-app",
profile: String = "production",
version: String = "1.0.0"
): MyAppGraph {
val graph = createGraphFactory<MyAppGraph.Factory>().create(
application = application,
appId = appId,
profile = profile,
version = version,
rootScopeProvider = DefaultRootScopeProvider()
)
graph.initRootScopeProvider()
return graph
}
}
}
The @DependencyGraph(AppScope::class) annotation tells Metro to merge all @ContributesBinding and @ContributesTo contributions targeting AppScope into this graph. The child scopes (UserScope, SessionScope) are created automatically through the IDK's scope managers.
Testing with Metro
For unit tests, create a test-specific dependency graph that provides mock or stub implementations:
@DependencyGraph(SessionScope::class)
abstract class TestSessionGraph {
@Provides
fun provideKeyManager(): KeyManagerService = mockk()
@Provides
fun provideConfigProvider(): ConfigProvider = mockk()
@Provides
fun provideCredentialFormatter(
configProvider: ConfigProvider
): MyCredentialFormatter = JsonCredentialFormatter(configProvider)
}
class MyFormatterTest {
@Test
fun testFormat() {
val graph = createGraph<TestSessionGraph>()
val formatter = graph.provideCredentialFormatter(graph.provideConfigProvider())
val result = formatter.format(mapOf("name" to "Alice"))
assertEquals("name: Alice", result)
}
}
For integration tests where you need the full IDK scope hierarchy, use your application graph's init() as you would in production, then access services from the session graph.
Comparison with Other DI Frameworks
If you're coming from another DI framework, these concepts map as follows:
| Concept | Dagger | Koin | Metro |
|---|---|---|---|
| Container | @Component | module { } | @DependencyGraph |
| Binding | @Provides / @Binds | single { } / factory { } | @Provides / @ContributesBinding |
| Scope | @Singleton / custom | single | @SingleIn(Scope::class) |
| Injection | @Inject constructor | inject() / get() | @Inject constructor |
| Multibinding | @IntoSet | n/a | @ContributesIntoSet |
| Child scope | @Subcomponent | scope | @GraphExtension |
The main difference from Dagger is that Metro uses a compiler plugin rather than annotation processing, making it compatible with Kotlin Multiplatform without platform-specific workarounds. Unlike Koin, all wiring is validated at compile time, so missing dependencies are compilation errors, not runtime crashes.
Best Practices
Use constructor injection. Declare dependencies as constructor parameters on your @Inject-annotated classes. This makes dependencies explicit, enables compile-time validation, and simplifies testing.
@Inject
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, binding = binding<MyService>())
class MyServiceImpl(
private val keyManager: KeyManagerService,
private val configProvider: ConfigProvider
) : MyService {
// Implementation
}
Choose the right scope. Place services in the narrowest scope that fits their lifetime. Application-wide singletons go in AppScope, tenant-specific services in UserScope, and per-request or per-session services in SessionScope.
Avoid circular dependencies. If service A depends on B and B depends on A, restructure one of them to break the cycle. Metro validates the dependency graph at compile time and will report cycles as errors.
Keep contributions focused. Each @ContributesBinding or @ContributesIntoSet class should do one thing. Rather than a single large service, prefer multiple focused services that each contribute to the appropriate scope.