From Dagger & Hilt into the multiplatform world with kotlin-inject

Embracing compile-time safety across platforms

Featured on Hashnode

We've been taking advantage of Kotlin Multiplatform at Klima for a while, but it's the first time we're embracing KMM as much as we are right now while we build our brand new app: 🌎 Planet Wild. We went with a monorepo from the start for our apps (Android & iOS), and we're sharing logic from the data and network layers all the way up to our presenters.

We realized in the early days having a multiplatform DI library would benefit our setup, something we didn't have experience with yet. After experimenting with a few different options, we ended up deciding on what's currently my favorite KMM library: kotlin-inject by Eva Tatarka.

The goal of this article is to make it easy for anyone coming from Dagger or Hilt to adopt kotlin-inject by going through and exploring their differences.

🐘 Why not Koin?

Koin is a significantly more popular library, it's currently what's in KaMP Kit, and there are many reasons why one may want to use it. So why shouldn't we stick with it? Koin is definitely a valid option, but here are the reasons why we chose kotlin-inject instead:

  • Compile-time safety — basically the same reason as to why someone would pick Dagger or Hilt instead of Koin for an Android project. Nothing beats compile-time dependency graph validation and no runtime crashes.

  • Dagger-like API. Not as important as the previous reason, but if you like the good parts of Dagger (e.g. adding a class to the graph by simply annotating it with @Inject), kotlin-inject will fit like a glove.

That second point might sound counterintuitive since many people will opt for Koin exactly because of Dagger's "verbose" API. However, kotlin-inject is obviously fully written in Kotlin, so it takes full advantage of what the language has to offer and delivers a delightful idiomatic (and yet familiar) API.

There are certainly other options besides kotlin-inject and Koin, but the scope here will be limited to exploring the option we found to be the best pick for us.


Getting started

All the code mentioned in the article can be found here:

The goal is to go through a small example where we move from Dagger and Hilt to kotlin-inject — if you’d be interested in taking a look at something like this in a real app, you can check Chris Banes’ migration PR on his Tivi app. Even though we're explicitly talking about Dagger and Hilt, this should still be a good read if you're using Anvil or anything on top of it like Whetstone.

There are two branches there: dagger and hilt. The first one adds vanilla Dagger (let's pretend dagger-android never existed… just to limit the scope) while the second one adds Hilt. There are also two open PRs there: Dagger to kotlin-inject and Hilt to kotlin-inject. The following sections will walk through all the changes in details, but feel free to jump straight into the code whenever it gets boring. And if it gets too boring, you can always jump to the TL;DR section all the way below!

The project

It's truly hard to come up with a reasonably simple example that showcases as many DI features as possible without looking completely weird. I promise we won't talk about thermosiphons, though.

With that said, behold The Greeter™! Inspired by the project we get when creating a new KMM project from Android Studio's KMM plugin.

Let’s start by taking a look at what we have with Hilt:

  • We start with the GreetingConcatenator, a simple interface with a single implementation, responsible for concatenating a set of greetings. We'll use this as an example of binding an implementation to an interface:

  • We can then jump to the GreetingModule. It exposes two special greetings (qualifiers!), 3 other non-special greetings in a set (multibindings!), and it binds GreetingConcatenator with its implementation:

  • Next, we look at the PlatformGreeter. We'll use it as an example for 3 things: @Singleton, the classic need of Android's context in the graph (which we get for free from Hilt with @ApplicationContext), and how kotlin-inject makes it easy to have platform specific implementations (demonstrated at the end):

  • And finally we have GreetingHandler. It has a useless assisted argument that we just log to showcase assisted injection, and it puts everything together: it gets the greetings, uses the concatenator, and calls the greeter:

We're also using Lazy for the concatenator dependency and Provider for the greeter for no good reason other than being able to demonstrate later how these concepts work with kotlin-inject.

Dagger & Hilt

Most of the code needed for this is pretty similar regardless if we're using Dagger or Hilt, except for two interesting details:

  • As mentioned above, Android's context is a default binding in Hilt. With vanilla Dagger, we need to make sure to add it ourselves when we're creating the component, like this:

  • There's no need to worry about components with Hilt. All we need is to annotate our Application class with @HiltAndroidApp and the activity (or fragment) with @AndroidEntryPoint, and we can have member injection working immediately on both. With Dagger, we need to define a component explicitly (like above) and we'd usually hold it and expose it from our application class like this (or something similar to that):

With vanilla Dagger, it’s also possible to skip member injection and ask for dependencies directly from the component (that's actually always been my preferred option). 💡 I'm highlighting these points because they'll be relevant when we're moving to kotlin-inject as it works more similarly to Dagger than to Hilt.

Introducing kotlin-inject

We're familiar with our project and we've seen the differences between the hilt and dagger branches. We're ready to start! Let's go through the PRs commit by commit.

1. Dependencies (from Dagger, from Hilt)

The first thing we need to do is to remove Dagger/Hilt dependencies and add kotlin-inject. It definitely feels great saying goodbye to kapt and saying hello to KSP. But other than that, there are no surprises here.

Dagger will eventually support KSP (and Hilt as well, it'll just take longer), which is fantastic. But we're still a long way from being able to imagine a multiplatform-friendly Dagger in the future.

2. Kotlin-inject everywhere (from Dagger, from Hilt)

This is the most important commit and where we'll spend most of our time since it's where we're literally translating Dagger and Hilt APIs to kotlin-inject. Let's revisit each of the files we mentioned before, most of them having the same or a pretty similar diff on both PRs:

GreetingConcatenator: all we need here is to move our @Inject annotation from the constructor to the class itself. No more having to write constructor everywhere! We're also updating the import, but it's possible to use javax.injectannotations as well, which may help on an actual migration.

GreetingModule:

  • The object becomes an interface with no annotation. We'll be able to implement this interface from our main component later — that's one of the options we have to aggregate components and form our object graph.

  • Qualifier annotations become type aliases! That's how dependency disambiguation works here. It may change in the future and we'll talk more about it in another commit.

  • @Provides methods, including the ones with multibinding, remain exactly the same.

  • The @Binds method is gone, together with its own module (which was created just because we can't have an abstract method inside an object) and in its place we have another simple @Provides method, but this time it's an extension function on top of the implementation with the interface as the return type that simply returns this — there's no separate mechanism for binding, it's just another ordinary @Provides method.

PlatformGreeter: similar diff from GreetingConcatenator, with one interesting detail around the @Singleton annotation: we had to create it ourselves. Scopes in kotlin-inject work the same as scopes in Dagger and Hilt, so instances will live as long as the component that created them, regardless of the scope name. Kotlin-inject doesn't come with any built-in scope annotation, so we have to create our own.

GreetingHandler:

  • There's no need to have a different inject annotation for assisted injection, so we can drop @AssistedInject and keep it annotated with @Inject like any other class. The assisted injected argument still must be annotated with @Assisted, though, just like how it is with Dagger/Hilt. And finally, we can drop the factory interface as it's not necessary with kotlin-inject. Assisted injection becomes significantly simpler!

  • Qualifier annotations are gone again and we can just reference our type aliases instead.

  • The greetingConcatenator is still a Lazy<GreetingConcatenator>, but that's not a dagger.Lazy anymore, but a kotlin.Lazy. It works pretty much the same, but instead of get() it's value, and we can also check if the instance has been initialized with isInitialized().

  • The Provider<PlatformGreeter> becomes a simple () -> PlatformGreeterlambda. Whenever we need a provider, we can just inject a lambda that returns the instance we're interested in. Then, instead of calling get(), we can just invoke the lambda.

We've already covered a lot, and so far all the files changed the same or very similarly on both PRs. The first meaningful difference comes when we're looking at the ApplicationComponent. As we've mentioned before, we don't need it with Hilt and can use the built-in components that come with it. However, we need to explicitly define a component with vanilla Dagger, and that's the same for kotlin-inject. Because of this, it's more interesting to look at Dagger's PR and see what happened with ApplicationComponent there:

  • The interface becomes an abstract class, and both annotations (@Component and @Singleton) are still there. Just like with Dagger, if a component is associated with a scope, we must apply the scope annotation to the component.

  • Instead of referencing the module inside the @Component annotation, we simply implement the interface. With that, everything provided by GreetingModule will be accessible by ApplicationComponent.

  • The component factory is gone and the argument is a straightforward constructor arg. In order to make it part of the graph, we annotate it with @get:Provides (that's the equivalent of Dagger's @BindsInstance).

  • The greeting handler dependency is still exposed there, but now it's an explicit abstract val since we're in an abstract class now, and instead of being the factory that doesn't exist anymore, it's a simple lambda that receives the assisted argument — that's how assisted injection works in kotlin-inject. Instead of dealing with factories, we deal with lambdas just like how we translated Dagger's Provider, but here the lambdas have arguments! 💡 It's a good idea to consider using a typealias to represent the lambda, though, so if the assisted arguments ever change we only need to change it in one place.

With the component in place, we need to create and expose it from our application class GreeterApplication. The only difference from vanilla Dagger here is how the component is created:

Kotlin-inject creates an extension on top of our component's KClass. So instead of having to reference a generated type like we do with Dagger, we just need to reference the generated extension.

Hilt is obviously simpler on these aspects as we don't have to worry about defining and creating a component. However, kotlin-inject is definitely simpler than Dagger, and at the end of the day that's a small price to pay to become multiplatform.

The last difference can be seen in the MainActivity. Kotlin-inject doesn't support member injection — that means whenever we need a dependency and we can't use constructor injection (e.g. in an activity), we'll need to get the dependency directly from the component. Nothing is different here when compared with vanilla Dagger if we're not working with member injection (which is the case in our dagger branch), and the only difference is that instead of calling the assisted injected factory, we're just invoking a lambda:

Here we can actually see a small disadvantage with the lambda approach: we lose named arguments and we can only rely on the order of the arguments when we're dealing with multiple assisted args. That's something Dagger addressed in version 2.32 by requiring a name to be set to assisted args with the same type — something we can't do here. This might change in the future, though, and kotlin-inject's assisted injection might work slightly different in the future (issues about this here and here).

3. No more modules

From here on the commits look the same on both PRs

We kept GreetingModule around with its original name, but the truth is kotlin-inject doesn't have the concept of modules — there are only components.

With Dagger and Hilt we have both modules and components: the first providing dependencies, and the second aggregating them and exposing them to consumers (via direct dependency access or inject() methods for member injection). With kotlin-inject, these responsibilities are merged and done by mighty components:

  • They can have @Provides methods like modules which can be scoped or not.

  • They can have arguments that can either be used to create other dependencies, or included directly in the graph like we did with context(or both).

  • They may or may not have the @Component annotation. Components that are not annotated cannot be created directly, but they can be implemented by other components to be aggregated into them like with our GreetingModule (resembling Dagger-like modules in this case).

  • They may also be composed by receiving other components as arguments — we'll see an example of that in the next commit.

So the third commit is a tiny one where we're just renaming GreetingModuleto GreetingComponent.

4. Handling injection points

Right now our MainActivity is getting its dependency directly from the ApplicationComponent. This is fine in our tiny project, but if we have to list in the ApplicationComponent all dependencies we need to access in all the places we can't use constructor injection, it won't scale well.

A good pattern to work around this is to define a new component per injection point, so the ApplicationComponent can remain lean. This is what this commit is about:

Now ApplicationComponent exposes nothing, and the new MainActivityComponent is the one responsible for exposing what MainActivityneeds. It receives the ApplicationComponent as a @Component annotated argument so it also has access to its dependencies.

5. Moving away from type alias as qualifiers

We've mentioned in the second commit that kotlin-inject disambiguates dependencies through type aliases — they basically replace qualifier annotations.

Even though that mechanism is extremely convenient, there's a point to be made about how this is stretching a bit what type alias means as a language feature, and that we can solve the same problem with inline classes without any help from the library itself.

Type alias as qualifiers in kotlin-inject may be deprecated in the future, so this commit moves away from them by embracing inline classes instead. The result is slightly more verbose, but it's certainly nice to be able to disambiguate dependencies with a language feature independent from the library.

6. Going full multiplatform

We've finally reached the last commit! Here we're going a bit beyond the scope of the article and we're trying to showcase the multiplatform capabilities of kotlin-inject.

  • We're moving all our classes to the shared module. That wouldn't be possible if we still had Dagger or Hilt references there.

  • We're extracting an interface out of PlatformGreeter with the same name, and our previous implementation is now called AndroidPlatformGreeter. The interface will live in the shared module, while the implementation will stay in the Android source set.

  • In order to be able to bind a different PlatformGreeter implementation per platform, we're introducing a new PlatformComponent. This pattern is incredibly powerful, as it allows us to have platform specific behavior without having to rely on expect/actual— you can hear more about this from Kevin Galligan here.

And finally, we're also implementing the iOS part: the IosPlatformGreeter, the UI, and a new ApplicationComponent. Since our ApplicationComponent has an Android context reference, we can't make it multiplatform, so we're creating a dedicated iOS component instead.

We don't have access to the KClass extension to create the component on the iOS side, but we can simply reference the generated class directly and instantiate it:

If we don't want to reference the generated implementation there, it's also easy to work around it by defining an explicit way to create the component in the iOS source set on the Kotlin side.


Wrapping up

That was a long journey, thanks for sticking it out until the end! I hope this clarifies how Dagger and Hilt concepts are translated in kotlin-inject, and helps highlighting kotlin-inject's API. But keep in mind that even though we covered a lot, we still skipped a few interesting points:

👋 I'm on Twitter and on Mastodon, feel free to reach out if I missed anything or if you have any questions.


TL;DR:

  • kotlin-inject expects us to manage components just like vanilla Dagger does, and it has no member injection so we always need to ask for dependencies directly from the component when we can't use constructor injection (which is arguably a good thing).

  • There are no modules in kotlin-inject, only components. They are basically able to do what Dagger modules and components do as a single entity.

  • Multibinding and constructor injection work the same, except with kotlin-inject we can actually annotate the class with @Inject instead of the constructor.

  • Scopes also work exactly the same, so scoped instances will only live as long as the component that created them.

  • The equivalent of Dagger qualifier annotations are type aliases. Bear in mind this might be deprecated eventually in favor of inline classes.

  • The equivalent of dagger.Lazy is kotlin.Lazy, and the equivalent of Provider<Something> is () -> Something.

  • Assisted injection works like the equivalent of the Provider, but the lambda has the assisted arguments (e.g. (String) -> Something), and they also need to be marked with @Assisted. No factory is needed.

  • The equivalent of a Dagger @BindsInstance in an argument of a component factory is a @Provides in an argument of a kotlin-inject component.