Cash Android Moves to Metro
Cash Android has officially migrated to Metro - a modern dependency injection framework developed by Zac Sweers (read Zac’s Introducing Metro blog post). In this article, we’ll discuss the reasoning behind this change, explain how we approached the migration and tackled the technical challenges we faced, and share the results.
But before we start…
A trip down memory lane
Android teams at Block have a long history of using and building dependency injection frameworks.
Back in 2012 Square released Dagger. Over time, Dagger became the industry standard, and in 2018 it transitioned under Google’s stewardship to become the officially recommended dependency injection solution for Android. Dagger 2 has compile-time dependency graph validation, which proved extremely valuable as Cash Android grew.
2020 was the birth year of Anvil, a Kotlin compiler plugin and a suite of annotations to make it easier to extend and manage large Dagger graphs. The Cash Android team happily adopted Anvil, which helped us keep our ever-growing DI graph in check and improved our build speeds.
Fast forward to 2025, and our dependency injection setup still felt pretty solid: we could iterate with confidence, our build speeds were fine, so…
Why change?
The industry is moving fast.
Today, Cash Android codebase is almost 100% Kotlin. Dagger, our main dependency injection solution, is still very much a Java library: its annotation processor requires kapt to process Kotlin code, and it generates Java code that needs to be compiled with javac. The whole build pipeline is complex which slows down our builds.
Kotlin 2.0 was released back in 2024, with K2 - the next version of the compiler with improved performance and IDE integration - reaching stability. While we’ve upgraded to Kotlin 2.0 a while ago, we weren’t able to upgrade to K2 and had to keep the language version setting at 1.9, as Anvil didn’t support K2 yet. Since Anvil is a compiler plugin, adding K2 support required significant effort. As the Anvil team worked on adding support, Metro started gaining traction. Evaluations done by Cash and Square teams convinced us that Metro is well aligned with our long term vision for dependency injection, and therefore we decided to adopt it. As a result of this decision, Anvil transitioned to maintenance mode.
So what is Metro?
According to Metro’s documentation:
Metro is a compile-time dependency injection framework that draws heavy inspiration from Dagger, Anvil, and Kotlin-Inject. It seeks to unify their best features under one, cohesive solution while adding a few new features and implemented as a compiler plugin.
As a compiler plugin, Metro adds minimal build time overhead, noticeably improving performance. It ships with
comprehensive interoperability tooling: while Metro has its own DI annotations, such as @Inject and @Provides,
it can be configured to “understand” similar annotations from Dagger and Anvil, meaning we wouldn’t need to change
every single file that uses those annotations during migration. And the fact that Metro is a Kotlin-first framework
built for K2 means it can leverage modern language features to offer better API and developer experience. There was a
lot to be excited about, and so we embarked on the journey to gradually and safely migrate Cash Android to Metro.
What did the migration look like?
Today, Cash Android is a huge 1500-module Android project serving tens of millions of customers every month, so we knew we couldn’t just YOLO rewrite everything and push the “ship” button - we needed a plan to ensure the migration is performed and rolled out as safely as technically possible.
Metro interop
We knew that Metro’s interop functionality will be the key to success, and we theorized that if we’re lucky, we should be able to get our code to a state where it can be built with both Dagger/Anvil and Metro, gated by a Gradle property. And so we introduced a Gradle property:
// gradle.properties
mad.di=AnvilDagger // Or Metro. Why "mad.di"? Don't ask!
Building the app would then look like this, which would allow us to set up CI shards building the app in both modes, to catch any potential regressions:
./gradlew app:assembleDebug -Pmad.di=AnvilDagger
// or
./gradlew app:assembleDebug -Pmad.di=Metro
Convention plugin changes
Cash Android engineers love convention plugins! They allow us to consolidate our project-specific build logic and share
it between all Gradle modules, without having to copy paste configuration code. BaseDependencyInjectionPlugin is the
convention plugin responsible for setting up dependency injection-related plugins and dependencies, and that’s where we
would read the value of our Gradle property to decide which plugin to apply:
class BaseDependencyInjectionPlugin : Plugin<Project> {
override fun apply(target: Project): Unit = with(target) {
val diImplementation = providers.gradleProperty("mad.di")
.getOrElse("AnvilDagger")
val libs = extensions.getByName("libs") as LibrariesForLibs
when (diImplementation) {
"AnvilDagger" -> {
pluginManager.apply(ANVIL_PLUGIN)
dependencies.add("api", libs.dagger.runtime)
}
"Metro" -> {
pluginManager.apply(METRO_PLUGIN)
with(extensions.getByType(MetroPluginExtension::class.java)) {
// We only had this option enabled during migration to debug build failures. It's not needed during normal
// development as it produces very verbose reports and can have a slight effect on build speeds.
reportsDestination.set(layout.buildDirectory.dir("metro/reports"))
interop.includeDagger(
includeJavax = true,
includeJakarta = false,
)
interop.includeAnvil(
includeDaggerAnvil = true,
includeKotlinInjectAnvil = false,
)
}
}
}
}
}
Another important change, which we made in our BasePlugin, was to conditionally disable Kotlin language version
override if we’re building with Metro:
tasks.withType(KotlinCompilationTask::class.java).configureEach { task ->
if (diImplementation == "AnvilDagger") {
task.compilerOptions.languageVersion.set(KotlinVersion.KOTLIN_1_9)
}
}
Once we started building in K2 mode, we needed to fix up a few minor method deprecations here and there (like renaming
toUpperCase() and toLowerCase() method calls to uppercase() and lowercase()), which was pretty straightforward.
Adjusting our code for Metro
At this point, in the best case scenario we would’ve been able to just build our project with Metro, but unsurprisingly it hadn’t been the case - there was more work to do to adjust our dependency graph to Metro.
Removing Module includes
Anvil allows @Modules to be annotated with @ContributesTo(Scope::class), which is an alternative to the
@Module(includes = ...) construct that scales better for large dependency graphs like ours. As we adopted Anvil, we
added @ContributesTo annotations to all our modules, but in some cases forgot to remove them from the includes
clauses of aggregator modules. Metro’s validation logic turned out to be stricter than Anvil’s, which led to errors
about modules being added to the DI graph twice. Luckily, this was easy to fix - we simply removed unnecessary
includes clauses and kept the @ContributesTo annotations.
Converting @Component.Builder to @Component.Factory
We had a bunch of @Components with @Component.Builders that looked like this:
@Component
interface AppComponent {
@Component.Builder
interface Builder {
@BindsInstance fun refWatcher(refWatcher: RefWatcher): Builder
@BindsInstance fun application(app: Application): Builder
fun build(): AppComponent
}
}
Metro’s interop turns Dagger @Components into @DependencyGraphs, but there’s no construct
similar to @Component.Builder in Metro. However, there’s @DependencyGraph.Factory,
which maps perfectly to @Component.Factory. Converting builders to factories was trivial!
@Component
interface AppComponent {
@Component.Factory
fun interface Factory {
fun create(
@BindsInstance refWatcher: RefWatcher,
@BindsInstance app: Application,
): AppComponent
}
}
Moving scoping annotations from @Binds bindings to type declarations
We had a number of bindings that looked like this:
@Module
@ContributesTo(AppScope::class)
abstract class SettingsStoreModule {
@Binds
@SingleIn(AppScope::class)
fun bindSettingsStore(real: RealSettingsStore): SettingsStore
}
Here, we’re binding RealSettingsStore implementation to the SettingsStore interface, at the same time marking
RealSettingsStore as @SingleIn(AppScope::class). While this is a valid construct in Anvil and Dagger,
Metro disallows scoping annotations on @Binds declarations, and for a good reason: these
declarations are supposed to simply map one type (implementation) to another (interface) and shouldn’t carry any
additional information. The scoping annotation should be placed on the implementation type declaration instead:
@SingleIn(AppScope::class)
class RealSettingsStore @Inject constructor(): SettingsStore
We simply had to move our scoping annotations to where they belong. Note that both annotation sites work in the exact
same way in Anvil and Dagger whenever SettingsStore is injected, and since we always inject our interface types and
never inject implementation types directly, we were confident this change would not cause any regressions in behavior.
Splitting up @MergeModules
This one was tricky: we had a number of Anvil’s @MergeModules used to aggregate @Modules contributed to a specific
secondary scope, which would then be added to a @MergeComponent with the primary scope:
@Module
@ContributesTo(ProductionAppScope::class)
object ProductionEndpointsModule
@Module
@ContributesTo(ProductionAppScope::class)
object ProductionDbModule
@MergeModules(scope = ProductionAppScope::class)
class ProductionAppScopeMergeModule
@MergeComponent(
scope = AppScope::class,
modules = [ProductionAppScopeMergeModule::class],
)
interface AppComponent
@MergeComponent can only aggregate modules for a single scope, so this approach was necessary to support secondary
scopes. Metro does support multiple scopes per @DependencyGraph, so we could simply convert our @MergeComponent
like so:
@DependencyGraph(
scope = AppScope::class,
additionalScopes = [ProductionAppScope::class],
)
interface AppComponent
This, unfortunately, would’ve prevented our codebase from being built with Anvil and Dagger, which was one of the main
requirements for the migration. So we had to resort to Dagger-style module includes, which is much less elegant than
@MergeModules, but does the job. And we knew we’ll be able to come back and clean this up once we’ve finished rolling
out the migration!
@MergeComponent(
scope = AppScope::class,
modules = [
ProductionEndpointsModule::class,
ProductionDbModule::class,
],
)
interface AppComponent
Removing direct calls to @Provides methods
There were a number of instances of @Provides-annotated bindings called directly from non-DI, mostly test, code:
object NetworkingModule {
@Provides fun provideOkHttpClient(): OkHttpClient = ...
}
class PaymentsIntegrationTest {
private val okHttpClient = NetworkingModule.provideOkHttpClient()
}
Metro doesn’t allow this, which makes sense: a dependency injection framework built as a compiler plugin should be able
to rewrite DI definitions for optimization purposes, and having external code access those definitions would make it
impossible. The fix we came up with was to simply split bindings into two methods, one that contains the actual binding
logic and the other that calls the first one and is annotated with @Provides. The former is perfectly safe for
external code to call!
object NetworkingModule {
fun okHttpClient(): OkHttpClient = ...
@Provides fun provideOkHttpClient(): OkHttpClient = okHttpClient()
}
class PaymentsIntegrationTest {
private val okHttpClient = NetworkingModule.okHttpClient()
}
Fixing nullability issues on injected types
We had a surprisingly large number of bindings that returned nullable types for non-nullable injection sites and vice
versa. Dagger, being a Java framework, does not distinguish between Kotlin’s nullable and non-nullable types, so this
all worked fine at build time, but was definitely opening us up for potential NullPointerExceptions. Metro does honor
nullable types, so we had to decide exactly what types we wanted in our bindings. This is a great example where Metro’s
stricter validation helped us make our dependency graph more robust!
Replacing @ClassKey with custom map keys
A small number of our features relied on @IntoMap injections with @ClassKeys:
@Module
abstract class LendingActivityItemModule {
@Binds
@IntoMap
@ClassKey(LendingActivityItem::class)
abstract fun bindLendingActivityItemPresenterFactory(): LendingActivityItemPresenter.Factory = ...
}
@Module
abstract class TaxesActivityItemModule {
@Binds
@IntoMap
@ClassKey(TaxesActivityItem::class)
abstract fun bindTaxesActivityItemPresenterFactory(): TaxesActivityItemPresenter.Factory = ...
}
class PresenterFactory @Inject constructor(
private val activityItemPresenterFactories: Map<Class<*>, ActivityItemPresenter.Factory>,
)
While Metro does interop with @ClassKey, since it’s a Kotlin framework, it would generate a map with KClass keys,
while Anvil/Dagger generated a map with Class keys. We couldn’t support both, as that would again break our
requirement to be able to build the project in both modes, so we decided to introduce a custom map key:
enum class ActivityItemType {
LENDING,
TAXES,
}
@Retention(AnnotationRetention.RUNTIME)
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.TYPE,
AnnotationTarget.FIELD,
)
@MapKey
annotation class ActivityItemTypeKey(val type: ActivityItemType)
@Module
abstract class LendingActivityItemModule {
@Binds
@IntoMap
@ActivityItemTypeKey(LENDING)
abstract fun bindLendingActivityItemPresenterFactory(): LendingActivityItemPresenter.Factory = ...
}
@Module
abstract class TaxesActivityItemModule {
@Binds
@IntoMap
@ActivityItemTypeKey(TAXES)
abstract fun bindTaxesActivityItemPresenterFactory(): TaxesActivityItemPresenter.Factory = ...
}
class PresenterFactory @Inject constructor(
private val activityItemPresenterFactories: Map<ActivityItemType, ActivityItemPresenter.Factory>,
)
While this version is somewhat more verbose, it comes with additional type safety, as it ensures the number of injected
keys is bounded by the ActivityItemType enum, so that’s another small win that the migration to Metro helped us
unlock.
Deleting unused dependency injection code
Last but not least, we stumbled upon a bunch of unused modules, bindings, components, etc., which we happily deleted. The takeaway here is that dead code, if not deleted, will at some point require non-trivial maintenance, which is a complete waste of effort. It’s always better to simply delete something that’s not used than to keep maintaining it - dead code will live in your git history forever anyway!
One last thing - instantiating dependency graphs
While we managed to get almost the same codebase building with two distinct dependency injection configurations, there
was one specific set of API calls that had to be different - the actual graph instantiation calls. With Dagger, we used
to call DaggerAppComponent.factory().create(...) inside our application class to instantiate the app component, and
with Metro, we had to migrate to the createGraphFactory<AppComponent>().create(...) API. Here’s what we did:
-
We introduced two new custom source sets in our
:appmodule, conditionally added to the build based on that same Gradle property:// app/build.gradle sourceSets { def diFramework = providers.gradleProperty('mad.di').getOrElse('AnvilDagger') if (diFramework == 'Metro') { main.kotlin.srcDir 'src/metro/kotlin' } else { main.kotlin.srcDir 'src/anvilDagger/kotlin' } } -
We added methods returning
AppComponent.Factorywith the exact same signature to both source sets:// src/metro/kotlin/.../factories.kt import dev.zacsweers.metro.createGraphFactory internal fun appComponentFactory(): AppComponent.Factory { return createGraphFactory() } // src/anvilDagger/kotlin/.../factories.kt internal fun appComponentFactory(): AppComponent.Factory { return DaggerAppComponent.factory() } -
We replaced the direct reference to
DaggerAppComponent.Factoryinside our application class with a reference to theappComponentFactory()method. And that’s it - the Gradle config ensured our code would always call the right version of the method based on the build property.
After a few weeks of iterative code modifications we were finally able to build our project with both frameworks with no code changes in between - that felt like magic!
The rollout
Once we did enough regression testing to ensure there were no runtime issues, we started preparing for the rollout. We knew this would be a tricky one as there’s no way to protect the change with a runtime feature flag - the decision for which DI framework to use happens at build time.
We decided that we’ll continue building the app in both modes up until we’ve fully rolled out, just in case we’d have to revert back to the Anvil + Dagger version. We actually managed to temporarily introduce regressions caused by overly eager post-K2 migration cleanup, so we set up separate CI shards that built the app in each mode, independent of what the state of the Gradle property was.
Finally, when everything was ready, we flipped the default value of the Gradle property and submitted the Metro flavor of the app build to the Play store. The rollout went smoothly and we were officially on Metro!
The results
So what did we achieve with this migration?
- We were able to turn on K2 mode to benefit from the latest Kotlin compiler improvements.
- We managed to modernize our dependency injection stack:
- We no longer use kapt.
- We don’t use Anvil and Dagger compilers anymore.
- Our dependency injection codegen now runs during Kotlin compilation, which is significantly simpler and faster than what we had before.
- According to our benchmarks, by migrating to Metro and K2 we managed to improve clean build speeds by over 16% and incremental build speeds by almost 60%! 🎉
| Scenario | Anvil/Dagger (seconds) | Metro (seconds) | Change (%) |
|---|---|---|---|
| ABI Change | 28.77s | 11.93s | -58.5% ⬇️ |
| Non-ABI Change | 17.45s | 7.15s | -59.0% ⬇️ |
| Raw Compilation Performance | 242.97s | 202.49s | -16.7% ⬇️ |
So what’s next?
- We’re gradually migrating to Metro’s native annotations so we can disable interop.
- We’re eager to adopt Metro-specific features to simplify our DI graph even further.
- We’re committed to contributing back to Metro by reporting and fixing bugs, sharing design feedback and feature requests, to help the framework thrive.
Conclusion
Migrating Cash Android to Metro was a significant undertaking only made possible thanks to the collaboration between a large number of engineers from different teams at Block and the help of the open source community. We’re very happy with the results and really excited about adopting more of Metro’s features and seeing what the future holds. We hope this article will help your team migrate your app to Metro - a modern dependency injection stack and fast builds are well worth the effort!