In the fast-evolving world of cross-platform development with Kotlin Multiplatform, choosing the right dependency-injection library can have a great impact on your project. We reveal the strengths and weaknesses of Koin and Kotlin-inject, so you can find the best match for your KMP needs.
As Kotlin Multiplatform is becoming an increasingly popular choice for cross-platform development, developers face the task of selecting a suitable dependency-injection library for their KMP projects. We’re here to introduce Koin and Kotlin-inject, two libraries that take different approaches to solving this problem.
In this article we will present both libraries, exploring their features and implementation. We’ll do a head-to-head comparison, shedding light on their strengths and weaknesses. So let’s dive in and help you find the best fit for your KMP needs.
If you want to explore more Kotlin-based libraries, you can also check our blog post on JsonApiX.
Our example project
In order to better compare Koin and Kotlin-inject, we’ve come up with a simple example project. It includes a WeatherApiService
responsible for providing WeatherData
, along with domain components like Interactor, Repository, and UseCase, used for propagating and mapping data:
// WeatherApiService.kt
interface WeatherApiService {
@GET("weather")
suspend fun getWeatherData(@Query("city") city: String): WeatherData
}
// GetWeatherDataInteractor.kt
class GetWeatherDataInteractor(private val weatherApiService: WeatherApiService) : Interactors.GetWeatherData {
override suspend operator fun invoke(city: String): WeatherData {
return weatherApiService.getWeatherData(city)
}
}
// WeatherRepository.kt
class WeatherRepository(private val getWeatherInteractor: Interactors.GetWeatherData) : Repositories.Weather {
override suspend fun fetch(city: String): Weather {
return getWeatherInteractor(city).mapToLocal()
}
}
// GetWeatherUseCase.kt
class GetWeatherUseCase(private val weatherRepository: Repositories.Weather) : UseCases.GetWeather {
override suspend operator fun invoke(city: String): Weather {
return weatherRepository.fetch(city)
}
}
We want our example to cover the differences in implementation, so let’s say WeatherApiService
and WeatherRepository
are intended to be singletons. This is what our final dependency structure looks like:
Koin
Let’s start by diving into the Koin library. Here are some of its key features:
- Kotlin-based dependency injection library that offers a user-friendly experience through its easy-to-read Kotlin DSL
- Suitable for any Kotlin application, including Android, Multiplatform, or Backend development
- Easily integrates with the ViewModel, Ktor, and Compose, which adds to Koin’s appeal across diverse developer domains
But let’s shift from theory to practice and test this library in action on our example project:
// ServicesModule.kt
val servicesModule = module {
single {
WeatherServiceProvider.provide()
}
}
// InteractorsModule.kt
val interactorsModule = module {
includes(servicesModule)
factory<Interactors.GetWeatherData> {
GetWeatherDataInteractor(get())
}
}
// RepositoriesModule.kt
val repositoriesModule = module {
includes(interactorsModule)
single<Repositories.Weather> {
WeatherRepository(get())
}
}
// UseCasesModule.kt
val useCasesModule = module {
includes(repositoriesModule)
factory<UseCases.GetWeather> {
GetWeatherUseCase(get())
}
}
Koin’s DSL, while remarkably straightforward, presents significant distinctions from Dagger or Hilt API. It primarily consists of module
functions, which can be linked together using includes
function:
val repositoriesModule = module {
includes(interactorsModule)
...
}
val useCasesModule = module {
includes(repositoriesModule)
...
}
This way, useCasesModule
includes all of the dependencies provided inside the repositoriesModule
, which includes all of the dependencies provided by interactorsModule
and so on.
The factory
function binds implementations to interfaces and the function get()
helps Koin retrieve injected dependencies during runtime. In the code snippet below, we’re instructing Koin on how to build a UseCases.GetWeather
instance. It uses the get()
function to obtain the necessary Repositories.Weather
dependency that GetWeatherUseCase
requires:
factory<UseCases.GetWeather> {
GetWeatherUseCase(get())
}
For WeatherApiService
and WeatherRepository
instances intended to be singletons, the single
function is used:
single<Repositories.Weather> {
WeatherRepository(get())
}
To use Koin, we first need to initialize it by calling the startKoin
function and providing a parent module, in this case, the useCasesModule
:
val exampleKoin = startKoin { modules(useCasesModule) }.koin
val weather = exampleKoin.get<UseCases.GetWeather>().invoke(cityName)
That’s it; Koin is initialized and ready for use.
Kotlin-inject
Now that we have a clearer understanding of how Koin works, let’s see how Kotlin-inject compares. Some of its key features are:
- Relatively new and simple Kotlin-based dependency-injection library
- Powered by Kotlin’s robust features like KSP and lazy initialization, which simplify the process of providing dependencies
- With the use of Kotlin typealias, it allows injecting multiple instances of the same type and even supports lambda injection
Let’s roll up our sleeves again and put this library to work on our example project:
// ServicesComponent.kt
interface ServicesComponent {
@ExampleScope
@Provides
protected fun provideWeatherApiService(): WeatherApiService =
WeatherServiceProvider.provide()
}
// InteractorsComponent.kt
interface InteractorsComponent : ServicesComponent {
@Provides
protected fun GetWeatherDataInteractor.bind(): Interactors.GetWeatherData = this
}
// RepositoriesComponent.kt
interface RepositoriesComponent : InteractorsComponent {
@ExampleScope
@Provides
protected fun WeatherRepository.bind(): Repositories.Weather = this
}
// UseCasesComponent.kt
interface UseCasesComponent : RepositoriesComponent {
@Provides
protected fun GetWeatherUseCase.bind(): UseCases.GetWeather = this
}
// ExampleComponent.kt
@ExampleScope
@Component
abstract class ExampleComponent : UseCasesComponent {
abstract val getWeatherUseCase: UseCases.GetWeather
}
Kotlin-inject relies on annotations to define scopes and functions that contribute to the dependency graph. In the example above, we can notice a more Dagger-like API, unlike Koin DSL, which bears less resemblance.
Additionally, we can notice one major difference – there are no modules. Kotlin-inject operates using components, with the parent component ExampleComponent
being an abstract class with @Component
annotation and its own @ExampleScope
:
@Scope
@Target(CLASS, FUNCTION, PROPERTY_GETTER)
annotation class ExampleScope
The components rely on the interfaces they inherit to provide the necessary dependencies, which is called component inheritance:
interface RepositoriesComponent : InteractorsComponent {
// provide repository dependencies
}
interface UseCasesComponent : RepositoriesComponent {
// provide usecase dependencies
}
@ExampleScope
@Component
abstract class ExampleComponent : UseCasesComponent {
...
}
ExampleComponent
implements UseCasesComponent
, which provides the necessary dependencies while also relying on RepositoriesComponent
for its dependencies and so on.
To bind implementations, a familiar @Provides
annotation is used combined with the extension function bind
:
@ExampleScope
@Provides
fun WeatherRepository.bind(): Repositories.Weather = this
WeatherRepository
and WeatherApiService
are scoped as singletons using the already mentioned @ExampleScope
annotation. The instance of these components will live as long as the ExampleComponent
does.
Finally, ExampleComponent
is crafted using the create
extension function and is ready for use:
val exampleComponent = ExampleComponent::class.create()
val weather = exampleComponent.getWeatherUseCase(cityName)
With the differences in implementation covered, we can now compare the two libraries based on some common criteria.
Koin vs. Kotlin-inject – a head-to-head comparison
Let’s size up these two libraries in some key categories that affect your development as well as the user experience. This will allow you to get a clearer picture of where each library shines and where its weaker points are.
Build time
First, we’re going to take a look at how these two libraries stack up against each other when it comes to build time. A key difference between Koin and Kotlin-inject lies in their mechanics:
The Koin approach:
Koin functions similarly to a service locator pattern, creating and providing the dependencies we want in runtime when they are needed. This translates to less boilerplate code generated during compile-time.
The Kotlin-inject approach:
Kotlin-inject acts as a code generator, creating the necessary boilerplate for effective dependency injection based on the setup and annotations you provide. Similarly to Dagger and Hilt, this results in a significant amount of code generated during compile-time.
Due to reduced compile-time code generation, using Koin typically leads to quicker builds. Notably, the difference in build time becomes more significant on larger projects, amplifying Koin’s advantage.
Runtime performance
Moving on with our agenda, we’ll compare the startup and injection performance of the two libraries. The results presented are based on the median of 100 iterations in our example project setup:
// Koin
// Startup time: 0.2475 ms
val exampleKoin = startKoin { modules(useCasesModule) }.koin
// Injection time: 0.0137 ms
val weatherUseCase = exampleKoin.get<UseCases.GetWeather>()
// Kotlin-inject
// Startup time: 0.0005 ms
val exampleComponent = ExampleComponent::class.create()
// Injection time: 0.0043 ms
val weatherUseCase = exampleComponent.getWeatherUseCase
In summary, while Koin offers quicker build times, it does come with a trade-off. The runtime dependency resolution impacts the overall performance, causing Koin to lag significantly behind Kotlin-inject in this regard. The difference is most noticeable during startup. Resolving the Koin dependency graph requires significantly more time compared to creating a Kotlin-inject component, which benefits from pre-generated boilerplate code.
Debugging
Now, let’s delve into the libraries’ debugging capabilities. Both libraries excel in providing clear and informative error descriptions. However, Kotlin-inject has the advantage of showing them at compile-time, which allows you to correct your mistakes without ever running the application. This is not only a more convenient but also a much safer approach. On the other hand, Koin addresses this with somewhat of a workaround – writing tests to validate your Koin setup:
class CheckKoinModulesTest {
@Test
fun checkUseCasesModule() {
checkKoinModules(listOf(useCasesModule))
}
}
Writing these tests does provide you with the verification of DI structure, but still requires you to run them quite often, which is less practical compared to Kotlin-inject compile-time error detection.
Community
To wrap things up, we should mention the communities behind these two libraries, since they are the ones responsible for their respective future development.
Koin has an active community of contributors ensuring regular updates and enhancements. After all, they’ve paved the way for smooth integrations with Ktor, Compose, ViewModel, and KMP and will continue to provide support for future technologies. Alongside regular updates and initiatives, Koin’s engaged user community helps spot and resolve issues quickly. On the other hand, Kotlin-inject is still establishing its presence with a smaller contributor base. However, it’s gaining traction within the Kotlin development scene, hinting at a bright future for the community.
Koin or Kotlin-inject? You choose.
As would be expected, both Koin and Kotlin-inject come with their own set of strengths and weaknesses.
If you prefer a simpler DSL, want to benefit from faster build times, seek integration with various technologies like Ktor, Compose, Android, KMP, and value a strong community support, Koin is a solid choice.
On the other hand, if you prefer a Dagger-like API, prioritize compile-time safety and runtime performance, want to use Kotlin’s powerful features, and are comfortable with fewer integrations by a smaller, but growing community, Kotlin-inject is the library for you.
Clearly, there’s no universal answer when it comes to choosing between these two libraries. However, if you take a close look at the needs of your project and take into consideration your own skills and preferences, you’ll be able to decide which of the two is the best fit.