~/posts/metro-migration-at-square-android...
Metro Migration at Square Android

Metro Migration at Square Android

Migrating the Square Android monorepo from Dagger 2 and Anvil to Metro

$ cat content.md

Metro has replaced Anvil and Dagger 2 as the dependency injection framework in our Square Android monorepo. Cash Android published a great blog post about why they adopted Metro, the challenges they encountered, and how they solved them. This post builds on that write-up and focuses on the additional constraints and lessons specific to Square Android.

We'd like to sincerely thank Zac Sweers, the author and maintainer of Metro, for partnering and spending many hours with us throughout this migration. Over many months, we worked closely with him to tackle problems unique to a codebase of our size. Zac helped us debug issues, resolve performance problems, and implement missing features. We couldn't have adopted Metro without his effort.

Thank you, Zac! ❤️

What comes after Anvil

We developed Anvil at Square and open sourced the framework in June 2020. It was tremendously helpful to us: it simplified our dependency injection setup with Dagger 2 and improved build times by a large margin. However, with the release of Kotlin 2.0 Anvil became a blocker for future upgrades, because it relied on compiler APIs specific to Kotlin 1, and there was no direct replacement in the Kotlin 2 compiler.

We considered rewriting Anvil with KSP, which would have let us avoid depending directly on unstable compiler internals. Although we had a working prototype, the build-time overhead was too high to justify moving forward. We also started a rewrite using Kotlin 2 compiler APIs directly, but progress was slow because Anvil still depended on the same Dagger 2 and KAPT-based architecture. That meant we would still be carrying much of the original complexity forward while porting it to a new compiler backend.

Around the same time, Zac started work on Metro and brought in people from several companies to provide feedback on its design and APIs. Metro quickly became what we had always wanted "Dagger 3" to be. Once the first releases landed, we decided to deprecate Anvil, focus on contributing to Metro, and begin migrating Square Android.

The migration

Our migration followed a similar path to Cash Android's. Metro's interop feature and a build time flag let us continue supporting Dagger 2 while incrementally migrating to Metro. We used a source split whenever Metro required code that was incompatible with Dagger 2, or vice versa. Many @Component.Builder annotations were converted to @Component.Factory so they could leverage the interop support. Metro also surfaced a number of legitimate nullability issues that we then fixed.

Given the size of our codebase and patterns unique to Square Android, we had to take a few extra steps compared to Cash Android.

Shadow CI job

The first pull request related to the Metro migration was merged in July 2025, and the full migration took nine months to finish. To measure progress and catch regressions early, we set up two nightly CI jobs. The first attempted to build every Gradle module in the repository, more than 7,000 in total, with Metro enabled and published the results to an internal dashboard. That helped us identify modules that still needed attention.

The second job ran our entire CI suite with Metro enabled. We have more than 1,500 CI jobs, and most failed in the beginning. The goal was to drive that number to zero. The results of the CI job were shared daily in a Slack channel:

Slack message screenshot

Converting Java code to Kotlin

Metro's code generation is driven by a Kotlin compiler plugin, so the code Metro processes has to be written in Kotlin. Java sources could still participate in our old Dagger setup, which meant we had to migrate Java files that were still using Dagger or javax.inject.* annotations before those parts of the codebase could move to Metro. In practice, that often meant Dagger modules using @Module, for example:

java
1
2@Module
3public abstract class AndroidModule {
4    @Provides
5    static ContentResolver provideContentResolver(Application context) {
6        return context.getContentResolver();
7    }
8}

And classes with an @Inject constructor, for example:

java
1public class EmailScrubber implements InsertingScrubber {
2    @Inject
3    public EmailScrubber() { ...}
4}

Removing @ContributesBinding.rank

Anvil's @ContributesBinding annotation has a rank parameter that can automatically deduplicate contributions for the same binding. This is helpful when Gradle modules don't depend on each other and therefore cannot use the strongly typed replaces parameter. For example, we use this feature in device tests to replace real implementations with fakes.

Metro's @ContributesBinding annotation doesn't support rank, so we had to migrate hundreds of usages. Our Gradle module structure enforces dependency inversion, which means implementation modules aren't allowed to depend on other implementation modules. As a result, they can't use replaces to reference the implementation they supersede.

kotlin
1// :public module
2interface LoginHandler
3
4// :impl-real
5@ContributesBinding(AppScope::class)
6class RealLoginHandler : LoginHandler
7
8// :impl-fake
9@ContributesBinding(AppScope::class)
10class FakeLoginHandler : LoginHandler

In this example, :impl-fake can't use @ContributesBinding(AppScope::class, replaces = [RealLoginHandler::class]), because that would require the module to depend on :impl-real in order to reference RealLoginHandler. Instead, we had to exclude the real implementation explicitly from the test graph, for example:

kotlin
1@DependencyGraph(
2    scope = AppScope::class,
3    excludes =
4        [
5            RealLoginHandler::class, // FakeLoginHandler is used in tests
6        ],
7)
8interface TestAppGraph

Fixing bugs in Metro

During the migration, we ran into several bugs, performance issues, and missing features in Metro. That isn't surprising for a framework in active development, especially when it is used in a codebase of our size. We fixed some problems ourselves, but more often we relied on Zac's guidance and expertise to turn bug reports into production-ready fixes. Over the course of the migration, our team merged 58 pull requests into Metro.

Anvil extensions

Anvil supported custom code generators, and we used these extensions heavily for patterns unique to Square Android. For example, we have custom @ContributesRobot and @ContributesService annotations so test robots and Retrofit services can be injected:

kotlin
1@ContributesRobot(LoggedInScope::class)
2class LogOutHandlerRobot @Inject constructor(
3    private val logOutHandler: LogoutHandler
4) : ScreenRobot<LogOutHandlerRobot>() {
5    fun forceLogout() {
6        logOutHandler.forceLogout(Normal)
7    }
8}
9
10
11@ContributesService(AppScope::class)
12interface AccountStatusService {
13    @GET("/...")
14    fun getAccountStatus(): AccountStatusResponse
15}

Metro doesn't currently provide stable extension points, so our first solution was to rely on KSP, as Metro recommends. That unblocked the migration, but KSP added significant build-time overhead, which matched what we had already learned when we explored migrating Anvil to KSP. In a second step, we migrated those KSP processors to our own Kotlin compiler plugin. These extensions are open source: square/metro-extensions.

Implementing our extensions as a compiler plugin carries some risk because Metro doesn't guarantee stability for the APIs we rely on. To catch regressions early, we set up a nightly job that builds Metro from source and runs our integration tests against it. That setup has already helped us catch breaking changes, report them upstream, and address them.

Build time improvements

One of Metro's main value propositions is faster builds. Compared with Dagger 2 and Kotlin annotation processing, Metro avoids entire build steps such as generating Java stubs from Kotlin code, running an annotation processor, and compiling the generated Java. Instead, Metro runs inline during the regular Kotlin compilation step that already has to happen:

Build steps

We compared Dagger 2 + Anvil with Metro + our KSP processors and Metro + our own compiler plugin. We expected improvements based on results from industry partners, but seeing these big leaps in our own project was still surprising:

Development app

Demo app benchmark results

The table below shows the median build times behind this chart. Positive deltas indicate faster builds.

ScenarioDagger median (ms)Metro + KSP median (ms)KSP delta vs. DaggerMetro + Compiler Plugin median (ms)Compiler Plugin delta vs. Dagger
ABI Change in Account10,3176,52336.8%4,48456.5%
ABI Change in Account Service6,5754,42832.6%3,89340.8%
ABI Change in App6,3854,33732.1%5,07420.5%
ABI Change in Utilities13,1109,84124.9%7,18445.2%
Dagger Change in Common Wiring7,3434,28641.6%3,80448.2%
Dagger Change in Login Wiring6,2784,12434.3%3,45545.0%
Dagger Change in Utilities Wiring6,6944,16437.8%3,62745.8%
Non-ABI Change in App4,0513,7268.0%4,401-8.7%
Non-ABI Change in Utilities2,1081,9796.1%1,9965.3%

Production app

Vertical app benchmark results

The table below shows the median build times behind this chart. Positive deltas indicate faster builds.

ScenarioDagger median (ms)Metro + Compiler Plugin median (ms)Compiler Plugin delta vs. Dagger
ABI Change in Account Public105,73455,34447.7%
ABI Change in Account Impl63,96045,69128.6%
ABI Change in Account Wiring68,66847,37331.0%
ABI Change in Utilities116,56868,43241.3%
Non-ABI Change in Utilities47,28445,3674.1%
Dagger Change in Wiring56,88744,72021.4%
Dagger Change in Utilities Wiring65,76448,42926.4%

We ran Gradle benchmarks on local MacBooks for the first graph, which represents a smaller development app, and on remote workstations for the second graph, which represents a large production app. Depending on the environment and scenario, we measured total build-time improvements ranging from 5% to 56%. Even if we assume a conservative average improvement of only 10%, that still saves us more than 4,800 hours of Gradle build time in CI every week.

It is rare to see a single change such as adopting Metro deliver gains this large. The build-time improvements alone should make any large Kotlin project seriously evaluate Metro over Dagger 2.

Conclusion

Metro is an excellent dependency injection framework for Kotlin. It learned from predecessors such as Guice, Dagger, and Anvil, kept the best parts, and removed much of the friction. Zac is also an outstanding maintainer: he responds quickly to issues, ideas, and improvements, and he helps external contributions get over the finish line.

After nearly nine months and 850+ pull requests, we successfully migrated more than 7,000 Gradle modules, 1,500 CI jobs, over 300 development apps, and 22 production apps from Dagger 2 and Anvil to Metro. That unblocked the Kotlin 2 compiler adoption for Square Android, which otherwise would have prevented us from upgrading to Kotlin 2.4 or newer. Build times also dropped significantly, improving productivity both locally and in CI for humans and agents.

Reaching this point took the work of many people across the team, including former teammates whose work was essential to making it successful. We're proud of the result and excited about the next steps for Metro.

$