Skip to content

bandlab/metro-station

Repository files navigation

🚉 Metro Station

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 KSP 🐢 Metro Station 🚀 Delta 📉
Root Abi 54.79s 28.72s -47.57%
Root Non-abi 40.26s 17.14s -57.43%

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(context: Context) : Page<MyViewModel>(),
  /* generated */ HasServiceProvider {

  @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:
  private val graphCreator = lazy {
    graphCreator(
      context, 
      createGraphFactory<FeatureGraph.Factory>(), 
      EmptyExtraDependencies // Or actual extra dependencies if it's presented
    )
  }

  override fun <T> resolve(): T = HasServiceProvider.resolveFrom(graphCreator.value)

  @IROnlyFactories
  @PageScope
  @DependencyGraph(
    scope = MyPage::class,
    bindingContainers = [DefaultPageDependencies::class]
  )
  interface FeatureGraph : PageInjector<MyPage> {

    @Provides
    fun provideBaseType(feature: MyPage): Page<*> = feature

    @DependencyGraph.Factory
    interface Factory : PageGraphFactory<MyPage, FeatureServiceProvider, EmptyExtraDependencies, FeatureGraph>
  }

  @ContributesTo(AppScope::class)
  interface FeatureServiceProvider : ServiceProvider, DefaultScreenServiceProvider
}

Besides the basic support, we will also generate param providers:

  • For CommonActivity, param type T is 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.

Supported types: Page, Activity, Fragment, and any other classes

@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>() {

  // All code below will be generated by the compiler plugin:
  @PageScope
  @GraphExtension(
    scope = MyPage::class,
    bindingContainers = [DefaultPageDependencies::class, FeatureBindings::class]
  )
  interface FeatureExtension : PageInjector<MyPage> {

    @ContributesTo(AppScope::class)
    @GraphExtension.Factory
    interface Factory {
      @Keep // We use reflection to access this method at runtime.
      fun create(@Provides feature: MyPage): FeatureExtension
    }
  }

  @IROnlyFactories
  @BindingContainer
  object FeatureBindings {
    @Provides
    fun provideBaseType(feature: MyPage): Page<*> = feature
  }

  @IROnlyFactories
  @ContributesTo(scope = AppScope::class)
  interface ExtensionFactoryContribution {
    @Provides
    @IntoMap
    @ClassKey(MyPage::class)
    fun provideFactory(factory: FeatureExtension.Factory): Any = factory
  }
}

Same as @MetroStation, we will also 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.

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.
  • 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

Releases

No releases published

Contributors