Mastodon
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

  1. 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.
  2. 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.

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.

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

The Contrived

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

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.

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.

No headings found in this post.