This compiler plugin demonstrates how we scale our DI setup in Android monorepo with Metro.
A few years ago, we implemented a bunch of KSP processors based on Dagger and Anvil to generate boilerplate code for our DI setup, such as multibinding contributions, dependency graphs, and graph extensions. It was a huge win for DX, but we immediately realized that the tradeoff is noticeable – the KSP performance wasn't good.
After we adopted metro and saw the capabilities and the performance improvements of a compiler plugin, we decided to rewrite our code generation setup to use Metro.
Please note that we are sharing this repository strictly as a practical demonstration of code generation with Metro. This is a reference example and is not intended for public adoption.
⚠️ Metro's extensions API is highly experimental and does not accept any FRs and issues. The Kotlin compiler plugin API itself is also constantly changing and lacks documentation. You should be aware of the high-maintenance cost if you decide to follow the same approach.
The benchmark was performed on the BandLab Android app with Gradle Profiler. At the time of benchmarking, BandLab Android app has 1163 modules, and 490 of them are running KSP (3 processors in total).
| Mutation | Metro + KSP 🐢 | Metro Station 🚀 | Delta 📉 |
|---|---|---|---|
| Root Abi | 54.79s | 28.72s | -47.57% ✨ |
| Root Non-abi | 40.26s | 17.14s | -57.43% ✨ |
If we take a step back, compare the setup before migrating to Metro (Dagger KAPT + Anvil KSP), we have sliced the incremental build time by 75% accumulatively by just updating the DI framework!
| Mutation | Dagger KAPT + Anvil KSP 🐢 |
Metro Station 🚀 | Delta 📉 |
|---|---|---|---|
| Root Abi | 115.02s | 28.72s | -75.03% ✨ |
| Root Non-abi | 77.7s | 17.14s | -77.94% ✨ |
This annotation generates a Dependency Graph for the feature.
Annotate your feature with @MetroStation, provide a dependency contract you need from the AppGraph,
and the compiler plugin will generate a standalone dependency graph for you ✨
📖
Pageis our internal light-weight framework to render a composable with an injected ViewModel type.
@MetroStation(appDependencies = MyPage.ServiceProvider::class)
class MyPage : Page<MyViewModel> {
@Composable
override fun Content(viewModel: MyViewModel) {
// MyViewModel is already injected at this point
}
interface ServiceProvider {
val appDependency: AppDependency
}
// All code below will be generated by the compiler plugin:
@GeneratedByMetroStation
override fun injectViewModel(deps: PageGraphDependencies): MyViewModel {
return createGraphAndInjectViewModel(
deps = deps,
param = Unit, // or the actual param type if you're using ParamPage
factory = createGraphFactory<FeatureGraph.Factory>(),
extraDependencies = ExtraDependencies,
)
}
@IROnlyFactories
@PageScope
@DependencyGraph(
scope = MyPage::class,
bindingContainers = [DefaultPageDependencies::class]
)
interface FeatureGraph : PageInjector<MyViewModel> {
@Provides
fun provideBaseType(feature: MyPage): Page<*> = feature
@DependencyGraph.Factory
interface Factory : PageGraphFactory<MyPage, MyViewModel, Unit, FeatureServiceProvider, EmptyExtraDependencies, FeatureGraph>
}
@ContributesTo(AppScope::class)
interface FeatureServiceProvider : ServiceProvider, DefaultScreenServiceProvider
}Supported types: Page, Activity, Fragment, and any other classes
We provide common default dependencies to ease the development for you.
- Page: DefaultPageDependencies.kt
- Activity: DefaultActivityDependencies.kt
- Fragment: DefaultFragmentDependencies.kt
We also request some common app-level dependencies for you. For screens (page, activity, fragment), we extend DefaultScreenServiceProvider to the generated FeatureServiceProvider interface. For activities, we extend CommonActivity.ServiceProvider additionally.
Besides the basic support, we will also generate param providers:
- For CommonActivity, param type declared as the generic type will be available in the graph.
- For ParamPage, we will provide both the initial param, and a flow of params that listens to the host activity's onNewIntent.
We inject known Android components automatically for you:
- Service: The plugin injects
fun onCreate()for you, if theonCreateis not declared, we will generate it and callsuper.onCreateafter injection. - BroadcastReceiver: The plugin injects
fun onReceive(context: Context, intent: Intent)for you. - CoroutineWorker: The plugin injects
suspend fun doWork(): Resultfor you.
This annotation generates a Graph Extension for the feature.
@StationEntry is in maintenance mode as we're shifting towards graph-per-feature, please use @MetroStation instead.
Annotate your feature with @StationEntry, and the compiler plugin will contribute a graph extension towards the declared parentScope for you, and contribute the factory to a multibinding to the AppGraph for runtime use.
@StationEntry
class MyPage : Page<MyViewModel> {
@Composable
override fun Content(viewModel: MyViewModel) {
// MyViewModel is already injected at this point
}
// All code below will be generated by the compiler plugin:
@GeneratedByMetroStation
override fun injectViewModel(deps: PageGraphDependencies): MyViewModel {
val factory = (deps as AndroidPageGraphDependencies).activity.resolveServiceProvider<FeatureExtension.Factory>()
return factory.create(this, Unit, deps).getPageViewModel()
}
@PageScope
@GraphExtension(
scope = MyPage::class,
bindingContainers = [DefaultPageDependencies::class, FeatureBindings::class]
)
interface FeatureExtension : PageInjector<MyViewModel> {
@ContributesTo(AppScope::class)
@GraphExtension.Factory
interface Factory {
fun create(
@Provides feature: MyPage,
@Provides param: Unit, // or the actual param type if you're using ParamPage
@Includes pageGraphDependencies: AndroidPageGraphDependencies,
): FeatureExtension
}
}
@IROnlyFactories
@BindingContainer
object FeatureBindings {
@Provides
fun provideBaseType(feature: MyPage): Page<*> = feature
}
}Same as @MetroStation, we will also provide default dependencies and generate param providers in FeatureBindings if the feature has a param.
Supported types: Page, Activity, Fragment
This annotation generates a multibinding contribution for config selectors.
📖 Config selector is our internal abstraction for remote config (a.k.a. feature flag) and user/ device preferences
Annotating a class with @ContributesConfigSelector will generate a nested @ContributesTo(AppScope::class) interface
that binds the annotated class into a Set<DebuggableConfigSelector> via @Binds @IntoSet.
@ContributesConfigSelector
object MyConfigSelector : BooleanConfigSelector {
// The plugin generates:
@ContributesTo(AppScope::class)
interface MultibindingContribution {
@Binds @IntoSet
fun bind(impl: MyConfigSelector): DebuggableConfigSelector
}
}Apply the Gradle plugin, and it will automatically include annotations to your project.
plugins {
id("com.bandlab.metro.station")
}This project has four modules:
- The
:compiler-pluginmodule contains the compiler plugin itself. - The
:plugin-annotationsmodule contains annotations which can be used in user code for interacting with compiler plugin. - The
:gradle-pluginmodule contains a Gradle plugin to add the compiler plugin and annotation dependency to a Kotlin project. - The
:stubsmodule contains stub classes for testing purposes.
Extension point registration:
- K2 Frontend (FIR) extensions can be registered in
MetroStationPluginRegistrar. - All other extensions (including K1 frontend and backend) can be registered in
MetroStationPluginComponentRegistrar.
There is a sample Android app under :sample that demonstrates how we use the compiler plugin.
Metro Station uses a composite versioning scheme that appends Metro's version as a suffix. For example, 0.1.0-1.1.1 means it is based on Metro 1.1.1. We have two compatibility guards:
- There is a GitHub workflow that builds the repository daily against Metro's latest snapshot, allowing us to catch breaking changes early.
- By default, the gradle plugin will check the Metro version during the configuration. You can disable it by using the gradle property
com.bandlab.metro.station.metroStrictCompatibility=false.
There are a few caveats to be aware of when using the metro extensions API:
- Use of
@IROnlyFactories: This annotation is required on a generated dependency graph if it contains providers, as well as binding container declarations. This is because external metro extensions will be run in one-pass with other metro native FIR extensions, and those providers won't be seen by metro, so we need to defer the factory generation to the IR phase. - Providers for Graph Extensions: Metro processes graph extensions in IR, if you declare providers in the graph extensions themselves, metro won't see them. You'll need to declare a separate binding container annotated with
@IROnlyFactoriesand include it in your extension instead. - Compiler Plugin Ordering: If your IR extension generates expressions that require metro to process, for example,
createGraphorcreateGraphFactory, you'll need to specify the compiler flag-Xcompiler-plugin-orderto run your plugin before metro.
The Kotlin compiler test framework is set up for this project.
To create a new test, add a new .kt file in a compiler-plugin/testData sub-directory:
testData/box for codegen tests and testData/diagnostics for diagnostics tests.
The generated JUnit 5 test classes will be updated automatically when tests are next run.
They can be manually updated with the generateTests Gradle task as well.
To aid in running tests, it is recommended to install the Kotlin Compiler DevKit IntelliJ plugin,
which is pre-configured in this repository.
Copyright 2026 BandLab Singapore Pte Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.