Koin vs. Kotlin-inject – Which to Choose and Why?

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:

dependency structure on our example Kotlin Multiplatform project, the basis for Koin – Kotlin-inject comparison

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.