In the previous article, we reviewed the basic principles of implementing DI through the Koin framework. Now we will look at more complex aspects of working with Koin, such as annotations, injection scopes, and so on.
Context isolation
When developing a library or some code that can be extended by other developers, you may need to separate the areas of dependencies, as they may conflict with each other. To avoid dependency conflicts, we can isolate the context.
First, we need to declare an instance of Koin and save it in a class for further access:
object IsolatedContext {
val koinApp = koinApplication {
modules(module1, module2, ...)
}
val koin = koinApp.koin
}Now, we can use IsolatedContext to define a custom KoinComponent that will use our isolated context:
abstract class IsolatedKoinComponent : KoinComponent {
override fun getKoin(): Koin = IsolatedContext.koin
}Finally, we can work within an isolated context:
class SomeKoinComponent : IsolatedKoinComponent()Scopes
The scope is a fixed period of time during which the state of an object is preserved. Once this gap expires, any objects bound under that scope cannot be injected again. In Koin, we have three kind of scopes by default:
The
singledefinition exists for the entire lifetime of the container and cannot be removed from it.The
factorydefinition creates objects with a short lifetime, and the factory is not stored inside the container.The
scopeddefinition creates an object whose lifetime lasts as long as the scope exists.
To declare a scope for a given type, we use the scope function, which gathers scoped definitions as a logical unit of time:
module {
scope<SomeType>{
scoped { Controller() }
}
}To move an instance of scope to a class, we can use KoinScopeComponent. You can use createScope to create scope from the current component's scope ID and name:
class OuterScopeComponent : KoinScopeComponent {
override val scope: Scope by lazy { createScope(this) }
}
class InnerScopeComponentLet's define a scope for OuterScopeComponent, to resolve InnerScopeComponent. That means that the lifetime of the InnerScopeComponent will be tied to the lifetime of the OuterScopeComponent:
module {
scope<OuterScopeComponent> {
scoped { InnerScopeComponent() }
}
}We can use an instance of InnerScopeComponent in this way:
class OuterScopeComponent : KoinScopeComponent {
override val scope: Scope by lazy { newScope(this) }
val outer : OuterScopeComponent by inject()
fun close(){
scope.close() // don't forget to close current scope
}
}Once your scope instance is complete, close it with the close() function.
Sometimes you may need to associate a scope with another one and then allow the combined definition space. For example:
module {
single { ParentScope() }
scope<ParentScope> {
scoped { ChildScope() }
}
scope<ChildScope> {
scoped { ChildComponent() }
}
}Now we can resolve ChildScope's scope instance ChildComponent, directly from ParentScope's scope using linkTo():
val parent = koin.get<ParentScope>()
val child = a.scope.get<ChildScope>()
parent.scope.linkTo(child.scope)
// we got the same ChildComponent instance
assertTrue(parent.scope.get<ChildComponent>() == child.scope.get<ChildComponent>())Modules
So far, we have been working with modules, but we haven't touched on how they work. A module is the space in which you define your dependencies and can be dependent on other modules. Dependencies are lazy and are resolved only when the component requests them.
If you have two dependencies in different modules that can be defined as the same, the last dependency will override all the others:
val module2 = module {
single<Service> { LocalServiceImpl() }
}
val module1 = module {
single<Service> { RemoteServiceImpl() }
}
startKoin {
// Remote implementation will override local implementation
modules(module1, module2)
}You can specify in your Koin application configuration to disallow override with allowOverride(false). In the case of disabling override, Koin will throw an DefinitionOverrideException exception on any override attempt:
startKoin {
// Forbid definition override
allowOverride(false)
}The includes() function allows you to create a module, including other modules and all dependencies defined in them, respectively:
val databases = module {
/* Definitions here */
}
val controllers = module {
/* Definitions here */
}
val app = module {
includes(databases, controllers)
}
startKoin { modules(app) }You can also use includes() to add internal and private modules, thus achieving greater flexibility in the project.
Testing support
Koin has built-in tools for working with testing. To begin with, we should visit the test class as KoinTest to work with it according to the same principles as with KoinComponent:
class SomeComponent
class AnotherComponent(val component: SomeComponent)
class SomeTest : KoinTest {
val anotherComponent : AnotherComponent by inject()
@Test
fun `components are injected`() {
startKoin {
modules(
module {
single { SomeComponent() }
single { AnotherComponent(get()) }
}
)
}
val someComponent = get<SomeComponent>()
assertNotNull(someComponent)
assertEquals(someComponent, anotherComponent.component)
}You can also declare components directly at runtime:
@Test
fun `runtime declaration`() {
startKoin { }
declare {
factory { SomeComponent() }
}
Assert.assertNotEquals(get<SomeComponent>(), get<SomeComponent>())
}If you use mocking in your tests, you can find out more on the official Koin documentation page.
Checking modules
Sometimes there can be problems with injecting dependencies at runtime. To avoid this, Koin provides features to check dependencies in tests. The checkModules method goes through the definition tree and checks whether each definition is injected properly:
@Test
fun `check dependencies hierarchy`() {
koinApplication {
modules(module1, module2, ...)
checkModules()
}
}The check Modules method has its own DSL, which allows to specify how to work with the following case:
withInstance(value)/withInstance<MyType>()adds a dependency in the context of Koin, which can then be used for a dependency tree.withParameter<Type>(qualifier){ qualifier -> value }/withParameter<Type>(qualifier){ qualifier -> parametersOf(...) }passes parameters to the component constructor.withScopeLinkis used to inject dependencies from a specific scope.
Let's say we have two scopes:val someModule = module { scope(named("scope1")) { scoped { SomeComponent() } } scope(named("scope2")) { scoped { AnotherComponent(get()) } } }To implement dependencies from these scopes, we use
withScopeLink:@Test fun `test scope linking`(){ koinApplication { modules(someModule) checkModules(){ withScopeLink(named("scope2"), named("scope1")) } } }withProperty(key, value)adds property to Koin.
Conclusion
In this topic, we delved deeper into working with Koin and studied such nuances of working with it as:
Context isolation to prevent conflicts between dependencies.
Scopes for binding the lifetime of one component to another.
Modules for better component organization.
Testing support for using dependency injection during testing.
Checking modules for checking the dependency graph at the testing stage.
All these subtleties will help you work with Koin at a more advanced level.