This is a port of a write-up I did in the Kotlin Lang slack here in response to the question "Dagger vs. Hilt vs. Koin vs. Metro vs. <what comes next>. Serious question: Are there any compelling reasons to switch from Koin to Metro in a Compose Kotlin multiplatform project?"
I think it's largely better to think of the frameworks [OP] listed as two categories that solve different problems in different ways.
Category 1: Dependency Injection
This includes Metro, Dagger, Hilt, Anvil, and kotlin-inject. They are all descendants of giants before them like Dagger 1, Guice (ish, API inspiration but guice was runtime), and the original JSR 330 spec. Anvil and Hilt are really just extensions to Dagger and kotlin-inject, like dagger-android before them. Manual DI (i.e. just constructors and whatnot) also falls in this category, the tools above are just generating all that wiring you'd otherwise handwrite.
These libraries are built with a laser focus on being true and pure dependency injectors. This means true inversion of control with a focus on ease of testing, simple constructor injection, and a generally low-touch runtime API that allows you to use the types they manage without the framework. This is what makes them great for testing and isolation.
Metro, Dagger, and kotlin-inject also do true compile-time dependency graph validation. If it compiles, it works and will never fail at runtime. That is a safety guarantee that is extremely valuable, especially in large teams and modularized codebases. If you add mobile to that consideration, it becomes even more important because mobile developers cannot immediately deploy fixes to production at the speed that backend or web developers can.
Lastly, the static validation these perform unlock two extra benefits
- They can generate extremely performant code because they know the exact shape of the graph at build-time. Metro and Dagger will generate significantly different code for the same binding depending on how the bindings in the graph are actually used in your consuming code.
- They are inherently analyzable because of this in-memory, compile time model. Dagger allows introspection of this via SPI, Metro does via reports and tracing.
They're not without their drawbacks though.
- In exchange for this added safety and performance, they ask you to be more intentional and explicit with your code. Some people find this irritating or tedious or hard to understand.
- Annotations are the easiest way for this in Kotlin and Java, though Metro as a compiler plugin is branching out of these since it can transform IR directly and already has some features that are not annotation based like dynamic graphs.
- That build-time validation is also not free, though it's rarely the framework running slow and rather kapt/ksp's overhead that's incurring the real amortizing cost.
Importantly, the currency of these costs are measured in developer productivity, and those can always be improved. Build toolchains get faster, Metro's biggest appeal isn't that its API is so much better (it isn't) or that it supports KMP (so does kotlin-inject), it's that it's so damn fast as a compiler plugin. Customers/users/etc are never paying this cost, and if you're a company that's exceedingly important. It's why tools like Dagger, regardless of people's papercuts with it over the past decade+, it remains the industry standard that people trust and use.
Category one automates construction of the graph. It's an O(1) act at runtime because the code gen pushes the dependencies down and fails only at compile-time.
Category 2: Service Locators
This includes Koin, Kodein, Guice-ish, Spring DI, Compose composition locals, Application.getInstance(), etc. Some of these support JSR-330-esque behaviors for convenience. They are sometimes nameless conventions in a codebase or framework. IntelliJ platform has getInstance() all over the place, when I worked at Flipboard we had a magic FlipboardManager.instance, etc etc. They arise naturally if you're not using an intentional DI system because we as programmers generally try to organize our code 🙂.
Their focus and value prop is on ease of use to developers. Application.getInstance(), by inject(), etc will always be easier reaches. The fact that they are runtime-only by default means there is no build costs and your builds are always faster! Your code may fail at runtime if you're missing a dependency but at least it won't quietly do the wrong thing or fail with some obscure NPE.
You don't really have to think about how that dependency got here, there's an implicit trust system. That trust system can work really well in a tight code base with serious alignment across the contributors to it. That is alignment that naturally degrades with codebase and team size, no matter how well intentioned. Jake mentioned this in a panel we did with JB a few months back - those large team graph explosions creep up on you when you're no longer seeing every PR that comes through.
But, if you're a backend team that can very quickly deploy a fix to prod? That runtime cost risk significantly lower, your clients can see your errors and gracefully degrade. If you're running on a beefy AWS instance, you aren't nearly as concerned about runtime reflection performance as an obfuscated mobile app running on a battery. And hey, sometimes we just generally agree that the developer-cost to doing it the IoC way is just too high to write code the way we want, and using a little bit of de-risked service locating unlocks powerful declarative code patterns (i.e. Compose UI and composition locals).
There are other factors worth considering, but in my experience they're somewhat secondary to the above concerns.
- Testing in isolation is often much harder and requires using the framework's test harness. Or, as is the case in frankly most SL shops I've heard of, you just write significantly less or no tests
- Reflection allows you to do powerful, dynamic replacement strategies or framework integrations. At the same time, it is slower at runtime and nigh-impossible to use safely with a code optimizer or obfuscator.
Category two automates retrieval of the graph. It's a runtime O(n) lookup of dependencies, fails at runtime, and you have to request the dependencies.
Where the endless debates about DI vs SL break down is a mix of legit and contrived issues.
The Legit
- Sometimes a SL framework's risks are totally acceptable for a given team. Sometimes it's not. Different teams estimate these risks differently too, based on personal experience/preference/bias/etc
- The build costs of compile-time DI are real, but also changing rapidly
- Multiplatform support is a real value prop
- Migration costs are real
- There is a difference between the underlying patterns
- The developer productivity bill comes due in different ways. Whether it's test harness boilerplate, when/where you fix DI issues (build time or prod), runtime performance, etc.
- IMO, the biggest bill in category 1 comes in the form of an extra few seconds of build time, and the biggest bill in category 2 might come in a 2am pagerduty alert.
The Contrived
- ___ is better because ___ is worse. This comes up a lot with Koin vs Dagger tbh, and isn't really a high-signal signal for anyone evaluating. It's weirdly political/tribal in a space that should value measurable impact (build times, production hotfixes, etc) over vibes.
- ____ is DI because it deals with dependencies! This comes up a lot in SL vs DI debates. I mean, a SL is strictly not DI. But this also isn't the hill to die on. If you're finding yourself trapped on the nomenclature taking issue with someone pointing out that SL is not DI, you're missing the point that the person pointing it out is trying to emphasize on why that distinction matters (i.e. everything I wrote above!)
There are other little things I haven't touched on because this is already long, like IDE experience/kotlin compatibility across versions, etc. But I think the above highlights are the most important ones.
A heavy nit about what "compile-time validation" means
One thing I do wanna nit about is the recurring, conflated usages of "compile-time validation". I mentioned this in my higher up message, but I think it's an important distinction. When someone says "Koin is switching from KSP to become a compiler plugin, like Metro, I see less reasons to switch than before", it's a fundamental misunderstanding of what is happening at compile time and implies that they're different tools accomplishing the same thing. Koin's KSP/compiler plugin do a few things, namely
- Validation of certain APIs' correctness
- Cross-module aggregation (similar to hilt/anvil/metro aggregation for large projects)
- Optimize some IR expressions
That first one gets tossed around a lot but it's worth being specific that it's more like a linter. Metro, Dagger, Anvil, etc also do all this in the form of just usage checks, but they are fundamentally not the same thing as compile-time dependency injection. Koin's docs have explicitly said that it's planned in the future, but not what it currently does and it's important enough to be worth not conflating.
Community
Most of us in this space know each other and talk often. I've known the Anvil, Dagger, kotlin-inject, and Guice people in the industry for years. I've contributed to them and they've advised or contributed or both to Metro. I met Arnaud in person finally at Droidcon London last year and he's a nice guy, I think it's super cool that Koin is exploring the compiler plugin space because I think it's a powerful tool for developers to leverage. We've been on a couple calls and docs about ideas of how we (DI people in the ecosystem) can make it all work better for the community too.
These frameworks are also still regularly borrowing things that work well from others. Koin clearly felt the value of some degree of compile-time validation and added its KSP (and now compiler plugin) to do some of this. I haven't seen much angst from the Koin community about adding back a build system here, because it was obviously valuable. Similarly, Dagger/Anvil/kotlin-inject clearly saw value in better accommodating kotlin-first approaches, building infra on top of KSP and native support of kotlin language features like Koin and Kodein did. That's healthy.
My 2c for IoC
True DI is just an IoC pattern people can adopt. Like any other pattern, there's an initial learning curve and then it becomes automatic. Compose, coroutines, FP, FRP, Spring, etc are all no different. Some have friction points with scale or build systems or both, and we generally treat those as solvable, engineering problems. This is how software ecosystems go.
- Guice wanted to be an implementation of JSR330
- Edit: Py pointed out I have the order wrong here!
- Dagger 1 wanted to be Guice but without the runtime risk or reflection performance cost
- Dagger 2 wanted to be Dagger 1 but with zero reflection and tighter semantics around object graphs (i.e. components)
- dagger-android was an extension to Dagger 2 to make it easier to use in Android framework types at the cost of being a little magic
- dagger-hilt, motif, and anvil were parallel extensions to Dagger 2 to make it easier to work in large, multi-module codebases.
- Anvil also was the first to really scratch the build time itch here with its factory-gen-only mode as an alternative to Dagger
- kotlin-inject was a greenfield, multiplatform-friendly kapt/ksp implementation of DI that intentionally tried new kotlin-first APIs. kotlin-inject-anvil ported anvil's aggregation features to it
- Metro was a greenfield, multiplatform-friendly, compiler plugin implementation of DI that took heavy inspo from kotlin-inject's API and dagger's code gen, and opted to make Anvil's features a first party API
Metro is almost certainly not the last new DI framework for Kotlin 🙂
Every iteration here has moved the needle a bit in different ways, but arguably the underlying IoC pattern here is ~80% unchanged over nearly two decades. Anvil's aggregation, multiplatform, kotlin-inject's kotlin-first semantics, etc were all natural evolutions and we were more than overdue for something that brought them all under one roof. I think Metro's success has been less about any of its own technical value and more that the community that valued all these things were clearly itching for something like it to come into existence. If the values I described above are what's important to your team, then it's the best type of tool for your team. If the tradeoff is too high, or the risk cost of runtime validation not significant enough, then service locators are probably fine for you. The best DI framework is the one you have. You don't migrate because another one is better, you migrate because the one you're using is not satisfying your requirements.
When I write a new simple project, I almost never use a DI framework out the gate. But I do write manual DI still. Then after a certain level of complexity I find myself annoyed with the wiring and adopt a DI framework. I did this with my Field Spottr app last year. The underlying pattern is the same.