Getting Injected: Ktor's New Dependency Injection Framework

Getting Injected: Ktor's New Dependency Injection Framework
Photo by Europeana / Unsplash

Ktor 3.2.0 was released last month and in this post I want to explore its new DI Framework. I'm a huge fan of how a good DI framework
can help keep an application maintainable, modular, and testable over the long term.
In the past when working with ktor I've made use of dagger2 but I love
the idea of ktor now including a DI package integrated into the framework. If you aren't familar with what ktor is
its a kotlin native web framework that is great for building backend services or full stack web applications.

We'll explore this update to ktor using a very simple little rest api service for a "recipe manager" CRUD app.
The full project can be viewed here on github

Getting started

In keeping with ktor's modular approach to building applications
the DI package is actually a new plugin so that its completely optional to include in your project.
If you have other DI solutions you prefer to keep using you can do so without any additional bloat to your project
from this new package, but if you do want to make use of it you'll need to add a new implementation dependency to your project

implementation("io.ktor:ktor-server-di:$ktor_version")

If you make use of a version catalog with a ktor bundle you can just add it there as well.


[libraries]
ktor-server-di = { module = "io.ktor:ktor-server-di-jvm", version.ref = "ktor" }

[bundles]
ktor-server = [
    "ktor-server-core",
    "ktor-server-netty",
    "ktor-server-content-negotiation",
    "ktor-serialization-json",
    "ktor-server-html-builder",
    "ktor-server-auth",
    "ktor-server-auth-jwt",
    "ktor-server-status-pages",
    "ktor-server-cors",
    "ktor-server-call-logging",
    #easy
    "ktor-server-di",

]

Once you've done that you'll be able to start providing and resolving dependencies using the various constructs that the plugin provides.

Let's Inject

The framework gives you several different ways of providing dependencies to your application via code and via application configuration. Let's take a look at an example of each one in code and then we'll swap over to the world of injection via configuration for some extra magic.

The Basics

Once you have the DI artifact included in your project dependencies the way you can begin to use it is via a dependencies block that will become available to you in any of your application modules.


fun Application.myModule(){
  dependencies{
    //the tldr; snippet
    provide { Foo() }
    provide<SomeInterface> { SomeInterfaceImpl() }
    provide(Bar::class)
    provide(::createFooBar)
    provide<() -> Stuff> { { Stuff() } }
    provide<NullableThing?> { null }
    provide<QualifiedThing>(name = "option1") { QualifiedThingImpl2() }
  }
  
  //Other stuff that's probably important to your app working would go here
}

Any module in your app can provide a dependency and those dependencies will then become available to any other module within your app. We'll look at resolution of dependencies in a moment but for now let's go look at some real examples of the code we have to write to provide dependencies.

Lambdas

In our first example we'll make use of a simple lambda and the provide our datasource. You can see this example on github here

In our Database module we create a function that returns our data source object via a simple provide call within the dependencies block. Once this has been added to the dependency container anyone who needs a datasource can just ask for one without needing to know all of the messy details, and our lambda will only get called once so we won't be creating multiples of this datasource.


suspend fun Application.configureDatabase() {
    dependencies{
        provide { provideDatasource() }
    }

    val dataSource = dependencies.resolve<HikariDataSource>()
    Database.connect(dataSource)

    transaction {
        SchemaUtils.create(Recipes)
    }
}


private fun provideDatasource(): HikariDataSource{
    println("Creating Datasource")
    val dbHost = System.getenv("DB_HOST") ?: "localhost"
    val dbPort = System.getenv("DB_PORT") ?: "5432"
    val dbName = System.getenv("DB_NAME") ?: "recipemanager_dev"
    val dbUsername = System.getenv("DB_USERNAME") ?: "postgres"
    val dbPassword = System.getenv("DB_PASSWORD") ?: ""
    val maxPoolSize = (System.getenv("DB_MAX_POOL_SIZE") ?: "10").toInt()
    val sslMode = System.getenv("DB_SSL_MODE") ?: "prefer"

    val jdbcUrl = "jdbc:postgresql://$dbHost:$dbPort/$dbName?sslmode=$sslMode"

    val dataSource = HikariDataSource(HikariConfig().apply {
        driverClassName = "org.postgresql.Driver"
        this.jdbcUrl = jdbcUrl
        username = dbUsername
        password = dbPassword
        maximumPoolSize = maxPoolSize
        isAutoCommit = false
        transactionIsolation = "TRANSACTION_REPEATABLE_READ"
        validate()
    })

    return dataSource
}


Constructor provides

You can provide a constructor reference to the provide function and it will find and use the constructor for your class when it provides the dependency.
This will work for a default no arg constructor as well as a constructor that takes arguments the DI framework can find and provide


class NoArgRepository : RecipeRepository {
    //implementation details
}

class NoArgRepositoryWrapper(private val wrapped: NoArgRepository){
  //wrapper stuff
}

fun Application.recipesModule() {
    dependencies {
        provide(::NoArgRepository)
        provide(::NoArgRepositoryWrapper)
    }
}


Class based provides

Similar to our last example you can also just provide a class reference and the DI framework will figure it all out. (assuming you've already provided what it needs for the constructor)


class ExposedRecipeRepository : RecipeRepository {
    //implementation details
}


provide<RecipeRepository>(ExposedRecipeRepository::class)


Provider function reference provides

We can also use functions that return types as provider functions for the DI framework. These functions can be simple no arg functions or take dependencies known to the framework as arguments.

fun createRepository(): RecipeRepository{
    return ExposedRecipeRepository()
}

fun createSomeSomethingElse(repo: RecipeRepository): SomethingElse{
  return SomethingElse(rep)
}


fun Application.recipesModule() {
    dependencies {
        provide(::createRepository)
        provide(::createSomeSomethingElse)
        provide<RecipeService> { RecipeService(resolve()) }
    }
}


Lambda provider function as a dependency

We can also easily implement a pattern where we create new instances
of a dependency that we may need on demand by using the DI framework's ability
to provide a producer function for us.

suspend fun Application.recipesModule() {
    dependencies {
        provide<RecipeRepository> { ExposedRecipeRepository() }
        provide<() -> RecipeRepository> {
            {
                println("Creating new repository instance")

                ExposedRecipeRepository()
            }
        }
        provide<RecipeService> { RecipeService(resolve()) }
    }

    val recipeRepositoryProvider = dependencies.resolve<() -> RecipeRepository>()

    recipeRepositoryProvider()
    recipeRepositoryProvider()
    recipeRepositoryProvider()


    routing{
        webRoutes()
        apiRoutes()
    }

}

Qualifiers

There are times where you'll need to provide multiple instances of the same thing and that's where most DI frameworks provide you with some kind mechanism for qualifying which one you are providing(or resolving) and ktor's framework is no different.
You can create a qualified dependency using a simple string on the name parameter of the provide function.

 dependencies {
            provide<GreetingService>(name = "test") { GreetingServiceImpl() }
        }

        val service: GreetingService = dependencies.resolve("test")
        assertEquals(HELLO, service.hello())

Injection via Configuration

We can also provide configuration values from our conf files to the di framework so that we can make use of those values as dependencies when we need them in our application code. Let's take a look at how we can do that.

First we'll need to swap our project to use EngineMain so that it auto loads our application.conf file

This means our main function goes from:


fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}

to


fun main(args: Array<String>) = io.ktor.server.netty.EngineMain.main(args)

Once we've done that we'll need to update our conf file with some properties we want to inject.
Let's modify our database module to use conf file property injection to configure our database rather than doing it all in code.

Current database module does a bunch of manual construction of objects:


fun Application.configureDatabase() {
    val dbHost = System.getenv("DB_HOST") ?: "localhost"
    val dbPort = System.getenv("DB_PORT") ?: "5432"
    val dbName = System.getenv("DB_NAME") ?: "recipemanager_dev"
    val dbUsername = System.getenv("DB_USERNAME") ?: "postgres"
    val dbPassword = System.getenv("DB_PASSWORD") ?: ""
    val maxPoolSize = (System.getenv("DB_MAX_POOL_SIZE") ?: "10").toInt()
    val sslMode = System.getenv("DB_SSL_MODE") ?: "prefer"

    val jdbcUrl = "jdbc:postgresql://$dbHost:$dbPort/$dbName?sslmode=$sslMode"

    val dataSource = HikariDataSource(HikariConfig().apply {
        driverClassName = "org.postgresql.Driver"
        this.jdbcUrl = jdbcUrl
        username = dbUsername
        password = dbPassword
        maximumPoolSize = maxPoolSize
        isAutoCommit = false
        transactionIsolation = "TRANSACTION_REPEATABLE_READ"
        validate()
    })

    Database.connect(dataSource)

    transaction {
        SchemaUtils.create(Recipes)
    }
}

In our application.conf let's move all of those db configuration variables to a database block


ktor.application.modules = ["com.bltucker.recipemanager.ApplicationKt.module"]
ktor.deployment.port = 8080
ktor.deployment.host = "0.0.0.0"


database {
  host = "localhost"
  host = ${?DB_HOST}
  port = "5432"
  port = ${?DB_PORT}
  name = "recipemanager_dev"
  name = ${?DB_NAME}
  username = "postgres"
  username = ${?DB_USERNAME}
  password = ""
  password = ${?DB_PASSWORD}
  maxPoolSize = "10"
  maxPoolSize = ${?DB_MAX_POOL_SIZE}
  sslMode = "prefer"
  sslMode = ${?DB_SSL_MODE}
}

What we've done here is defined our database connection properties in the application.conf file where they can now be picked up by the DI framework + EngineMain auto loading of the conf file.

So let's go to our database module now and make use of these properties.

data class DatasourceConfig(
    val host: String,
    val port: String,
    val name: String,
    val username: String,
    val password: String,
    val maxPoolSize: Int,
    val sslMode: String,
){
    val jdbcUrl = "jdbc:postgresql://$host:$port/$name?sslmode=$sslMode"
}

fun provideDataSourceConfig(
    @Property("database.host") host: String,
    @Property("database.port") port: String,
    @Property("database.name") name: String,
    @Property("database.username") username: String,
    @Property("database.password") password: String,
    @Property("database.maxPoolSize") maxPoolSize: Int,
    @Property("database.sslMode") sslMode: String,
) = DatasourceConfig(host, port, name, username, password, maxPoolSize, sslMode)

First we define a data class that holds all of the properties for us in a convenient and typed way. Then we write a provides function that takes those properties as arguments and returns an instance of our typed config object.

Now let's use that typed config object to create a datasource

fun provideDataSource(dbConfig: DatasourceConfig): HikariDataSource{
    val dataSource = HikariDataSource(HikariConfig().apply {
        driverClassName = "org.postgresql.Driver"
        this.jdbcUrl = dbConfig.jdbcUrl
        username = dbConfig.username
        password = dbConfig.password
        maximumPoolSize = dbConfig.maxPoolSize
        isAutoCommit = false
        transactionIsolation = "TRANSACTION_REPEATABLE_READ"
        validate()
    })

    return dataSource
}

Now for the last bit of black magic. Let's make those provide functions available to the DI fraemwork via config file properties rather than via writing codein a dependencies block.

ktor.application.dependencies = [
  "com.bltucker.recipemanager.database.DatabaseKt.provideDataSourceConfig",
  "com.bltucker.recipemanager.database.DatabaseKt.provideDataSource"
]

Now that we've defined everything in conf files lets go resolve and use the dependencies to connect to our database

suspend fun Application.configureDatabase() {

    val dataSource = dependencies.resolve<HikariDataSource>()

    Database.connect(dataSource)

    transaction {
        SchemaUtils.create(Recipes)
    }
}

And just like that we've got a much simpler and easier to read database module.Magical
Whether you want to invoke this kind of magic in your own project can be up to your taste for this kind of behind the scenes configuration, but its an awesome ability if you want or need it.

All of the dependencies for our DB are provided via the config file now

Dependency Resolution

Now that we've looked at the myriad ways we can provide dependencies to our app, let's take a look at how we can resolve them when we need them so our app can use them to carry out its functions.

First lets cover the ways we've already seen from the examples above.

Via dependencies.resolve

val dataSource = dependencies.resolve<HikariDataSource>()

Via resolve() in a provide block

 provide<RecipeService> { RecipeService(resolve()) }

Via configuration properties

fun provideDataSourceConfig(
    @Property("database.host") host: String,
    @Property("database.port") port: String,
    @Property("database.name") name: String,
    @Property("database.username") username: String,
    @Property("database.password") password: String,
    @Property("database.maxPoolSize") maxPoolSize: Int,
    @Property("database.sslMode") sslMode: String,
) = DatasourceConfig(host, port, name, username, password, maxPoolSize, sslMode)



fun provideDataSource(dbConfig: DatasourceConfig): HikariDataSource{
    val dataSource = HikariDataSource(HikariConfig().apply {
        driverClassName = "org.postgresql.Driver"
        this.jdbcUrl = dbConfig.jdbcUrl
        username = dbConfig.username
        password = dbConfig.password
        maximumPoolSize = dbConfig.maxPoolSize
        isAutoCommit = false
        transactionIsolation = "TRANSACTION_REPEATABLE_READ"
        validate()
    })

    return dataSource
}

However, there are still more ways to resolve dependencies when you need them and we'll start taking a look at those now.

Via delegation

We can use Kotlin's property delegation to automatically resolve and inject our data source dependency. This is a simple to use alternative to the dependencies.resolve() from our other examples.


fun Application.configureDatabase() {

    val dataSource: HikariDataSource by dependencies

    Database.connect(dataSource)

    transaction {
        SchemaUtils.create(Recipes)
    }
}

Named resolution

When we have multiples of the same dependency that are declared using a name, we can resolve them with that name


fun Application.namedStuff(){
    dependencies{
        provide<String>(name = "string1"){ "Hello String 1"}
        provide<String>(name = "string2"){ "Hello String 2"}
    }


    val string2 : String = dependencies.resolve(key = "string2")
    println("String 2: $string2")
  
}
 

Resolution & Module function parameters

We can also use configuration magic to inject our modules with the dependencies they need through their function parameters. Let's take a look at this by creating a new module in code that will need a DatasoureConfig object.

suspend fun Application.injectedModuleViaConfiguration(dataSourceConfig: DatasourceConfig){

    println("Using injected datasource config: $dataSourceConfig")
}

We won't call this module's function in code to install it in the manner that we've done for our other modules inside of our main applicaiton module.
Instead we are going to just add this module to our module's array in the application.conf file and that way it will be loaded after our application module, and any arguments it takes will be provided via the dependency injection framework


ktor.application.modules = ["com.bltucker.recipemanager.ApplicationKt.module", "com.bltucker.recipemanager.ApplicationKt.injectedModuleViaConfiguration"]
ktor.deployment.port = 8080
ktor.deployment.host = "0.0.0.0"

If we build and run the application with these changes you would see our println statement and a string representation of our DatasourceConfig.

Nullable resolution

We can also make use of kotlin's nullable types whenever we resolve for dependencies that may or may not be there


suspend fun Application.maybeModule(){
 val maybeFoo: Foo? by dependencies
  
  println(maybeFoo)//maybeFoo or null depending on if we found it.
}

Dependency Clean Up

Let's talk about dependencies that may need to be cleaned up when they are no longer needed. What can we do about that? Well if your dependency implements AutoClosable its close function will automatically get called for you! How cool is that?

If you need to run some custom clean up logic though or your dependency does not implement AutoCloseable the DI framework has your back.

In this fictional example we have an object that has a release function that should be called whenever you are done with it. Rather than burden ourselves with figuring out where in code that should happen, let the DI framework take care of that for you

suspend fun Application.cleanUp(){

   dependencies{
       provide<SocketsManager> { SocketsManager() } cleanup { it.release() 
   }

}

Testing 1..2..3...

The DI plugin makes testing a breeze too by letting you easily override a dependency in your test via either code or replacement configuration files. Whatever floats your boat.

In Code


fun MockedTest() = testApplication {
  application {
    //swap the real thing out with this fake
    dependencies.provide<RemoteService> {
      MockRemoteService()
    }
    
    //more test code
  }
}

With Configs

fun MockedTest() = testApplication {
  //load the default config file
  configure()
  
  //load your test configs full of mocks and fakes
  configure("test-config.conf", "alternative-config.conf)
}

Wrap up

That's all for this post. I'm looking forward to playing around some more in ktor now that it has this built in DI framework and hopefully you are too. Its a great web framework with lots of cool features in a language that is absolutely kick ass.

Useful links

Recipe Manager Github

Ktor docs

Ktor 3.2

Ktor Dependency Injection

Ktor Dependency Injection Test

Ktor's github