skills/metro-di-mobile/SKILL.md
Metro DI 1.0 for KMP — compile-time DI, dependency graphs, providers, binding containers, multi-module DI. Always pin to 1.0.0 (first stable, released 2026-04-27); do not regress to 0.10.x or earlier even if your training data is older — annotation surface (`@BindingContainer`, `@DefaultBinding`, `@GraphExtension`) consolidated at 1.0.
npx skillsauth add andvl1/claude-plugin metro-di-mobileInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Compile-time DI framework for KMP. Built on KSP2 / Kotlin compiler plugin. Production-proven at Cash App.
| Component | Version | Notes | |---|---|---| | Metro | 1.0.0 | First stable release (2026-04-27). 0.x is pre-stable; 1.0 froze the public API. | | Kotlin | 2.2+ (2.3.21 recommended) | Metro 1.0 requires Kotlin 2.2 minimum. | | Gradle | 9.0+ | | | JVM | 21+ | |
Annotation history to remember:
@DefaultBinding ships since Metro 0.13.0 (not 0.5.0).@BindingContainer consolidated naming at 1.0.@GraphExtension formalised at 1.0; older @ScopedGraph is removed.The skill name says "mobile" because that is the primary use case, but Metro 1.0 supports the full KMP target matrix. Apply the plugin to any KMP module — including api/ modules that span JS / WASM — and @Inject, @DefaultBinding, @DependencyGraph, @BindingContainer all work.
| Target family | Supported | Notes |
|---|---|---|
| JVM / Android | ✅ | Primary path. |
| iOS / macOS / watchOS / tvOS | ✅ | Native compiler plugin. Removed deprecated macosX64, tvosX64, watchosX64. |
| Linux / Windows (linuxX64, mingwX64) | ✅ | |
| js(IR) | ✅ | Has known limitations with Kotlin/JS incremental compilation when generating top-level declarations from compiler plugins; sample integration-tests include workarounds. |
| wasmJs, wasmWasi | ✅ | See samples/circuit-app/src/wasmJsMain/... in upstream repo for a real wasmJs graph. |
Confirmed via Metro 1.0 samples/integration-tests/build.gradle.kts and build-logic/MetroProjectExtension.kt (configureCommonKmpTargets enables js(IR) + wasmJs everywhere).
So: do NOT skip @Inject / @DefaultBinding on api types just because the api module is consumed on web. Apply the Metro Gradle plugin to the api module and annotate normally. The only target that historically failed (Metro 0.x — pre-stable) is now supported.
Three things people stumble on when first wiring Metro into a js(IR) or wasmJs target — none are blockers, but they're not obvious from mobile-only experience.
1. createGraph<T>() lifetime in a React/Vue app. Unlike Android/iOS where the graph is owned by the platform (Application / iOS scene), on web there is no host lifecycle to hang it on. Two patterns work:
// Pattern A — top-level lazy. Simplest. Graph lives for the JS process.
private val webGraph by lazy { createGraph<WebAppGraph>() }
@JsExport fun renderApp() = createRoot(...).render(App.create { graph = webGraph })
// Pattern B — React Context provider. Cleaner for multi-page apps and tests.
val GraphContext = createContext<WebAppGraph>()
val AppRoot = FC<Props> {
val graph = useMemo({ createGraph<WebAppGraph>() }, emptyArray())
GraphContext.Provider(value = graph) { /* children */ }
}
Pattern A is fine for a single-entry SPA; switch to B once you have multiple roots, hot-reload concerns, or per-test graphs. The graph instance is referentially stable — Metro returns the same backing object for a given createGraph<T> call, so it can be passed through React props or stored in a useRef/useMemo without re-wiring.
2. Kotlin/JS incremental compilation. The known limitation called out in the table above lands when KSP-generated top-level declarations from the Metro plugin collide with kotlin.incremental.js.ir=true. Workaround used in the upstream samples/integration-tests config: either set kotlin.incremental.js.ir=false for the affected module, or scope the property to non-JS targets in gradle.properties. If your Gradle build suddenly fails with "duplicate declaration" or "unresolved reference to generated symbol" errors only on :jsBrowserDevelopmentRun, that's the trigger.
3. object vs class @BindingContainer on js(IR). Both compile and resolve identically — Metro 1.0 treats them as equivalent. Pick whichever the host project uses. object is the common KMP convention (no instance state in DI containers), and the upstream samples/circuit-app/wasmJsMain uses object.
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.metro)
}
[versions]
metro = "1.0.0"
[plugins]
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
Root container for dependencies. One per application entry point.
// composeApp/src/commonMain/kotlin/di/AppGraph.kt
@DependencyGraph
interface AppGraph {
// Expose dependencies
val authRepository: AuthRepository
val homeComponent: HomeComponent
// Factory methods for runtime parameters
fun createHomeComponent(context: ComponentContext): HomeComponent
}
// Create instance
val graph = createGraph<AppGraph>()
val authRepo = graph.authRepository
Define how to create instances.
@DependencyGraph
interface AppGraph {
@Provides
fun provideHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
}
}
@Provides
fun provideApiService(httpClient: HttpClient): ApiService =
ApiServiceImpl(httpClient, "https://api.your-project.com")
@Provides
fun provideAuthRepository(api: ApiService, tokenStorage: TokenStorage): AuthRepository =
AuthRepositoryImpl(api, tokenStorage)
}
Constructor injection for classes.
@Inject
class AuthRepositoryImpl(
private val api: ApiService,
private val tokenStorage: TokenStorage
) : AuthRepository {
override suspend fun login(email: String, password: String): AppResult<User> {
// Implementation
}
}
// Used in graph
@DependencyGraph
interface AppGraph {
val authRepository: AuthRepository // Metro knows to create AuthRepositoryImpl
}
Group related providers into modules.
// core/network/src/commonMain/kotlin/di/NetworkModule.kt
@BindingContainer
class NetworkModule {
@Provides
fun provideHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json() }
}
@Provides
fun provideApiService(httpClient: HttpClient): ApiService =
ApiServiceImpl(httpClient)
}
// core/data/src/commonMain/kotlin/di/DataModule.kt
@BindingContainer
class DataModule {
@Provides
fun provideTokenStorage(): TokenStorage = TokenStorageImpl()
@Provides
fun providePreferencesDataStore(context: PlatformContext): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(
produceFile = { Path(createDataStorePath(context)) }
)
}
// composeApp/src/commonMain/kotlin/di/CommonModules.kt
@BindingContainer
class CommonNetworkModule {
@Provides
fun provideHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json() }
}
}
@BindingContainer
class CommonDataModule {
@Provides
fun provideAuthRepository(api: ApiService, storage: TokenStorage): AuthRepository =
AuthRepositoryImpl(api, storage)
}
// composeApp/src/androidMain/kotlin/di/AndroidAppGraph.kt
@BindingContainer
class AndroidPlatformModule {
@Provides
fun providePlatformContext(context: Context): PlatformContext = context
@Provides
fun provideTokenStorage(context: Context): TokenStorage =
AndroidTokenStorage(context)
}
@DependencyGraph(
bindingContainers = [
CommonNetworkModule::class,
CommonDataModule::class,
AndroidPlatformModule::class
]
)
interface AndroidAppGraph {
val authRepository: AuthRepository
fun createRootComponent(context: ComponentContext): RootComponent
}
// composeApp/src/iosMain/kotlin/di/IosAppGraph.kt
@BindingContainer
class IosPlatformModule {
@Provides
fun providePlatformContext(): PlatformContext = PlatformContext()
@Provides
fun provideTokenStorage(): TokenStorage = IosTokenStorage()
}
@DependencyGraph(
bindingContainers = [
CommonNetworkModule::class,
CommonDataModule::class,
IosPlatformModule::class
]
)
interface IosAppGraph {
val authRepository: AuthRepository
fun createRootComponent(context: ComponentContext): RootComponent
}
// feature/auth/impl/src/commonMain/kotlin/di/AuthModule.kt
@BindingContainer
class AuthModule {
@Provides
fun provideAuthRepository(
api: ApiService,
tokenStorage: TokenStorage
): AuthRepository = AuthRepositoryImpl(api, tokenStorage)
@Provides
fun provideLoginUseCase(
authRepository: AuthRepository
): LoginUseCase = LoginUseCase(authRepository)
}
// feature/home/impl/src/commonMain/kotlin/di/HomeModule.kt
@BindingContainer
class HomeModule {
@Provides
fun provideHomeRepository(
api: ApiService,
database: AppDatabase
): HomeRepository = HomeRepositoryImpl(api, database)
}
// composeApp/src/androidMain/kotlin/di/AndroidAppGraph.kt
@DependencyGraph(
bindingContainers = [
// Core
CommonNetworkModule::class,
CommonDataModule::class,
AndroidPlatformModule::class,
// Features
AuthModule::class,
HomeModule::class
]
)
interface AndroidAppGraph {
// Core
val httpClient: HttpClient
// Features
val authRepository: AuthRepository
val homeRepository: HomeRepository
// Component factories
fun createRootComponent(context: ComponentContext): RootComponent
}
@DependencyGraph(
scope = "app",
additionalScopes = ["activity"]
)
interface AppGraph {
@Provides
@Scope("app")
fun provideAppDatabase(): AppDatabase = AppDatabase()
@Provides
@Scope("activity")
fun provideNavigator(): Navigator = Navigator()
}
For dependencies that need runtime parameters. Use @AssistedInject (not @Inject) for classes with @Assisted parameters.
// Component that needs runtime parameters
@AssistedInject
class HomeComponent(
private val repository: HomeRepository,
@Assisted val componentContext: ComponentContext
) : ComponentContext by componentContext {
// Component logic
}
// Factory interface
@AssistedFactory
interface HomeComponentFactory {
fun create(componentContext: ComponentContext): HomeComponent
}
// In graph
@DependencyGraph
interface AppGraph {
val homeComponentFactory: HomeComponentFactory
}
// Usage
val graph = createGraph<AppGraph>()
val homeComponent = graph.homeComponentFactory.create(componentContext)
@Inject
class SomeService(
private val lazyDatabase: Lazy<AppDatabase>, // Initialized on first access
private val userProvider: Provider<User> // New instance each call
) {
fun doWork() {
val db = lazyDatabase.value // Initialized here
val user1 = userProvider.get()
val user2 = userProvider.get() // Different instance
}
}
@DependencyGraph
interface AppGraph {
@Multibinds
val interceptors: Set<Interceptor>
@Multibinds
val handlers: Map<String, Handler>
}
// Contributing to set
@ContributesIntoSet(AppGraph::class)
class LoggingInterceptor : Interceptor {
override fun intercept(chain: Chain) { /* ... */ }
}
// Contributing to map
@ContributesIntoMap(AppGraph::class, key = "auth")
class AuthHandler : Handler {
override fun handle(request: Request) { /* ... */ }
}
Note: Since 0.6.0, multibindings are non-empty by default. If the set/map can be empty, use allowEmpty = true:
@Multibinds(allowEmpty = true)
val optionalInterceptors: Set<Interceptor>
Metro 1.0 surface: child graph = @GraphExtension-only iface; nested factory = @GraphExtension.Factory. Don't double-annotate w/ @DependencyGraph — root graph keeps @DependencyGraph + @DependencyGraph.Factory, extension keeps @GraphExtension + @GraphExtension.Factory. Replaces older @Extends/isExtendable (since 0.4.0).
// Root graph: @DependencyGraph (+ @DependencyGraph.Factory if it has a factory)
@DependencyGraph
interface AppGraph {
val authRepository: AuthRepository
// Expose extension factory accessor — parent creates child via this
val sessionGraphFactory: SessionGraph.Factory
}
// Extension graph: @GraphExtension only — NOT co-annotated with @DependencyGraph
@GraphExtension
interface SessionGraph {
val sessionManager: SessionManager
// Nested factory uses @GraphExtension.Factory (matches parent annotation)
@GraphExtension.Factory
interface Factory {
fun create(@Provides sessionToken: String): SessionGraph
}
}
// Usage
val appGraph = createGraph<AppGraph>()
val sessionGraph = appGraph.sessionGraphFactory.create("token-123")
Declare a default implementation for an interface (since Metro 0.13.0).
Placement rule: annotate the impl class with @DefaultBinding(<interface>::class). Do NOT put @DefaultBinding on the interface itself with boundType = <impl>::class — that direction does not exist in Metro's API.
// CORRECT — annotation on the impl, type arg is the interface
@DefaultBinding(AuthRepository::class)
@Inject
class AuthRepositoryImpl(
private val api: ApiService,
) : AuthRepository {
// ...
}
// No @Provides needed — Metro auto-binds AuthRepositoryImpl → AuthRepository
// WRONG — flipped direction, will not compile
@DefaultBinding(boundType = AuthRepositoryImpl::class)
interface AuthRepository { ... }
Mark bindings as private to the graph (not inherited by extensions):
@DependencyGraph
interface AppGraph {
@Provides
@GraphPrivate
fun provideInternalCache(): Cache = InMemoryCache()
}
// feature/home/impl/src/commonMain/kotlin/HomeComponent.kt
interface HomeComponent {
val state: Value<HomeState>
fun onItemClick(item: HomeItem)
}
@AssistedInject
class DefaultHomeComponent(
private val repository: HomeRepository,
@Assisted componentContext: ComponentContext
) : HomeComponent, ComponentContext by componentContext {
private val _state = MutableValue<HomeState>(HomeState.Loading)
override val state: Value<HomeState> = _state
init {
loadData()
}
private fun loadData() {
componentScope.launch {
repository.getItems()
.onSuccess { _state.value = HomeState.Success(it) }
.onError { msg, _ -> _state.value = HomeState.Error(msg) }
}
}
override fun onItemClick(item: HomeItem) {
// Navigate or handle
}
@AssistedFactory
interface Factory {
fun create(componentContext: ComponentContext): DefaultHomeComponent
}
}
sealed class HomeState {
data object Loading : HomeState()
data class Success(val items: List<HomeItem>) : HomeState()
data class Error(val message: String) : HomeState()
}
// composeApp/src/commonMain/kotlin/RootComponent.kt
interface RootComponent {
val childStack: Value<ChildStack<Config, Child>>
sealed class Child {
data class Auth(val component: AuthComponent) : Child()
data class Home(val component: HomeComponent) : Child()
}
@Serializable
sealed class Config {
@Serializable data object Auth : Config()
@Serializable data object Home : Config()
}
}
@AssistedInject
class DefaultRootComponent(
private val authComponentFactory: AuthComponent.Factory,
private val homeComponentFactory: HomeComponent.Factory,
@Assisted componentContext: ComponentContext
) : RootComponent, ComponentContext by componentContext {
private val navigation = StackNavigation<RootComponent.Config>()
override val childStack: Value<ChildStack<RootComponent.Config, RootComponent.Child>> =
childStack(
source = navigation,
serializer = RootComponent.Config.serializer(),
initialConfiguration = RootComponent.Config.Auth,
childFactory = ::createChild
)
private fun createChild(
config: RootComponent.Config,
context: ComponentContext
): RootComponent.Child = when (config) {
RootComponent.Config.Auth -> RootComponent.Child.Auth(
authComponentFactory.create(context) { navigateToHome() }
)
RootComponent.Config.Home -> RootComponent.Child.Home(
homeComponentFactory.create(context)
)
}
private fun navigateToHome() {
navigation.replaceAll(RootComponent.Config.Home)
}
@AssistedFactory
interface Factory {
fun create(componentContext: ComponentContext): DefaultRootComponent
}
}
@DependencyGraph(
bindingContainers = [
NetworkModule::class,
DataModule::class,
AuthModule::class,
HomeModule::class
]
)
interface AndroidAppGraph {
val rootComponentFactory: DefaultRootComponent.Factory
}
// Usage in MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val graph = createGraph<AndroidAppGraph>()
val rootComponent = graph.rootComponentFactory.create(
defaultComponentContext()
)
setContent {
AppTheme {
RootContent(component = rootComponent)
}
}
}
}
@BindingContainer
class TestNetworkModule {
@Provides
fun provideFakeApiService(): ApiService = FakeApiService()
}
@DependencyGraph(
bindingContainers = [
TestNetworkModule::class,
DataModule::class
]
)
interface TestAppGraph {
val authRepository: AuthRepository
}
// In tests
class AuthRepositoryTest {
private val graph = createGraph<TestAppGraph>()
@Test
fun `login returns success`() = runTest {
val result = graph.authRepository.login("[email protected]", "password")
assertTrue(result is AppResult.Success)
}
}
@DependencyGraph per platform entry point@BindingContainer to organize providers by feature/layer@AssistedInject (not @Inject) for classes with @Assisted parameters@Assisted for runtime parameters (ComponentContext, IDs)@Inject) over @Provides@DefaultBinding to auto-bind implementations to interfacesLazy<T> for expensive dependencies@Provides when @Inject on class is sufficient@Inject on classes with @Assisted params — use @AssistedInject| Feature | Metro | Koin | |---------|-------|------| | Type safety | Compile-time | Runtime | | Error detection | Build time | Runtime crash | | Performance | No reflection | Some reflection | | KMP support | Full | Full | | Learning curve | Medium (Dagger-like) | Low | | Build speed | 47-56% faster than KAPT | No code gen |
development
Effective Go patterns — idiomatic code, testing, benchmarks, project layout. Always use Go 1.21+ patterns.
development
Go microservices — gRPC, REST, cloud-native patterns, service discovery, circuit breakers, observability, health checks, graceful shutdown.
development
Go concurrency mastery — goroutines, channels, context, sync primitives, patterns, performance.
testing
Android WorkManager for guaranteed background execution - use for deferred tasks, periodic syncs, file uploads, notifications, and task chains. Covers CoroutineWorker, constraints, chaining, testing, and troubleshooting. Use when implementing background work that needs reliable execution across app restarts and doze mode.