From Dagger to Metro

Metro - modern and kotlin injection framework created by Zac Sweers. And we, Android developers at Vinted, officially and fully migrated to it! It was quite a bumpy ride for our huge codebase.

Our story begins…

Era before Metro

We have a huge codebase of a few hundreds Gradle modules, collected some good code and some legacy code during the past 14 years. We adopted the dependency injection idea from the beginning, first it was the Dagger version released by Square, then the second fully-static version was released by Google. A couple of years later, we adopted dagger.android, and the idea of having subcomponents per fragment looked fantastic back then (spoiler alert, it is not). Later, a simpler yet more powerful DI idea arrived as the Hilt framework, but it was too late to redo all fragments.

After modularization took momentum and module count grew rapidly, we began to envy Hilt’s way of installing dependencies instead of providing via large dagger modules. But it was hard to justify the time spent rewriting the code for the business.

Until one day we found the Anvil - Kotlin compiler plugin, which brings Hilt idea to contribute dependencies via annotation. And it was faster due to its dagger factory generation. We eagerly began adopting and even migrating Fragments from Android Injector to construction injection.

But technologies are moving fast, Kotlin released K2, and since Anvil was Kotlin compiler plugin, it required huge effort to adopt K2, and later Anvil moved to Maintenance mode.

So, even by upgrading Kotlin to 2.x, we were still stuck on K1 and 1.9 language features, without incremental compilation. K1 support nearing its end also added pressure. We had many options: Hilt, Kotlin-inject, and … Metro.

Why Metro

Metro was built using lessons learned from other DI frameworks, bringing together many solid ideas. It supports Kotlin idioms well and is fast, consistent, and easy to learn. However, it was difficult to justify switching at the time—at first glance, it seemed too risky to rely on a brand-new framework.

Metro has a major migration advantage that other frameworks don’t: robust, feature-rich interoperability with popular DI solutions like Dagger, Anvil, and kotlin-inject. That level of compatibility is something its competitors lack. In fact, Metro was the quickest path for us to adopt K2, since other frameworks would have required migrating more business code—adding not only time, but also risk.

We were evaluating all the options, but Metro was growing fast and the direction of its growth aligned with our needs closely. This has further solidified our choice.

Bumpy migration

Not gonna lie, the ride was not easy. We decided to migrate everything at once, without using any of the interoperability options, keeping scope and graph structure. First obvious thing to do was just mass-replace imports and annotation names.

- import javax.inject.Inject
+ import dev.zacsweers.metro.Inject

Funny thing about javax.inject.Inject - a lot of libraries are “leaking” it! So the IDE will always try to suggest it in autocomplete. At some point, we’ve had to set up a separate validating KSP processor just to fail the build when @Inject annotation from the wrong library was encountered in source code, since it can lead to subtle and hard-to-catch bugs. Later though, we were able to remove it from the compile classpath completely, which solved the autocomplete problem.

Also removing @JvmSuppressWildcards as they are not needed anymore. Harder thing was to replace @ContributesMultibinding since Metro has two annotations: @ContributesIntoSet and @ContributesIntoMap. But no worries, Metro will let you know if you make a mistake!

- @ContributesMultibinding(FragmentComponent::class)
+ @ContributesIntoMap(FragmentScope::class)
  @ViewModelKey(AddressPluginViewModel::class)
  class AddressPluginViewModel @Inject constructor(): ViewModel

boundType (and many other cases) can be fixed by regexp magic. Pro tip: write the script instead of manually doing mass replace, it will help later doing upstream merges.

boundType = (.*)::class
️⬇️
binding = binding<$1>()

The other half was tricky. Do you remember Android Injectors from dagger.android I mentioned earlier? We still have more than 100 fragments left… But there is nothing code generation would not solve! We made a crude implementation to generate graph extensions from the similar annotations (we made some shortcuts here). From this code:

// Container only for Android Injector contributions
@InjectorModule(ActivityScope::class)
abstract class LegacyFragmentsModule {
    @FragmentScope
    @ContributesAndroidInjector(modules = [LegacyModule::class])
    abstract fun contributesLegacyFragment(): LegacyFragment
}

We generated this:

@FragmentScope
@GraphExtension(
    FragmentScope::class,
    bindingContainers = [LegacyModule::class]
)
public interface LegacyFragmentInjectorGraph {
    // Still using member injection
    public fun inject(instance: LegacyFragment)

    @ContributesTo(ActivityScope::class)
    @GraphExtension.Factory
    public interface Factory {
        fun create(
            @Provides instance: LegacyFragment
        ): LegacyFragmentInjectorGraph
    }
}

@Inject
@ContributesIntoMap(
    ActivityScope::class,
    binding = binding<InstanceInjector<Fragment>>()
)
@ClassKey(LegacyFragment::class)
public class ShippingFragmentInjector(
    private val graphFactory: ShippingFragmentsInjectorGraph.Factory,
) : InstanceInjector<Fragment> {
    override fun inject(instance: Fragment) {
        graphFactory.create(instance as LegacyFragment).inject(instance)
    }
}

… and voilà, and another big chunk of code was done! The rest was easier. We took advantage of our existing ksp-powered code generation. We had custom codegen for a lot of things, since we needed to generate boilerplate for Anvil (yes, we are generating boilerplate for everything). Changing the codegen was not hard, mostly imports and annotation names, and boom, another couple hundred cases were fixed!

@ContributesFragment
class InfoFragment @Inject constructor() : Fragment

@ContributesViewModel
class InfoViewModel @Inject constructor() : ViewModel

Not everything was so smooth. We learned in a hard way what it means to adopt the 0.x tool. We found quite a few cases when the compiler was crashing due StackOverflowException, or the generated code was too slow. We began with the 0.7.x version, and finished with 0.9.2 (0.9.3 was broken for us). Most of the problems arise due to MemberInjector, which we don’t recommend to use.

Moreover, being a compiler plugin, Metro does not output much in build directories, like Anvil and Dagger used to do. At first, it makes debugging a bit harder, but once we’ve got accustomed to rich diagnostic reports which are hidden by a Metro Gradle plugin property, debugging has become much easier.

Another big problem was constant upstream changes. A few dozen developers produced a lot of changes daily, which made a 30min conflict solving ceremony each day. We strategically chose to do migration around the holiday season.

After summing everything, we have zero regrets. Of course, it was a bumpy ride, but a worthy one. We learned so much about compilers, how to do mass migrations, how to make good codegen. Big thanks to Zac Sweers, who put a lot of effort into fixing problems in a timely manner, and we hope that our small contributions to Metro will help others to have a smoother migration.

The results

Two months later after migrating and solving all the issues we were able to enable this juicy K2 and a bit later Incremental compilation (which is a topic that deserves another article all for itself). The results are looking quite good for us. Apart from getting rid of existential dread which was caused by the fact that K1 support will be dropped sooner or later, we’ve got some solid CI build times improvements! For our large codebase, they look as follows:

Build Scenario Metro Dagger/Anvil Reduction Savings
Best Case build; most tasks are cached 3m 23s 4m 33s 25.64% 1m 10s
Worst Case build; no tasks are cached 24m 12s 27m 05s 10.65% 2m 53s
Worst Case Release build; no tasks are cached 37m 43s 40m 09s 6.06% 2m 26s
ABI change in a core module that all feature modules depend on 15m 46s 17m 22s 9.21% 1m 36s


These stats were recorded with Metro 0.9.x. Metro continues to grow and improve, also improving the code it generates, and therefore, build times, so if we were measuring them with the latest version, the results would have certainly been even better!

Our local build times also improved greatly, incremental compilation is no joke! However for the sake of brevity, we will not include them here.

Conclusion

To sum it all up, Metro consolidated all the best practices from other popular frameworks, while leaving out the not-so-best practices on the side, allowed us to enable K2 and immediately experience significant build time improvements, while also unlocking incremental compilation, which means that the builds will be getting even faster.

The migration process, even in a big codebase with lots of legacy remainders lingering, even without using any interoperability capabilities, was interesting and as challenging as it should have been in such circumstances.

The developer satisfaction and confidence in the context of dependency injection has also increased with the arrival of Metro. It’s easier to reason about one DI framework, rather than two, especially when this framework is kotlin-first and kotlin-centric.