If you want to use dependency injection in your Android application today, it’s easy to get analysis paralysis because there are so many options available: Dagger, Hilt, Koin and something vague called Pure dependency injection (a.k.a. manual dependency injection). Not only that, for each of these options, a quick search on the internet will yield many articles explaining why that specific approach is the best. In this situation, making a choice becomes a major challenge all by itself.
Therefore, to assist you with this challenging decision, in this article I’ll describe the most popular approaches to dependency injection in Android and discuss their respective benefits and drawbacks.
Before we jump into implementation details, it’s worth taking a moment to understand what “dependency injection” is and, even more importantly, what it isn’t.
Fundamentally, dependency injection is a high-level architectural pattern that separates two concerns inside your codebase: the “functional” concern, which is the actual logic of your app, and the “construction” concern, which deals with instantiating and wiring of individual objects. I know this sounds way too abstract if you have never used DI before. If you feel that you need a bit more context, then, in this article, I described the general concept of dependency injection as it applies to Android applications.
Unfortunately, there is a lot of incomplete and even plainly wrong information about dependency injection out there. For example, you might’ve heard that DI is basically “passing arguments into constructors”, which is a common misconception. To debunk some of the most common myths, I wrote this article. It’s recommended read if you already know about DI to make sure that you received trustworthy information on this subject.
What’s important to understand is that dependency injection is one of the most beneficial architectural patterns out there. The difference between codebases that implement proper DI and those which don’t is simply staggering. Therefore, adoption of this practice is an investment with exceptionally high long-term ROI.
Dependency Injection Frameworks
Dependency injection frameworks are special libraries that assist you in implementing dependency injection in your projects. These frameworks utilize Convention over Configuration paradigm. This basically means that they expose a predefined set of conventions which, when you follow them, spare you quite a bit of manual effort (i.e. less coding).
To put it simply, when you adopt a DI framework, your DI implementation basically builds on top of framework’s “template” and you get access to various quality-of-life improvements that it provides. Different frameworks offer different templates and different utilities, and also involve different architectural and operational trade-offs. These are the aspects that you need to understand when choosing a DI framework for your own projects.
So, let’s discuss the most popular dependency injection frameworks for Android.
Dagger 2 framework, which is commonly referred to as just “Dagger”, is maintained by Google and, therefore, can be thought of as the “official” DI framework for Android. Unsurprisingly, it’s also the most popular choice today.
Dagger offers a very impressive set of conventions and features which makes it the most powerful DI framework out there. Unfortunately, it also makes it the most complex one to learn and use. In addition, notoriously bad official documentation, parts of which became memes among Android developers, makes the complexity issue even worse.
To support its advanced features, Dagger relies on compile time annotation processing and code generation. This means that when you build your app, Dagger scans your codebase, identifies where you used its conventions and then generates a pile of source code that “does the magic” according to your specifications. If you made a mistake, Dagger will detect it and fail the build. Therefore, with Dagger, you get compile-time validation of your DI setup. This is a major benefit, of course, but it comes at a price of an increased build time.
- The “official” framework by Google
- Most popular
- Biggest feature set
- Compile-time validation
- Build time overhead
- Poor official documentation
Hilt is relatively new framework which is also maintained by Google. To be precise, Hilt is not exactly a full-fledged DI framework on its own, but kind of “wrapper” around Dagger. The stated motivation for Hilt was to simplify Dagger workflow when implementing DI in Android applications. Technically speaking, Hilt delegates to Dagger under the hood and adds a bit more conventions and restrictions on top of Dagger’s.
You can think about the relationship between Dagger and Hilt in the following manner: Dagger is extremely versatile and, therefore, the “template” it provides is very minimalist. This can be very handy if you need to accommodate some special requirements, but opens many possibilities for developers to shoot themselves in the foot. [Believe me, developers do get creative with Dagger and, occassionally, make total mess of their projects.] This versatility is not needed in most projects, so Hilt proposes a more rigid and opinionated template to allow developers ramp up faster and have less space for mistakes. That’s the deal.
In my opinion, Hilt’s template is indeed very reasonable default and will be sufficient for most Android projects. In addition, it has decent documentation, so, compared to Dagger, developers will probably have easier time using Hilt.
My only issue with Hilt is that it adds even more “magic” on top of Dagger and, most probably, adds even more build time overhead. Given the fact that long build times are the primary productivity killer in bigger projects, I find Hilt’s value proposition questionable.
- Provides better default “template” compared to Dagger
- Less space for mistakes compared to Dagger
- Decent documentation
- Risk of additional build time overhead
Out of all approaches described in this article, Koin is the one I’m least familiar with. A while ago I used Koin in a simple tutorial app to learn how it works, but I haven’t seen it used in production yet. Therefore, if I get anything wrong in this section, please correct me in the comments below the post.
First, I’d like to state it as clearly as I can: Koin is a dependency injection framework, not a service locator. This needs to be said because many developers think otherwise, which is understandable. I myself thought the same thing when I saw Koin for the first time, but I was wrong and Koin isn’t service locator. Technically speaking, Koin is as close to being service locator as Dagger, but the topic of service locators is outside the scope of this article.
The motivation behind Koin was to write relatively simple dependency injection framework which would realize the full potential of Kotlin programming language. As a consequence, Koin is not suitable for use in projects written exclusively in Java (although it does support injection into Java classes in mixed codebases). Another step away from Dagger’s legacy was the decision to avoid compile time annotation processing and code generation.
If not annotation processing, then how Koin works? Until I wrote this article, I had thought that Koin uses reflection under the hood. However, as several readers pointed out, that’s not exactly correct. Koin’s core logic is reflection-free. Instead, it relies on Kotlin’s inline functions and reified type arguments to work around Java’s classical type-erasure limitations.
That said, Koin does offer optional reflection-based conventions that constitute further quality-of-life improvements for developers. However, runtime reflection is not all rainbows and unicorns. Its first drawback is that it’s relatively slow, so it adds overhead in production, on users’ devices. This overhead is negligible in most cases, but the problem is that the cumulative overhead scales with app’s size and disproportionately affects lower-end devices. Therefore, if you use Koin’s optional reflective features, you take the risk of running into performance problems down the road. At what scale can these issues pop up? I don’t know for sure, but my guess is that it won’t become a problem for most applications, except for the biggest ones.
Regardless of whether you opt into reflection or not, Koin doesn’t provide compile-time validation of your DI setup. Therefore, if you use this framework and make mistakes, you can experience runtime errors and crashes. Frankly, I think this isn’t that big of a problem and its overall severity gets exaggerated by developers. I mean, most of these issues will be found during development or QA, and even if something like that does happen in production here and there, most Android apps aren’t critical systems that require 100% reliability. In addition, Koin provides a way to validate your DI setup in a JUnit test, though this approach, even if it works, introduces additional overhead and complexity.
- Almost no build time overhead
- Good documentation
- Good support
- No compile-time validation (i.e. runtime errors)
- Limited feature set compared to Dagger and Hilt (e.g. as for today, no ability to add binding for Activity object)
- Risk of user-facing performance issues if runtime reflection is used (optional)
- Can’t be used in Java projects
Pure Dependency Injection
The last approach to dependency injection in Android is to implement it manually, by hand. That’s so-called “pure” dependency injection (a.k.a. manual DI, vanilla DI, etc.). This is the least popular approach of all and it earned bad reputation among developers. The curious thing about pure DI’s reputation, though, is that every time I ask developers who complain about it how many times they implemented pure DI in real apps, the answer is always “zero”.
For a very long time I myself bought into the idea that pure DI is inferior and infeasible approach, without giving it a second thought. However, after I read Mark Seemann’s blog posts where he said that he came to realize that this is the best approach to DI, I decided to give it a try. So, I tried it in small app. Then in a bigger one. Then I used it in client’s codebase. Long story short, today I think that pure DI is totally viable option in Android, which is also the optimal choice in many situations.
The main benefit of pure DI is that it doesn’t require any framework at all. So, all those performance concerns that we discussed earlier become irrelevant. If you use pure DI, there is simply no way to screw either your build times or app’s performance in production. That’s great. In addition, since no framework is involved, there is no “magic”. Therefore, new developers jumping into the code will have much easier time understanding what’s going on. After all, pure DI is just code. [Dagger’s pre-processor, for example, generates one possible implementation of pure DI].
The main drawback of pure DI is that you need to implement it by hand, from scratch. Since there is no framework to provide you with a “template”, if you don’t know what’s your end goal exactly, you’re dead in the waters. The best case, you won’t know where to start. The worst case, you’ll create some barely working monster, riddled with bugs, which will haunt all project’s developers for years to come. Another drawback is that, well, it’s manual DI, so you’ll need to write all the “plumbing” code yourself.
Pure DI’s benefits:
- No performance concerns
- Simple to understand and modify
Pure DI’s drawbacks:
- Initial implementation requires special knowledge and skills
- More manually written code than when using DI frameworks
- Not “sexy” enough for many developers
In the previous sections I described several approaches to DI in Android, alongside their respective benefits and drawbacks. However, I’m sure that many readers would still find it challenging to make a choice. After all, as is always the case in software engineering, none of the options is ideal and there are tricky trade-offs involved.
So, what technique should you use? Here come some bad news… you’ll need to answer this question for yourself, based on your specific situation and preferences. All I can do now, aside from describing your options, is to share my own setup.
In most cases, I use Dagger. Plain Dagger, without Hilt. As I said, Hilt adds some decent conventions, but I just don’t need them. I’ve been working with Dagger for years, in tens of different projects, so I can set it up in a greenfield project in less than 30 minutes. In addition, I don’t use Jetpack ViewModels (and, luckily, most of my clients don’t use them either), so Hilt’s “magical” handling of ViewModels is of no interest to me. Therefore, Hilt’s “benefits” don’t justify the risk of additional build time overhead for me.
I don’t use Koin because I’m still not sure that it’s good long-term bet. Even if I’d known for sure that Koin is better than Dagger (which I don’t), I still wouldn’t use it. The reason is that the rate of Dagger’s adoption in the community, its track record and the fact that Google maintains this framework are much more important than using the “better” (from a technical standpoint) tool. In my experience, many older Android projects are coupled to legacy frameworks and developers waste a lot of time learning and dealing with them. For those developers (and businesses), the fact that someone considered that framework to be “the best” several years ago is hardly comforting.
However, in light of Dagger’s complexity and its effect on build times, for bigger legacy projects that decide to adopt dependency injection, I prefer to use pure DI. I would also refactor projects that use Dagger and grow to the point where Dagger’s overhead becomes noticeable to pure DI. Why just for bigger projects? Well, there is nothing wrong with choosing pure DI even for greenfield projects, but I find Dagger’s “magic” very valuable, especially at the initial stages of development when you just learn about app’s business domain and constantly refactor your code. In this situation, Dagger’s “auto-wiring” capabilities allow me to go faster and explore more ideas per unit of time. If I’d start a project that has a high chance of growing into a behemoth, I’d probably use pure DI from the onset, but this is kind of imaginary scenario because, in my experience, projects have much higher chance of being shut down than growing to become behemoths.
Alright, that’s it. I hope that the information in this post will help you in making your own choice.
Most importantly, remember: dependency injection is one of the most beneficial architectural patterns out there. Therefore, I highly recommend that you use it. Whether you’ll implement it using Dagger, Hilt, Koin, pure DI, or any other tool is really less important than the sole fact that you did implement it. It’s especially important to consider DI in new projects because it’s very challenging to incorporate DI into existing “legacy” code.
As usual, thanks for reading and if you have anything to add, leave your comment below.