KMP for Mobile Native Developer — Part. 3: Dependency Injection in Kotlin Multiplatform (KMP)

Santiago Mattiauda
10 min readApr 2, 2024

--

Dependency Injection

In software development, dependency management is essential. Dependency Injection (DI) is a design pattern that allows application components to receive their dependencies rather than creating them internally. This promotes modularization, code reuse, and facilitates unit testing. In the context of Kotlin Multiplatform (KMP), DI plays a crucial role in ensuring code cohesion and portability across different platforms.

Advantages of Dependency Injection

  • Code Reuse: By decoupling components, code can be reused on different platforms without significant changes.
  • Testability: DI facilitates the writing of unit tests by allowing the substitution of dependencies with mock or dummy objects during testing. (In future articles, we will explain how to simulate objects for testing in KMP)
  • Flexibility: DI allows the incorporation of new functionalities or changes in dependencies without affecting existing code, improving the scalability and maintainability of the project.
  • Clarity and Organization: DI promotes a clearer and more organized code structure by explicitly defining dependencies and their relationships.

While Jetbrains does not provide a dependency injection solution for the Multiplatform ecosystem, it does encourage us to use existing solutions from the community.

Tweet de Jetbrains sobre tip para Inyección de dependencias (link)

Jetbrains tweet about Dependency Injection tip (link)

Implementation of Dependency Injection in KMP

There are several libraries and approaches to implementing DI in Kotlin Multiplatform, each with its own advantages and disadvantages. Some of the common approaches include:

  • Kodein: A purely Kotlin dependency injection library, providing a simple syntax and easy configuration. Kodein is suitable for KMP projects due to its platform-independent nature.
  • Koin: Another popular DI library in Kotlin. Koin offers a declarative syntax and is easy to configure. However, reflection can be an issue on some platforms, like iOS.
  • Manual DI: Even though it can be high cost to implement, manually implementing Dependency Injection is a viable option in KMP. This involves creating instances of dependencies externally and passing them through constructors or methods.

Let’s look in detail at each of the points mentioned above.

Kodein

Kodein is a Dependency Injection (DI) library in Kotlin. It is designed to simplify dependency management in Kotlin applications on various platforms such as Android, iOS, Web and Backend. Its simple syntax and easy configuration make it a popular option for Kotlin Multiplatform (KMP) projects.

Main Features of Kodein

  1. Concise and Declarative Syntax: Kodein uses a concise and declarative syntax, which facilitates the definition and resolution of dependencies in an intuitive way, improving code readability and maintainability.
  2. Platform Independent: As it is purely Kotlin, Kodein is platform independent, allowing its use in KMP projects without having to worry about compatibility with different frameworks or platform-specific libraries.
  3. Support for Kotlin Coroutines: Kodein integrates support for working with Kotlin Coroutines, simplifying dependency management in asynchronous and reactive code.
  4. Modular Configuration: It allows dependencies to be configured modularly, which facilitates organization and maintenance of code in large and complex projects.
  5. Constructor and Property Injection: Kodein supports dependency injection both through constructors and properties, providing flexibility in resolving dependencies in various parts of the application.
  6. Support for Annotations: The library offers useful annotations for highlighting specific components and configurations, improving code clarity and understanding.

Basic Use of Kodein

To start using Kodein in a Kotlin Multiplatform project, you first need to add the Kodein dependency to the project’s configuration file. Then, you can define the dependency modules and register the different components of the application using the Kodein API. Finally, you can resolve dependencies in the different sections of the application as needed.

Below, you will find a simple example of what the code looks like when using Kodein.

// Define a dependency module
val kodeinModule = Kodein.Module("myModule") {
bind<Database>() with singleton { Database() }
bind<UserRepository>() with singleton { UserRepository(instance()) }
}

// Configure Kodein with the defined module
val kodein = Kodein {
import(kodeinModule)
}
// Resolve dependencies in a class
class MyService(private val userRepository: UserRepository) {
// ...
}
// Use the resolved dependencies
val myService = kodein.instance<MyService>()

In this example, a dependency module is defined that provides an implementation of a database and a user repository. Then, Kodein is configured with this module and an instance of MyService, which depends on the user repository, is resolved.

Koin

Koin is a Dependency Injection (DI) library specifically designed for Kotlin. It provides a simple and lightweight solution for handling dependencies in Kotlin applications across various platforms, including Android, backend, and more recently, iOS. Koin stands out for its ease of use, focusing on simplicity and code readability.

Main Features of Koin

  1. Non-Intrusive and Function-Based: Koin is based on pure Kotlin functions, which means it does not require any changes in the existing architecture of the application. It integrates easily with existing code without the need to modify classes or add additional annotations.
  2. Declarative Syntax: Koin uses a declarative and DSL-oriented (Domain Specific Language) syntax that facilitates the definition and resolution of dependencies. This makes the code more readable and easy to understand for developers.
  3. No Reflection: Unlike some other DI libraries, Koin does not rely on reflection, which means it is more efficient in terms of performance and can be used in environments where reflection is restricted, such as in iOS development with Kotlin/Native.
  4. Constructor and Property Injection: Koin supports dependency injection both by constructor and by property, providing flexibility in how dependencies are resolved in different parts of the application.
  5. Scope Management: Koin offers support for scope management (scopes), which allows defining the lifecycle of components and controlling the creation and destruction of instances more precisely.
  6. Easy Integration with Frameworks: Koin easily integrates with different Kotlin frameworks and libraries, making it compatible with a wide variety of projects and technologies.

Basic Use of Koin

To use Koin in a Kotlin Multiplatform project, you must first add the Koin dependency in the project configuration file. Then, you can define the dependency modules and register the various components of the application using the API provided by Koin. Finally, you can resolve dependencies in the different sections of the application as needed.

Below is a simple example of how the code would be used with Koin:

// Define a dependency module
val myModule = module {
single { Database() }
single { UserRepository(get()) }
factory { MyViewModel(get()) }
}

// Configure Koin with the defined module
startKoin {
modules(myModule)
}
// Resolve dependencies in a class
class MyActivity : AppCompatActivity() {
private val viewModel: MyViewModel by viewModel()
// ...
}

In this example, a dependency module is defined that provides an implementation of a database, a user repository, and a ViewModel. Then, Koin is configured with this module and an instance of MyViewModel is resolved, which depends on the user repository.

Manual Dependency Injection in KMP

Manual Dependency Injection (DI) is an alternative approach to handling dependencies in Kotlin Multiplatform (KMP) projects, without the need for external libraries. Instead of using specialized libraries like Kodein or Koin, manual DI involves the creation and manual injection of dependencies into the classes that require them.

  • Manual Creation of Dependencies: Instead of delegating the creation of dependency instances to a DI container, these are generated directly in the classes that need them or delegate this responsibility to a ServiceLocator.
@ThreadLocal
object ServiceLocator {

private val API_KEY = BuildConfig.apiKey
private var client: HttpClient? = null
private var movieRepository: MovieRepository? = null

fun provideMoviesViewModel(): MoviesViewModel {
return MoviesViewModel(provideMovieRepository())
}

private fun provideMovieRepository(): MovieRepository {
return movieRepository ?: createMovieRepository()
}

private fun createMovieRepository(): MovieRepository {
val newRepository = MovieRepository(provideHttpClient())
movieRepository = newRepository
return newRepository
}

private fun provideHttpClient(): HttpClient {
return client ?: createHttpClient()
}

private fun createHttpClient(): HttpClient {
val newClient = ktorHttpClient(apiKey = API_KEY)
client = newClient
return newClient
}
}

Where instead of creating the instance where we need it, we could ask our ServiceLocator to create it for us and thus respect IoC.

// Resolver dependencias en una clase
class MyActivity : AppCompatActivity() {
private val viewModel: MoviesViewModel = ServiceLocatore.provideMoviesViewModel()
// ...
}

Let’s look at some principles to keep in mind when manually performing DI.

Basic Principles of Manual DI

  • Injection through Constructors or Methods: Dependencies are passed through the class constructors or initialization methods, ensuring they are explicit and clear in the code.
  • Object-Oriented Approach: Manual DI aligns with the principles of object-oriented programming (OOP), where objects communicate with each other through clear interfaces and explicit dependencies.

Steps to Implement Manual DI

  1. Identify Dependencies: Determine the dependencies that a class requires to function properly. These can include services, repositories, databases or other components.
  2. Create Dependencies: Generate instances of these dependencies at the top level of the application, such as the main class or entry point. This is usually done using the Singleton design pattern or creating instances as needed.
  3. Inject Dependencies: Provide the dependencies to the classes that require them through their constructors or initialization methods. This ensures that the dependencies are explicit and easily interchangeable.
  4. Manage Lifecycle: If necessary, manage the lifecycle of the dependencies to ensure they are properly created and destroyed. This can involve releasing resources or cleaning up memory when dependencies are no longer needed.

Advantages of Manual Dependency Injection

  • Simplicity: By not depending on external libraries, the development process is simplified and the amount of additional code is reduced.
  • Full Control: With manual dependency injection, you have full control over how dependencies are created and managed in your application.
  • Clarity and Transparency: By passing dependencies explicitly through constructors or methods, the flow of dependencies becomes clear and transparent in the code.

Challenges of Manual Dependency Injection in KMP

  • Higher Coupling: Manual dependency injection can result in stronger coupling between classes, as dependencies must be explicitly passed through the code.
  • Manual Maintenance: Managing dependencies manually can be more error-prone and may require additional maintenance effort as the project expands in size and complexity.
  • Code Repetition: In large projects, there may be code repetition when the same dependencies are passed to multiple classes.

Considerations

When considering the implementation of Manual Dependency Injection (DI) in Kotlin Multiplatform (KMP) projects, it is crucial to take into account several aspects to ensure efficient dependency management and a solid architecture throughout the application. Here are some key considerations:

  1. Clear Interface Design: Design clear and well-defined interfaces for your components and dependencies. This will facilitate their instantiation and dependency injection in different areas of the application. At this point, it is recommended to follow the SOLID principles, especially Single Responsibility and Interface Segregation.
  2. Lifecycle Management: Consider the lifecycle of your dependencies and how it relates to that of your application on each platform. It is essential to properly manage the creation and destruction of instances to prevent memory problems or resource leaks.
  3. Minimize Coupling: Try to reduce coupling between your components and dependencies. This can be achieved with flexible interfaces and the use of design patterns like Inversion of Control (IoC) to decouple the creation of dependency instances.
  4. Testability: Ensure that your code is easy to test. Manual DI can facilitate unit tests by allowing the substitution of dependencies with mock objects during testing, similar to what is done with Koin and Kodein but manually.
  5. Scalability and Maintainability: Think about how your manual DI approach will scale as your project grows in size and complexity. Maintain a modular and well-structured approach to facilitate maintenance and the continuous evolution of your application.
  6. Consistency across Platforms: If you are developing a KMP application that will run on multiple platforms, it is essential to maintain consistency in dependency management across all of them. This may require careful design and clear communication between development teams.
  7. Documentation and Communication: Clearly document the structure of your manual DI and communicate recommended patterns and practices to all development team members. This will help ensure a common understanding and maintain consistency throughout the application.
  8. Continuous Evaluation: Lastly, continuously evaluate your manual DI approach as your project evolves. Be open to adjustments and improvements as needed to adapt to changes in requirements and the development environment.

Taking into account these key aspects, you will be able to successfully implement Manual Dependency Injection in your Kotlin Multiplatform projects, ensuring efficient dependency management and a robust architecture throughout the application.

Off Topic: Creating our own dependency injection framework.

As the title of this section indicates, it is an off-topic subject, but it could be a valid alternative if we do not want to rely on external resources for dependency injection and want to mitigate one of the aspects of manual injection, which is Code Repetition. I won’t delve too much into this topic, but I didn’t want to conclude this article without recommending the following article on the matter.

Summary of the alternatives presented in this article

Koin is a simple library that helps to add Dependency Injection in Kotlin applications. It is easy to use and efficient, highly appreciated by developers. It works well with different platforms and is regularly updated, making it reliable for Multiplatform Kotlin projects.

Kodein is a solid library that facilitates dependency management in Multiplatform Kotlin projects. It has a clear syntax, works on different platforms, and supports advanced features like Kotlin Coroutines. It is popular among developers who need an efficient and flexible DI solution. Thanks to its active community and constant updates, Kodein is another reliable option for DI in Kotlin.

Manual Dependency Injection is an option for Multiplatform Kotlin projects that want to avoid using external libraries and maintain full control over how dependencies are handled in the app. Although it may require more work and maintenance, it can be a good solution for small projects or for those who prefer a simpler and more direct approach to managing dependencies. But it’s important to think carefully about the project’s requirements and consider the benefits and challenges of manual DI before deciding to use it in a KMP project.

Conclusions

Dependency Injection is an invaluable technique in modern software development, particularly in the context of Multiplatform Kotlin. By incorporating Dependency Injection into KMP projects, developers can generate more modular, checkable, and portable code. This contributes to the creation of more robust and maintainable applications across various platforms. Given the variety of libraries and approaches available, it is essential to select the Dependency Injection solution that best fits the specific needs of the project and the limitations of the target platforms.

If you’re starting out and still unclear about the concept of Dependency Injection, I would recommend that you start with a manual alternative. Then, you can adopt a library like Koin or Kodein to reinforce the knowledge acquired.

Referencias

--

--