Simple and lightweight dependency injection framework

1. Introduction

The principle of injection (injection) of dependencies is becoming more and more an integral part of the development process. Without it, it is difficult to imagine achieving the desired separation of duties in the code or ensuring the proper level of testability.

At the same time, although the Spring Framework is a widely accepted choice, it is not for everyone. Some would prefer to use simpler and lightweight frameworks with advanced support for asynchronous I/O operations. Others would appreciate static dependency resolution for faster application startup.

Of course there is a framework Guicebut if we want to have something more suitable for the Kotlin language, we should pay attention to Koin. This lightweight framework provides facilities for dependency injection via DSL, which is not a trivial task in the case of Java-based Guice.

In addition to being able to explicitly declare dependencies between components in code, Koin also has integrated support for well-known applications developed in the Kotlin programming language. Specifically, it facilitates interoperability and integration with popular frameworks and platforms such as Ktor for building server applications and the Android platform for mobile applications. It is important to note that Koin is not “magic” – it does not generate proxy objects, does not use reflection, and does not heuristically try to find a suitable implementation to satisfy our dependency. Instead, it only does what it’s explicitly told to do and doesn’t have “autowiring” like Spring does.

In this guide, we will cover the basics of Koin and set the stage for deeper and more advanced usage of the framework.

2. How to get started with Koin

As with any library, we need to add some dependencies. It all depends on the specific project: for successful work, we need either a standard setup using Kotlin (in this case, it will be a so-called “vanilla” (pure) Kotlin project), or we can have a project based on the Ktor framework, which allows you to develop server applications. If we are using the Gradle build system with the Kotlin DSL, we will need to specify two dependencies in our project. These dependencies are needed in order for the Koin library to function correctly in the “vanilla” Kotlin project mode, that is, in normal mode without specific frameworks:

val koin_version = "3.2.0-beta-1"
implementation("io.insert-koin:koin-core:$koin_version")
testImplementation("io.insert-koin:koin-test:$koin_version")

If we plan to use the JUnit 5 library to write and run tests, we need to explicitly state that our project depends on this library. To make JUnit 5 work in our project, we must specify its dependency in our project’s settings file:

testImplementation("io.insert-koin:koin-test-junit5:$koin_version")

Similarly, for the version using the Ktor framework, to integrate with Koin, there is a special dependency (it replaces the main (default) dependency in Ktor applications):

implementation("io.insert-koin:koin-ktor:$koin_version")

That’s all we need to start using the Koin library. We will be using the latest beta to keep the guide up to date for a long time to come.

3. Modules and Definitions

Let’s start our journey by creating a registry for our DI (Dependency Injection) pattern. We will register dependencies in this registry so that we can later inject them into various parts of our application.

3.1. Modules

Modules contain declarations of dependencies between services, resources, and repositories. They allow you to organize these dependencies and provide information about which components can be injected into other parts of the application. There can be multiple modules, one for each semantic field. When creating a Koin context, all modules are passed to the function modules()which will be discussed later.

Each module may depend on definitions found in other modules. Koin performs dependency evaluation in modules “lazy”, meaning it does not create or resolve dependencies until they are actually needed during program execution. This avoids redundant calculations and the creation of unnecessary objects, which improves the efficiency and performance of the application. Definitions can even form dependency loops. This usually occurs in complex applications where various components interact with each other and depend on the results of each other’s work.

In Koin, thanks to the lazy evaluation of dependencies, circular dependencies do not cause problems. However, it still makes sense to avoid creating semantic loops, since it is difficult to maintain such a structure in the future.

To create a module, we must use the function module {}:

class HelloSayer() {
    fun sayHello() = "Hello!"
}

val koinModule = module {
    single { HelloSayer() }
}

Modules can be included in each other:

val koinModule = module {
    // Some configuration
}

val anotherKoinModule = module {
    // More configuration
}

val compositeModule = module {
    includes(koinModule, anotherKoinModule)
}

Moreover, they can form a complex tree structure without significant performance degradation. includes() will combine all definitions into one common collection (perform the “flattening” operation) and they will become available at the same level. This avoids the need for long hierarchical access to definitions within nested modules and can make them easier to use in other parts of the application.

Function includes() in the Koin library, it combines not only definitions (dependencies), but also the components themselves (services, resources, and other elements) that were declared in different modules.

3.2. Definitions of Singleton and Factory

To create a definition, most often you have to use a single function single<T>{}Where T is the type that must match the requested type in subsequent calls get<T>():

single<RumourTeller> { RumourMonger(get()) }

single {} will create a definition for the singleton object and each time the method is called get()will return the same instance (instance) of this object.

Another way to create a singleton (singleton) – a new feature singleOf() in version 3.2.. This approach is based on two observations.

First, most Kotlin classes only have one constructor. Because of the default values, they do not require multiple constructors to support different usage scenarios, as in Java.

Secondly, most definitions do not have alternatives. In older versions of Koin, this resulted in definitions such as:

single<SomeType> { get(), get(), get(), get(), get(), get() }

Therefore, instead, we can specify the constructor we want to call:

class BackLoop(val dependency: Dependency)

val someModule = module {
    singleOf(::BackLoop)
}

This approach is based on simplifying the process of defining singletons, making the code more concise and understandable.

Another approach is the function factory {}used to define a dependency that will be created by a new instance each time it is called get() for this dependency:

factory { RumourSource() }

If we don’t declare the dependency with single {} with parameter createdAtStart = true (i.e. immediately at the start of the application), then the lambda expression (*creator lambda) will be executed only if any component KoinComponent explicitly request this dependency. (*Lambda expression points to a function that defines how the dependency is instantiated when using the Koin framework).

3.3. Variants of definitions

It is important to understand that every definition is a lambda. This means that although often a definition can be a simple constructor call, it is not limited to just that:

fun helloSayer() = HelloSayer()

val factoryFunctionModule = module {
    single { helloSayer() }
}

In addition, the definition can have a parameter:

module {
    factory { (rumour: String) -> RumourSource(rumour) }
}

In the case of a singleton, the first call will create an instance, and all subsequent attempts to pass the parameter will be ignored:

val singleWithParamModule = module {
    single { (rumour: String) -> RumourSource(rumour) }
}

startKoin {
    modules(singleWithParamModule)
}
val component = object : KoinComponent {
    val instance1 = get<RumourSource> { parametersOf("I've seen nothing") }
    val instance2 = get<RumourSource> { parametersOf("Jane is seeing Gill") }
}

assertEquals("I've heard that I've seen nothing", component.instance1.tellRumour())
assertEquals("I've heard that I've seen nothing", component.instance2.tellRumour())

In the case of a factory, each injection will be instantiated with its own parameter, as expected:

val factoryScopeModule = module {
    factory { (rumour: String) -> RumourSource(rumour) }
}

startKoin {
    modules(factoryScopeModule)
}
// Same component instantiation

assertEquals("I've heard that I've seen nothing", component.instance1.tellRumour())
assertEquals("I've heard that Jane is seeing Gill", component.instance2.tellRumour())

This means that the factory creates new dependency instances with each request and passes in the necessary parameters so that each dependency is individual and can be customized to suit specific requirements.

Another trick is how to define multiple objects of the same type. You need to create several different instances of the same class or type, but with different parameters or settings. This is actually very easy to do:

val namedSources = module {
    single(named("Silent Bob")) { RumourSource("I've seen nothing") }
    single(named("Jay")) { RumourSource("Jack is kissing Alex") }
}

We have multiple objects of the same type RumourSourcebut with different names – “Silent Bob” and “Jay”.

In this code, we define two different RumourSource instances with the function single. The peculiarity lies in the use of the function namedwhich allows us to give names to specific instances.

In the first line we create an instance RumourSource with the name “Silent Bob” and give him the message “I didn’t see anything”. The second line contains an instance named “Jay” with the message “Jack kisses Alex”.

Now, when we inject these instances into other components of our application, we can easily distinguish them by name. For example, in the code we will request an instance named “Silent Bob” or “Jay” for further use.

Thus, this technique allows us to create and inject several different instances of the same type, distinguishing them by name, to suit the different needs and scenarios of our application.

4. Koin components

Definitions from modules are used in KoinComponents. The class that implements the interface KoinComponentsomewhat similar to Spring @Component. He He is associated with the global instance Koinwhich is created when the application is initialized, and serves as an entry point to the tree of objects described in modules:

class SimpleKoinApplication : KoinComponent {
    private val service: HelloSayer by inject()
}

Instance reference by default Koin is implicit and uses GlobalContext, however this mechanism can be overridden in some cases. For example, in case we need to have several isolated Koin contexts within one application.

We must instantiate Koin components in the usual way, through their constructors, not by injecting them into a module. Takova recommendation library authors: probably injecting components into modules leads to performance degradation or endless recursion when dependencies call each other in a loop.

By using regular constructors to create components, we have more control over the instantiation process and can explicitly specify which dependencies should be created and how they should interact. This provides more reliable and predictable application behavior, and improves performance and overall code structure.

4.1. Immediate Evaluation vs. Lazy Evaluation

An object that implements an interface KoinComponenthas the ability to use methods inject() And get() to get dependencies:

class SimpleKoinApplication : KoinComponent {
    private val service: HelloSayer by inject()
    private val rumourMonger: RumourTeller = get()
}

Dependency injection using a method inject() means that the dependency is not created immediately when the object is initialized, but is deferred until the moment it is first called. The keyword is used for this. by along with the method inject(). A delegate is created – a special object that is responsible for the lazy calculation of the dependency. It will be executed the first time the dependency is accessed.

Unlike lazy evaluation (inject()), method get() allows you to get the dependency immediately, without waiting. When an object calls a method get() for a particular dependency, it immediately obtains a directly instantiated instance of that dependency.

So using the methods inject() And get() gives developers the flexibility to manage dependencies by allowing them to choose between lazy evaluation and immediate evaluation based on specific application requirements.

5. Koin Instance

In order to activate all of our definitions, we need to instantiate Koin. It can be made and registered in GlobalContext, and it will be available during the running (runtime) of the entire application. Or we will form an autonomous (standalone) instance Koin and we will manage the link to it ourselves.

In other words, we instantiate Koinwhich exists separately from any global context, in which case we ourselves are responsible for managing this instance.

When we create a “standalone” instance Kointhen we do this using the function koinApplication{}, which allows you to define modules and other settings:

val app = koinApplication {
    modules(koinModule)
}

This instance Koin represents our application or “starting point” for dependency management.

We have to keep a reference to the application (instance Koincreated with koinApplication{}) in the app variable and use it later to initialize our components.

It is important to note that instantiation Koin by using koinApplication{} does not initialize our components yet. It only creates a configuration Koin. In order to start using our dependencies, we must call startKoin {}passing application modules to it.

Function startKoin {} creates a global instance Koinwhich will be available for use in our code:

startKoin {
    modules(koinModule)
}

This instance provides access to the dependencies defined in our modules.

However, it is worth noting that Koin has certain preferences for certain frameworks. One such example is Ktor. It has its own way of initializing the Koin configuration. Let’s talk about setting up Koin in both a vanilla Kotlin app and a Ktor web server.

5.1. Basic vanilla app with Koin

To get started with the basic configuration of Koin, you need to create an instance Koin:

startKoin {
    logger(PrintLogger(Level.INFO))
    modules(koinModule, factoryScopeModule)
    fileProperties()
    properties(mapOf("a" to "b", "c" to "d"))
    environmentProperties()
    createEagerInstances()
}

This function can only be called once in the lifetime of the JVM (Java Virtual Machine). The most important part of it is the call modules(), where all definitions are loaded. However, we can load additional modules and unload some later with loadKoinModules() And unloadKoinModules().

Let’s look at other calls inside the lambda startKoin {}. This logger()various *properties() and challenge createEagerInstances(). The first two of the functions, namely logger() And *properties()require separate explanations, so a separate section should be devoted to them.

The third function in question is createEagerInstances()allows you to explicitly create and initialize those same defined singletons at application startup (with the argument createdAtStart = true), even if they haven’t been explicitly requested yet.

5.2. Basic Ktor server with Koin

For the Ktor server, Koin is just another installed feature. She plays the role of a call startKoin {}:

fun Application.module(testing: Boolean = false) {
    koin {
        modules(koinModule)
    }
}

After that the class Application gets functionality KoinComponent and maybe inject() (inject) dependencies:

routing {
    val helloSayer: HelloSayer by inject()
    get("/") {
        call.respondText("${helloSayer.sayHello()}, world!")
    }
}

5.3. Standalone instance of Koin

It might be wise not to use a global Koin instance for SDKs and libraries. To achieve this, we can use the function koinApplication {} to create an isolated instance Koin-container and save a link to it:

val app = koinApplication {
    modules(koinModule)
}

Then we need to override some of the default functionality KoinComponent:

class StandaloneKoinApplication(private val koinInstance: Koin) : KoinComponent {
    override fun getKoin(): Koin = koinInstance
    // other component configuration
}

After that, we will be able to instantiate the components at runtime (runtime) with one additional argument – an instance Koin:

StandaloneKoinApplication(app.koin).invoke()

6. Logging and Properties

Now let’s talk about those features logger() And properties() in setup startKoin {}.

6.1. Logger Koin

To make it easier to find problems in the setup, we can enable the Koin logger. In fact, it is already always enabled, but its implementation is used by default. EmptyLogger. We can change it to PrintLoggerto view Koin logs on standard output:

startKoin {
    logger(PrintLogger(Level.INFO))
}

Or, alternatively, you can implement your own Logger. If we are using Ktor, Spark, or the Android version of Koin, then there is an option to use their loggers: SLF4JLogger or AndroidLogger.

6.2. Properties

Koin can also use properties from a file (usually a file koin.propertieswhich is located by default in the folder classpath:koin.propertiestherefore, in our project this file should be in src/main/resources/koin.properties), from system environment variables and directly from the passed map (map):

startKoin {
    modules(initByProperty)
    fileProperties()
    properties(mapOf("rumour" to "Max is kissing Alex"))
    environmentProperties()
}

Then in the module, we can access these properties using the methods getProperty():

val initByProperty = module { 
    single { RumourSource(getProperty("rumour", "Some default rumour")) }
}

7. Testing Koin Applications

Koin also provides a fairly mature testing infrastructure. Implementing an interface KoinTestwe provide our test with the functionality KoinComponent and even more:

class KoinSpecificTest : KoinTest {
    @Test
    fun `when test implements KoinTest then KoinComponent powers are available`() {
        startKoin {
            modules(koinModule)
        }
        val helloSayer: HelloSayer = get()
        assertEquals("Hello!", helloSayer.sayHello())
    }
}

7.1. Mocking Koin Definitions

An easy way to mock or otherwise replace entities declared in modules is to create a special situational module on the fly when running Koin in a test:

startKoin {
    modules(
        koinModule,
        module {
            single<RumourTeller> { RumourSource("I know everything about everyone!") }
        }
    )
}

Another way is to use JUnit 5 extensions to startKoin {} and mocking:

@JvmField
@RegisterExtension
val koinTestExtension = KoinTestExtension.create {
    modules(
        module {
            single<RumourTeller> { RumourSource("I know everything about everyone!") }
        }
    )
}

@JvmField
@RegisterExtension
val mockProvider = MockProviderExtension.create { clazz ->
    mockkClass(clazz)
}

After registering these extensions, mocking does not have to be in a special module or in one place. Any call declareMock<T> {} will create a suitable mock:

@Test
fun when_extensions_are_used_then_mocking_is_easier() {
    declareMock<RumourTeller> {
        every { tellRumour() } returns "I don't even know."
    }
    val mockedTeller: RumourTeller by inject()
    assertEquals("I don't even know.", mockedTeller.tellRumour())
}

We can use any library or approach when mocking, as Koin does not specifically define Koin for this.

7.2. Checking Koin Modules

Koin also provides tools to check the module’s configuration and identify any possible injection problems that we might encounter during the runtime process. To do this is very simple: we must call checkModules() inside the config KoinApplicationor check the list of modules with checkKoinModules():

koinApplication {
    modules(koinModule, staticRumourModule)
    checkModules()
}

Checking modules has its own DSL. This language allows you to mock some of the values ​​in a module or provide an alternative value. It also provides for passing parameters that the module may need to instantiate:

koinApplication {
    modules(koinModule, module { single { RumourSource() } })
    checkModules {
        withInstance<RumourSource>()
        withParameter<RumourTeller> { "Some param" }
    }
}

8. Conclusion

In this tutorial, we took a closer look at the Koin library and learned how to use it.

Koin creates a root context object, which contains settings for creating dependencies and specific options for those dependencies. For singleton objects, it also keeps references to instances of those dependencies.

It’s sort of a repository where dependency definitions (singleton, factory, etc.) are bundled together and can be queried from your code.

Dependencies can be described as modules.

You can create and configure dependencies within individual modules. Each module contains a set of definitions that describe what dependencies will be built, what components will be provided, and how they will be linked together.

This approach helps to divide the functionality of your application into logical blocks, which makes it easier to understand and manage dependencies. It also improves code reuse and facilitates testing and maintenance, as each module can be independently configured and replaced as needed.

Modules provide descriptions of dependencies that are loaded into the object’s creator function Koin. They describe how to create dependencies using producer functions, which are often simple constructor calls.

Modules can depend on each other, And individual definitions can have different scopes, such as singleton and factory. Scope (scopes) in the Koin context determines how long the created dependency object will exist and how it will be created. For example, a singleton means that an object is created once and stored for reuse. All subsequent requests for this object will return the same instance. A factory, in contrast, creates a new instance each time a dependency is requested. Definitions are also capable of containing parameters that allow us to inject environment-specific data into the object tree. This means that we can pass specific values ​​or settings to dependency constructors at the time they are created. Thus, we are able to adapt the behavior of our dependencies according to the current environment or the requirements of the application.

To access dependencies we need to mark one or more objects as KoinComponent. After that we can declare fields in such objects using delegates inject() or to perform their immediate initialization with get().

During the creation of Koin we can inject parameters from file, environment and programmatically created Map (cards). You can use all three methods or any subset of them. In the object Koin these properties are stored in a single registry, so the most recently loaded property definition takes precedence.

The same applies to modules: within Koin, you can override dependency definitions if necessary. If there are multiple definitions for the same dependency, then the last definition that was declared will be used.

In other words, the last defined dependency definition overwrites previous definitions. This allows you to dynamically change dependency configuration in different parts of your application. For example, you can override a definition in a unit test to replace the real dependency with a mock object during testing.

Koin Provides Testing Opportunities both functionality and the Koin configuration itself.

As usual, all our code available on GitHub.


Material prepared in anticipation of the start online course “Kotlin Backend Developer. Professional”. Recently, as part of the course, an open lesson was held on the applicability of Kotlin in various areas of development: Multimedia, ML, 3D/VR, Frontend, IoT/Robotics, Blockchain. If you are interested, you can watch the recording of the session. link.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *