Skip to content

bandlab/metro-station

Repository files navigation

🚉 Metro Station

Metro Build Validate Metro Snapshot License

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.

Performance Benchmark

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%

Use Cases

@MetroStation

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 ✨

📖 Page is 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

Default Dependencies

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.

Android Component Injection

We inject known Android components automatically for you:

  • Service: The plugin injects fun onCreate() for you, if the onCreate is not declared, we will generate it and call super.onCreate after injection.
  • BroadcastReceiver: The plugin injects fun onReceive(context: Context, intent: Intent) for you.
  • CoroutineWorker: The plugin injects suspend fun doWork(): Result for you.

@StationEntry

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

@ContributesConfigSelector

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
    }
}

Usage

Apply the Gradle plugin, and it will automatically include annotations to your project.

plugins {
    id("com.bandlab.metro.station")
}

Project Structure

This project has four modules:

  • The :compiler-plugin module contains the compiler plugin itself.
  • The :plugin-annotations module contains annotations which can be used in user code for interacting with compiler plugin.
  • The :gradle-plugin module contains a Gradle plugin to add the compiler plugin and annotation dependency to a Kotlin project.
  • The :stubs module 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.

Compatibility with Metro

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:

  1. There is a GitHub workflow that builds the repository daily against Metro's latest snapshot, allowing us to catch breaking changes early.
  2. 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.

Caveats

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 @IROnlyFactories and include it in your extension instead.
  • Compiler Plugin Ordering: If your IR extension generates expressions that require metro to process, for example, createGraph or createGraphFactory, you'll need to specify the compiler flag -Xcompiler-plugin-order to run your plugin before metro.

Tests

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.


License

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.

About

A Kotlin compiler plugin built on Metro to simplify DI setup

Topics

Resources

License

Stars

Watchers

Forks

Contributors