Metro vs Hilt: a brief history of Android dependency injection
The Dagger years
If you've been writing Android long enough to remember findViewById, you remember when wiring up an app meant typing the same kind of code over and over. You'd construct a ViewModel somewhere, hand it to your Activity, the Activity would need a UserRepository, which needed an ApiClient, which needed an OkHttpClient, which needed a list of interceptors. By the time you'd finished sketching out a screen, you had a dependency tree six levels deep and a few hundred lines of MyApp.getInstance().getRepository() glue spread across the app. Android development, for most of its history, was less about writing features and more about wiring up the plumbing those features ran on top of.
Then Dagger showed up. Square wrote the first version, Google rewrote it as Dagger 2, and for the better part of a decade it was the boring answer to dependency injection on Android. The pitch was: annotate the constructor with @Inject, annotate a class with @Singleton, annotate a method with @Provides, and the compiler would generate the glue code that knew how to construct anything you needed. You'd stop writing new for your own classes.
The simple shape looked something like this:
@Singleton
class UserRepository @Inject constructor(
private val api: ApiClient,
)
@Module
class NetworkModule {
@Provides
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
}
@Component(modules = [NetworkModule::class])
@Singleton
interface AppComponent {
fun userRepository(): UserRepository
}You'd declare a Component, list the modules that contributed bindings, ask for a thing, and at compile time Dagger generated a DaggerAppComponent class that knew how to build the whole tree. The first time you saw it work, it felt like magic. The hundredth time you traced through a thirty-line @Component.Builder for an Activity-scoped user flow, it felt like a tax.
Dagger 2 was correct. It was also a lot. Every Activity needed its own Component-Subcomponent setup. You'd write a base class that called (application as MyApp).component.activityComponent().inject(this) in onCreate. You'd remember to make the field lateinit var. You'd debug why a Fragment couldn't see the Activity's bindings. Every Android lifecycle had its own ceremony.
Hilt arrives
Google noticed the ceremony, and around 2020 they shipped Hilt. Hilt is, more or less, "Dagger with the Android boilerplate already written for you." Pre-defined components for every Android scope you cared about — SingletonComponent, ActivityComponent, ViewModelComponent. Pre-defined annotations that wired your Activity into the graph without you writing any wiring. The two-line version of the same code became:
@HiltAndroidApp
class MyApp : Application()
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var repo: UserRepository
}Done. No MyApp.getComponent().inject(this). No lateinit var component. You annotate, and Hilt does the rest.
The "does the rest" part is where it gets interesting under the hood. Annotations alone don't construct objects. Somebody has to generate the code that does. With Dagger, that somebody was an annotation processor — first kapt (the Kotlin Annotation Processing Tool), later KSP (Kotlin Symbol Processing). Both work the same way at a high level: they run before the Kotlin compiler, read your annotations, and emit a bunch of .java or .kt files. Then the Kotlin compiler compiles those generated files alongside your real source.
Kapt was painful because it had to fake Java for the Java annotation processor system. Your .kt files got translated into stub .java files, the AP read those stubs, generated Java, and then everything compiled together. Compilation became this multi-stage round trip, and on a project with a few hundred modules, you'd watch your build sit there for minutes burning CPU on the kapt stub generation alone. KSP came along and removed the Java pretense — it could read Kotlin directly — and that took a real bite out of the build times. But the architectural shape was the same: read annotations, emit source files, recompile those source files.
Hilt sits on top of all of this. Underneath the friendly @AndroidEntryPoint is a Dagger component being generated, plus a clever bytecode transformation that rewrites your MainActivity so its superclass is no longer ComponentActivity but Hilt_MainActivity. You wrote one line; Hilt ran an annotation processor, generated Hilt_MainActivity.java, ran an Android Gradle Plugin transform after compilation to swap your class's superclass in the resulting .class file, and stitched a SingletonComponent together at app startup. Magical from the outside. A lot of moving parts on the inside.
Anvil and the K2 break
Somewhere around 2018, Square got tired of writing @Subcomponent graphs by hand and built Anvil. The pitch was simple: instead of maintaining a manually-listed modules = [...] array in a god-Component, just annotate each binding with @ContributesBinding(AppScope::class). Anvil would find every contribution at compile time and merge them into the graph automatically. No more centralized component definitions. Add a new feature module, drop a @ContributesBinding on its implementation, and it would just appear in the graph.
That sounds like a small ergonomic win. At Square's scale — thousands of modules — it was the difference between a maintainable monorepo and an unmaintainable one. And Anvil did something else clever. It wasn't an annotation processor. It was a Kotlin compiler plugin.
This matters more than it sounds. A compiler plugin doesn't generate source files that have to be recompiled later. It hooks directly into the Kotlin compiler — into its resolver during type checking, into its IR generator during code emission — and produces output in the compiler's native form. There's no round trip. The generated factory classes flow straight through the compiler pipeline alongside your hand-written code. For ABI-changing edits, the kind that invalidate caching the hardest, this approach was dramatically faster than Dagger-on-kapt. Square published numbers showing 40 to 60 percent incremental compile improvements when they rolled Anvil out.
The catch — there's always a catch — was that Anvil hooked into a set of Kotlin compiler APIs that weren't really meant to be public. The Kotlin compiler at the time had a frontend, everyone calls it "K1" in retrospect, that was, frankly, a mess. It had been built up over a decade of language evolution and was hard to maintain, hard to extend, hard to parallelize, and notoriously slow on large projects. Around 2019, JetBrains decided to rewrite the entire frontend. Not patch it. Rewrite it. The new frontend, called K2, used a different internal representation called FIR (Frontend IR), had a real plugin API, and was about twice as fast on most workloads.
K2 became opt-in in Kotlin 1.9 and the default in Kotlin 2.0, which shipped in May 2024. Here's where the story turns sour for Anvil. Anvil's plugin hooks didn't exist in K2. They were K1 internals. Porting Anvil to K2 wasn't a matter of changing some API calls; it was a rewrite of how the entire plugin worked. Square evaluated the effort, and Zac Sweers — who'd maintained Anvil and built much of Square's DI tooling — decided not to port it. He decided to start over.
Metro is that fresh start.
Metro: a clean rebuild on K2
Metro is what you'd build if you had a clean sheet of paper, ten years of Anvil and Dagger experience in your head, and the K2 compiler's stable plugin API to work against. It's a Kotlin compiler plugin, like Anvil was, but written for the modern compiler. It generates dependency factories directly into the compiler's IR — the intermediate representation that sits between type-checked Kotlin and final bytecode — and lets the rest of the compilation pipeline carry that work to bytecode without ever round-tripping through source files. No kapt. No KSP. No generated .kt files. The compiler runs once, and your DI graph is wired.
The code you write with Metro looks like a refined version of Anvil:
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class UserRepositoryImpl(
private val api: ApiClient,
) : UserRepository
@DependencyGraph(AppScope::class)
interface AppGraph {
val userRepository: UserRepository
}The contribution model from Anvil is preserved. The graph definition is leaner. The compile-time validation is stronger — Metro checks not just that bindings exist but that the entire graph is consistent, cycles are reported with the full path, scopes line up across the whole module tree. And it's fast. The numbers from teams that have migrated tell a consistent story: ABI-incremental builds drop by 50 to 80 percent off the Dagger+Anvil+kapt baseline, clean builds drop by 15 to 25 percent. For organizations with thousand-module monorepos, this translates to thousands of CI hours saved per week.
So that's the lineage. Dagger 2 generates factory code via annotation processing. Hilt adds Android-specific scaffolding on top of Dagger plus a bytecode transform for Activity entry points. Anvil bypassed annotation processing by hooking into the K1 compiler. K2 broke Anvil. Metro is the clean rebuild on K2.
Should you migrate?
The obvious question is: should you use it?
For a Kotlin Multiplatform project, the answer is straightforward. Hilt is Android-only — it depends on Android lifecycle classes that don't exist on iOS or desktop. If you want compile-time DI on a KMP project, your real options have been kotlin-inject (also a compiler plugin, written by Evan Tatarka, predating Metro by several years) and now Metro. Metro is faster, has a richer feature set around graph extensions and assisted injection, and has the Anvil-style contribution model that kotlin-inject lacks. For new KMP projects, Metro is the modern default.
For Android-only projects, the answer is more nuanced. If you're on Dagger plus Anvil today, the migration to Metro is almost obligatory — you're either staying on Kotlin 1.9 forever or moving off Anvil, and Metro is the obvious destination. Square, Cash App, Vinted, BandLab, and Freeletics all did this migration in 2024 and 2025. Their public writeups read the same way: the K2 upgrade forced them off Anvil, Metro was waiting, and as a happy side effect they got significant build-time wins.
If you're on Hilt, the question is harder. Hilt isn't blocked on K2 — Google ported Hilt to support K2, and it works fine on Kotlin 2.x. There's no forcing function pushing Hilt users off Hilt. The migration is voluntary, motivated entirely by build performance and developer experience.
And the migration is real work. The mechanical translation from Hilt to Metro is not enormous in concept — every @Binds becomes a @ContributesBinding on the implementation class, every @Module becomes a @BindingContainer, every @HiltViewModel becomes a @ContributesIntoMap(ViewModelKey...) declaration. But it's spread across every module in your codebase. On a monorepo with a thousand Hilt modules, you're touching everything. Two or three senior engineers working full-time for four to six months is a reasonable order-of-magnitude estimate.
The piece that's hardest to replace is the part of Hilt that isn't really about DI at all: the bytecode transform that lets you write @AndroidEntryPoint class MyActivity and have your Activity magically inject itself on onCreate. Metro is a Kotlin compiler plugin, and Kotlin compiler plugins run before bytecode is emitted. They can't reach forward in time to rewrite compiled .class files the way Hilt's AGP transform does. To replace @AndroidEntryPoint without losing the ergonomics, somebody would need to write a separate Android Gradle Plugin transform that runs after kotlinc. Nobody has shipped a polished one publicly. So in a Metro-only Android codebase, your Activities and Fragments inject themselves manually:
class MainActivity : ComponentActivity() {
@Inject lateinit var repo: UserRepository
override fun onCreate(savedInstanceState: Bundle?) {
appGraph.inject(this)
super.onCreate(savedInstanceState)
}
}One line per entry point. Annoying, not catastrophic. But it's a step down from @AndroidEntryPoint.
That's not the only thing you give up. You lose first-party Google tooling — Hilt is in every Android Studio template, every codelab, every Stack Overflow answer about modern Android DI. You lose the friendly testing rules (HiltAndroidRule, HiltTestApplication) that come for free. You lose the easy Compose integration that hiltViewModel() gives you — there are community helpers like metroViewModel(), but they're not the standard. And subtly, you lose AI tooling alignment: Claude, Copilot, and friends have a much deeper well of Hilt knowledge to draw from than Metro knowledge. For a year or two, every IDE assist and every chat-based code suggestion is going to be slightly worse at Metro than at Hilt.
You gain build speed. You gain a cleaner contribution model that scales better across many modules. You gain stricter compile-time validation. And you gain the ability to bring the same DI framework to KMP later, if you ever go there. Whether the trade is worth it depends entirely on how much your team feels the build-time pain and how much you value being on the Google-blessed path.
To gauge it honestly: pilot the migration on one library module. Pick something independent — a network library, a logging utility, something with no Activities or ViewModels. Migrate it to Metro by hand. Measure the compile time delta on hot incremental ABI changes against the same module on Hilt. If you see 25 percent or better, the math probably works across a team of fifty engineers within eighteen months. If you see 10 percent, it probably doesn't. Don't trust the published benchmarks blindly — they're calibrated against Dagger plus Anvil plus kapt, which is not your baseline if you're a Hilt user.
Who's done it
The list of public adopters is short but heavyweight. Square moved their entire monorepo — seven thousand Gradle modules, twenty-two production apps. Cash App moved their fifteen-hundred-module codebase and reported a 59 percent improvement on incremental ABI builds. Vinted moved their few-hundred-module marketplace and gained both K2 compatibility and a 10 to 25 percent build improvement. BandLab moved nine hundred modules. Freeletics moved five hundred and saw 40 to 55 percent gains on ABI changes. All of these were Anvil projects first; none were Hilt-to-Metro migrations.
The Hilt-to-Metro story is being written by skillful solo dev, John Buhanan has published metrox-hilt-interop, a shim that lets teams migrate module-by-module rather than big-bang. A few teams on Slack and Reddit have reported partial migrations. As of mid-2026, no major Hilt project has publicly completed the move.
That gap is informative. The Anvil refugees migrated under duress. The Hilt-stayers are watching from the sidelines, waiting either for a clean Hilt-killer to ship on top of Metro, or for a clear signal from Google about Hilt's long-term direction. Neither has happened.
The Deeper Shift
The deeper lesson from this whole arc is that the Kotlin compiler is becoming a real platform. For most of Android's history, the only way to do meta-programming was through annotation processors generating source code. That model worked, but it left a lot of performance on the table. K2's stable plugin API is changing this — Compose's compiler plugin paved the way, Metro is the second major load-bearing example, and there are more coming. Five years from now, "the compiler is the platform" will be how Kotlin tooling is built by default, and the annotation-processor era will feel like the kapt-stubs era feels now: a transitional thing we did because we didn't have anything better.
Metro is one of the first real glimpses of what that future looks like. Whether you adopt it this year, next year, or never, it's worth understanding. The architecture it represents is going to be the shape of Kotlin tooling for a long time, and Zac Sweers — who built Anvil, deleted Anvil, and built Metro — is the engineer who most consistently shows you where the Kotlin ecosystem is heading two years before it gets there. Worth paying attention to.
Clap to support the author, help others find it, and make your opinion count.