9.
Dependency Injection
Written by Saeed Taheri
Putting unicellular organisms aside, nearly everything in the world depends on other entities to function. Whether it’s something in nature or something mankind has created, it usually takes multiple things to create a working instance of anything.
Imagine an assembly line in a car factory. They don’t create the engines and the wheels on the assembly line. Car manufacturers outsource many of the parts to other companies. In the end, they bring them all to the assembly line, inject each part into the making-in-progress and a shiny new car appears. The car is dependent on other objects. The same applies to the software world.
If you were to model the Car into a class, one of its dependencies would be the Engine. The car object shouldn’t be responsible for creating the engine. You should inject the engine from outside into the assembly line — or in programming nomenclature, constructor, or initializer.
Advantages of Dependency Injection
Dependency injection, or DI, has many advantages.
- Maintainability: DI makes your code maintainable. If your classes are loosely coupled, you can catch bugs more easily and address a possible issue faster than you would with a convoluted class that doesn’t adhere to the single-responsibility principle.
- Reusability: Going back to the car factory example, you’re able to reuse the same model of wheels for many cars the factory manufactures. Loosely coupled code will let you reuse many parts of your code in different ways.
- Ease of refactoring: There may come a time in the lifetime of your app when you need to apply a change to your codebase. The less coupled your classes are, the easier the process will be. Imagine you needed to change the engine if you wanted to have new headlights!
- Testability: Everything comes back to the code being loosely coupled. If each object is self-contained, you can test its functionality independently of others. No one would like a car whose engine wouldn’t work when a windshield wiper is broken! This way, each team responsible for each module will test their product and hand it over to other teams.
- Ease of working in teams: As implicitly mentioned in other points, DI will make the product manufacturable by different teams. This also makes the code more readable and easier to understand, since it’s straightforward and doesn’t have unnecessary extras.
Automated DI vs. Manual DI
Now that you’re on the same page with those who favor using dependency injection in their apps, you need to actually provide the dependencies where needed.
Open the starter project in Android Studio. Next, open RemindersViewModel.kt from the presentation directory in commonMain. You’ll take on the responsibility of creating the repository instance outside RemindersViewModel.
Remove the repository definition and pass it in via the constructor like this:
class RemindersViewModel(
private val repository: RemindersRepository
) : BaseViewModel() { // ...
}
Build the project by going to the Build menu and clicking Make Project. You’ll immediately see there are compile issues in both RemindersView.kt files on Android and desktop. The same error is also there for RemindersView.swift, which Android Studio can’t catch.
Note: One implementation which won’t show up in the output above, but still needs to be updated, is the
viewModeldefinition in RemindersViewModelTest.kt. PassRemindersRepository()where you initialize an instance ofRemindersViewModelclass.
You’ll have to go to each of these files and provide an instance of RemindersRepository. What if the repository has its own dependencies? And what if those dependencies have their dependencies as well? This is a rabbit hole you want to avoid getting into!
You can provide all the dependencies yourself and no one can prevent you from doing so. Off the record, iOS developers usually do all this and write all the boilerplates by themselves, since there’s not a popular library or methodology that everyone agrees on.
However, in the Android world, some libraries solve this problem by automating the process of creating and providing dependencies. They fall into two categories:
- Static solutions that generate the dependency graph at compile time.
- Solutions that connect the dependencies at runtime.
The most famous library for the first category is Hilt. Google recommends Hilt as part of their app architecture suggestions.
The catch is that neither Hilt nor its biological parent Dagger is available for KMP. Thus, the approach you can take is to do manual DI or use the most famous library of the second category: Koin.
Many would call libraries like Koin — which resolve dependencies at runtime — Service Locators. Those who favor static DI libraries will seriously object if you call Koin a DI library. However, here you’re free to call it whatever you like.
Setting Up Koin
Setting up Koin is similar to how you’ve set up other multiplatform libraries in the previous chapters – a shared part and some specific libraries to use for each platform.
Open libs.versions.toml inside the gradle directory in the root of your project and add the Koin version in the [versions] section. As of writing this chapter, the latest version of Koin is 3.4.3.
koin = "3.4.3"
Next, in the [libraries] section, add these entries:
koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" }
koin-test = { group = "io.insert-koin", name = "koin-test", version.ref = "koin" }
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version = "3.4.6" }
Thereafter, open build.gradle.kts for the shared module.
Add a dependency for commonMain source set as follows:
implementation(libs.koin.core)
While you’re here, add a test dependency to commonTest as well. You’re going to need it later in the chapter.
implementation(libs.koin.test)
Next, open build.gradle.kts for the androidApp and add these two dependencies. The second one is necessary because the app is using Jetpack Compose.
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
Last but not least, open build.gradle.kts for the desktopApp and add this dependency to jvmMain:
implementation(libs.koin.core)
Make sure to sync Gradle after adding all these dependencies.
Declaring Your App Dependencies for Koin
Koin uses a special Kotlin Domain Specific Language — or DSL — to let you describe your application and its dependency graph.
There are three steps to start using Koin:
- Declare your modules: Modules are entities that Koin later injects into different parts of your app as needed. You can have as many modules as you want.
-
Start Koin: A single call to
startKoinfunction, passing in the modules in your app, will make a Koin instance ready to do the injection job in your app. - Perform the injection: Using some special keywords provided by Koin lets you inject object instances at will.
Inside the shared module, in the commonMain directory, create a sibling file to Platform.kt named KoinCommon.kt. You’re going to write Koin setup codes there.
First, create an object in which you can hold a reference to the modules.
package com.yourcompany.organize
object Modules {
val repositories = module {
factory { RemindersRepository() }
}
}
Define a module using the module block. A factory is a definition that will give you a new instance each time you ask for this object type. If you want to have a single instance or a singleton across the lifetime of your app, use the single keyword. It’s most suited for things like databases and network managers.
Second, add a constant for the ViewModel’s module inside the Modules object.
val viewModels = module {
factory { RemindersViewModel(get()) }
}
The new kid in town is the get() function. It’s a generic function that will resolve a component dependency. When you use this function, Koin looks up the declaration you provided and finds a matching call. As you remember, RemindersViewModel needs an instance of a RemindersRepository in its constructor, and you just defined it as a module.
So, Koin is good to go! Bear in mind that Koin resolves this dependency at runtime. Hence, if you use get() without a matching declaration, your app will most likely crash.
Finally, create a global function below Modules, which you’ll call from each platform.
fun initKoin(
appModule: Module = module { },
repositoriesModule: Module = Modules.repositories,
viewModelsModule: Module = Modules.viewModels,
): KoinApplication = startKoin {
modules(
appModule,
repositoriesModule,
viewModelsModule
)
}
This function takes three parameters.
The first one is the appModule. You can use this in later chapters for injecting app-level dependencies. Since those dependencies come from each platform, you’re making the ability to pass them from outside.
The second and third parameters are for repositories and viewModels with default values. You’ll see later on that you need to pass those in certain scenarios.
The return type of initKoin is an instance of KoinApplication. You get an instance of this type by calling startKoin, passing in all the modules you defined. This is the Koin starting point and the glue that keeps everything together.
Import all the missing dependencies as follows:
import com.yourcompany.organize.data.RemindersRepository
import com.yourcompany.organize.presentation.RemindersViewModel
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.dsl.module
Using Koin on Each Platform
Now that you’ve got all the pieces, it’s time to use Koin for real.
Android
Open OrganizeApp.kt in the androidApp module. Add the onCreate function below inside the class and start Koin there.
override fun onCreate() {
super.onCreate()
initKoin(
viewModelsModule = module {
viewModel {
RemindersViewModel(get())
}
}
)
}
Call the initKoin method you defined earlier. You’re using the viewModel block, which comes from org.koin.androidx.viewmodel.dsl.viewModel package, to declare an Android viewModel. The difference between Android viewModels and others is that they will live through the Android configuration changes, such as device rotation. This needs a special kind of initialization, which Koin for Android does for you.
Open RemindersView.kt in the androidApp module and replace the RemindersView function definition as follows:
@Composable
fun RemindersView(
viewModel: RemindersViewModel = getViewModel(),
onAboutButtonClick: () -> Unit,
) {
// ...
}
Calling the getViewModel() extension function, which Koin provides, does all the creation and injection process.
Build and run the Android app. The app should behave as you’re familiar with — this time with DI, though.
iOS
Koin is a Kotlin library. Lots of bridging occurs, should you want to use it with Swift and Objective-C files and classes. To make things easier, you’d better create some helper classes and functions.
Create KoinIOS.kt inside the iosMain directory as a sibling to Platform.kt.
Create a function inside an object for initializing Koin on iOS. Swift doesn’t bridge Kotlin functions with default parameters. This function is to compensate for that limitation.
package com.yourcompany.organize
object KoinIOS {
fun initialize(): KoinApplication = initKoin()
}
Next, create an extension function on Koin for getting instances of a specific Objective-C class. Remember that extension functions need to be in the top-level file. So make sure this function is outside the KoinIOS object you just created. Unfortunately, there’s no easy way to write them as generic functions, and some type-casting will be necessary at the call site.
@kotlinx.cinterop.BetaInteropApi
fun Koin.get(objCClass: ObjCClass): Any {
val kClazz = getOriginalKotlinClass(objCClass)!!
return get(kClazz, null, null)
}
Here, you’re passing null for qualifier and parameter. If you find yourself in need of passing parameters when asking for a dependency, you could add this extension function as well:
@kotlinx.cinterop.BetaInteropApi
fun Koin.get(objCClass: ObjCClass, qualifier: Qualifier?, parameter: Any): Any {
val kClazz = getOriginalKotlinClass(objCClass)!!
return get(kClazz, qualifier) { parametersOf(parameter) }
}
As some types and functions you used in these extensions are in beta, you need to opt in by annotating the functions with @kotlinx.cinterop.BetaInteropApi.
Next, open Xcode, create Koin.swift inside the Supporting Files directory and write the class as follows:
import Shared
final class Koin {
//1
private var core: Koin_coreKoin?
//2
static let instance = Koin()
//3
static func start() {
if instance.core == nil {
let app = KoinIOS.shared.initialize()
instance.core = app.koin
}
if instance.core == nil {
fatalError("Can't initialize Koin.")
}
}
//4
private init() {
}
//5
func get<T: AnyObject>() -> T {
guard let core else {
fatalError("You should call `start()` before using \(#function)")
}
guard let result = core.get(objCClass: T.self) as? T else {
fatalError("Koin can't provide an instance of type: \(T.self)")
}
return result
}
}
- Store a reference to the Koin core type. This will make it possible to ask for objects.
- Create a static property for the newly created class to use it as a singleton.
- Call this function when the app starts. Here, you’re calling into Kotlin to initialize Koin.
KoinIOS.sharedis the way Kotlin exposes theobjectyou created earlier. If for any reason this procedure fails, you’ll make the app crash. - Mark the initializer for this class as private. This will prevent people from accidentally initializing the Swift
Koinclass apart from the way you intended. - This method uses the
getextension methods you wrote on Koin in Kotlin. It first checks ifcoreisn’tnil. Then it tries casting fromAnyto the generic typeT. This will make this function type-safe at the call site.
Two steps remain for using Koin on iOS.
First, open iOSApp.swift and start Koin at initialization time.
@main
struct iOSApp: App {
init() {
Koin.start()
}
// ...
}
Finally, open RemindersViewModelWrapper.swift and initialize viewModel as follows:
let viewModel: RemindersViewModel = Koin.instance.get()
Build and run. The app will work as before.
Desktop
This is the easiest of all platforms. First, open Main.kt and add a reference to the Koin object. Initialize it in the main function.
lateinit var koin: Koin
private set
fun main() {
koin = initKoin().koin
return application { // ... }
//
Next, open RemindersView.kt in the desktopApp module, and change the RemindersView composable function definition as follows:
@Composable
fun RemindersView(
viewModel: RemindersViewModel = koin.get(),
onAboutButtonClick: () -> Unit,
) {
// ...
}
Use the koin instance you created and take advantage of the get() function.
Build and run — enjoy!
Updating AboutViewModel
You’re now familiar with the process, it’s time to update AboutViewModel to use DI. Put the book down and see if you can do it all by yourself.
Here’s what you should do:
Open AboutViewModel.kt from the commonMain/presentation directory and move the platform definition to the constructor as follows:
class AboutViewModel(
platform: Platform
) : BaseViewModel() {
// ...
}
Next, open KoinCommon.kt in the commonMain directory and add another factory block to the viewModels module.
val viewModels = module {
factory { RemindersViewModel(get()) }
factory { AboutViewModel(get()) }
}
Declare to Koin how to resolve the dependency of the AboutViewModel class, which is of type Platform. Create a constant named core inside the Modules object to hold this and some other dependencies, which will come in a later chapter.
object Modules {
val core = module {
factory { Platform() }
}
// ...
}
Last but not least in this file, update the definition of initKoin to accept the new modules you defined:
fun initKoin(
appModule: Module = module { },
coreModule: Module = Modules.core,
repositoriesModule: Module = Modules.repositories,
viewModelsModule: Module = Modules.viewModels,
): KoinApplication = startKoin {
modules(
appModule,
coreModule,
repositoriesModule,
viewModelsModule,
)
}
Next, to follow the usual approach, you’ll update the codes for the platforms in order.
Android
Open AboutView.kt in the androidApp module and change the AboutView composable definition to this:
fun AboutView(
viewModel: AboutViewModel = getViewModel(),
onUpButtonClick: () -> Unit
) {
// ...
}
You’re once again using the getViewModel() method from the Koin Android library.
Finally, open OrganizeApp.kt for the Android app and declare the module for AboutViewModel as well:
initKoin(
viewModelsModule = module {
viewModel {
RemindersViewModel(get())
}
viewModel {
AboutViewModel(get())
}
}
)
Build and run the app to make sure everything is still working as expected.
iOS
It’s pretty straightforward. Open AboutView.swift and change the definition of viewModel as follows:
@State private var viewModel: AboutViewModel = Koin.instance.get()
And that’s it! Build and run the iOS app. Open the About page to ensure DI is working correctly.
Desktop
This is also a piece of cake. Open AboutView.kt in desktopApp module and change the AboutView composable function definition:
fun AboutView(
viewModel: AboutViewModel = koin.get()
) {
ContentView(items = viewModel.items)
}
That concludes integrating Koin in the viewModels of all three apps.
Build and run and confirm the app is behaving as before.
Testing
Because of the changes you made in this chapter, the tests you wrote in the previous chapter wouldn’t compile anymore. Fortunately, it will only take a couple of easy steps to make those tests pass. You’ll also learn a few more tricks for testing your code along the way.
Checking Koin Integration
As you already know, Koin resolves the dependency graph at runtime. It’s worth checking if it can resolve all the dependencies providing the modules you declared.
In the commonTest directory, create a file called DITest.kt as a sibling to PlatformTest.kt.
Create a class named DITest, and add this test function inside it:
package com.yourcompany.organize
class DITest {
@Test
fun testAllModules() {
koinApplication {
modules(
Modules.viewModels,
)
}.checkModules()
}
}
Here, you’re creating an instance of KoinApplication and providing a list of modules. For now, add the viewModels module. checkModules() is a function that does the integration check you read about earlier. It verifies all definitions’ dependencies, starts all modules and then checks if definitions can run.
Run the test and check out the result.
The test failed, and the reason is pretty obvious. By only providing the viewModels module, Koin can’t create an instance of RemindersViewModel or AboutViewModel. To fix this, just add Modules.repositories and Modules.core to the list of modules in the test function above. Therefore, you’ll be passing these modules:
modules(
Modules.core,
Modules.repositories,
Modules.viewModels,
)
Run the test again, and it will pass successfully.
Note: The test only passes on desktop and iOS. It fails on Android. The reason for that lies in the creation process of
ScreenInfo. If you take a look at the actual Android implementation of that class, you’ll see that it callsResources.getSystem(). When running tests, this call would fail since the Android system isn’t available while unit testing. Resolving this issue needs mocking theResourcesclass, which is beyond the scope of this chapter. You saw a similar issue in the previous chapter as well.
A good citizen doesn’t litter, and neither does a good developer when testing Koin. Whenever you create an instance of KoinApplication or call startKoin, make sure to stop it after you don’t need it anymore. As you know, a good place to do so is to create a function with the @AfterTest annotation.
Add this function to DITest class, which uses the Koin-provided stopKoin method to do the cleanup.
@AfterTest
fun tearDown() {
stopKoin()
}
Updating RemindersViewModelTest
Open RemindersViewModelTest.kt. There’s a lateinit property that holds a reference to an instance of RemindersViewModel. In the setup method, you’re initializing this property like this:
viewModel = RemindersViewModel(RemindersRepository())
Yuck! No one likes this anymore. It’s better to summon Koin to do the job!
There are a few steps you need to take to integrate Koin into tests.
First, make RemindersViewModelTest extend KoinTest. This conformance will make the test class a KoinComponent. The KoinComponent interface is here to help you retrieve instances directly from Koin using some special keywords.
Next, change the viewModel property definition to this:
class RemindersViewModelTest: KoinTest {
private val viewModel: RemindersViewModel by inject()
//...
}
The by keyword in Kotlin delegates the implementation of the accessors for a property to another object. By using by inject(), Koin will lazily retrieve your instances from the dependency graph. The inject() function is an extension to KoinTest.
The last piece is to initialize Koin before the test and stop it after the test — pretty much like when you tested the Koin integrity.
Implement these two functions in the test class:
@BeforeTest
fun setup() {
initKoin()
}
@AfterTest
fun tearDown() {
stopKoin()
}
In the setup method, you don’t need to initialize the viewModel property yourself anymore. You just call the initKoin method, which you called at the application’s launch. It provides the default values for the modules. The tearDown function is exactly the same as in the DITest class.
Run the tests for the RemindersViewModelTest class, and they will pass as they used to do.
Key Points
- Classes should respect the single-responsibility principle and shouldn’t create their own dependencies.
- Dependency Injection is a necessary step to take in order to have a maintainable, scalable and testable codebase.
- You can inject dependencies into classes manually, or use a library to do all the boilerplate codes for you.
- Koin is a popular and declarative library for DI, and it supports Kotlin Multiplatform.
- You declare the dependencies as modules, and Koin resolves them at runtime when needed.
- When testing your code, you can take advantage of Koin to do the injections.
- You can test if Koin can resolve the dependencies at runtime using the
checkModulesmethod. - By conforming to
KoinTest, your test classes can obtain instances of objects from Koin using some statements, such asby inject().
Where to Go From Here?
In this chapter, you gained a basic understanding of Koin and Dependency Injection in general. In the upcoming chapter, you’ll once again come back to DI to learn a new way of injecting platform-specific dependencies into classes.
To learn more about Koin in particular, the best source may be the official documentation at https://insert-koin.io/docs/reference/introduction, which is rather concise and self-explanatory, while covering numerous scenarios.
As mentioned in the chapter, there are other DI libraries as well. However, either those don’t work in Multiplatform scenarios, or they’re not as popular as Koin.
If you aren’t targeting Multiplatform, you can consult Hilt: https://developer.android.com/training/dependency-injection/hilt-android and Dagger: https://dagger.dev on Android. In the iOS world, there isn’t a go-to library.
For testing in particular, Koin also provides you with a way to mock or stub different objects.