Skip to main content
Version: v0.25.0 (Latest)

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:

build.gradle.kts
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

AnnotationPurpose
@DependencyGraphMarks an abstract class as a dependency graph (the top-level container for a scope)
@DependencyGraph.FactoryInterface for creating a graph instance with external parameters
@GraphExtensionDefines a child scope graph that extends a parent graph
@GraphExtension.FactoryInterface for creating a child scope instance
@ProvidesMarks a method or parameter that supplies a dependency
@InjectMarks 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)
@MultibindsDeclares a Set or Map multibinding that may have zero contributors

Scope Markers

The IDK uses a three-level scope hierarchy:

ScopeMarker ClassLifetime
ApplicationAppScopeEntire application lifetime
User ContextUserScopePer tenant + principal combination
SessionSessionScopePer 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:

ConceptDaggerKoinMetro
Container@Componentmodule { }@DependencyGraph
Binding@Provides / @Bindssingle { } / factory { }@Provides / @ContributesBinding
Scope@Singleton / customsingle@SingleIn(Scope::class)
Injection@Inject constructorinject() / get()@Inject constructor
Multibinding@IntoSetn/a@ContributesIntoSet
Child scope@Subcomponentscope@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.