Who Builds the Objects? Dependency Injection in Ktor


We’ve been passing repo into taskRoutes(repo) by hand. That’s dependency injection, the plainest kind. Ktor ships with no DI container, on purpose, and for a small app manual wiring is genuinely the right answer. This post shows where it stops scaling and how to bring in Koin when it does.

Manual wiring, and a service layer

First, an honest look at what we have. As the app grows, routes shouldn’t talk to repositories directly. Business logic (validation, rules, combining sources) belongs in a service between them:

class TaskService(private val repo: TaskRepository) {
    suspend fun list(): List<Task> = repo.all()

    suspend fun get(id: Int): Task =
        repo.find(id) ?: throw NotFoundException("No task with id $id")

    suspend fun create(draft: NewTask): Task = repo.create(draft)
}

Now module() builds the object graph top-down by hand:

fun Application.module() {
    val repo = TaskRepository()
    val service = TaskService(repo)

    configureSerialization()
    configureStatusPages()
    configureRouting(service)
}

This is fine — readable, explicit, no library. But watch what happens as the graph grows: a UserService that needs the repo and the task service, an email sender, a payment client, each with its own dependencies. Soon module() is a tangle of constructor calls in just the right order, and every new dependency means editing that wiring by hand. That’s where a DI container earns its keep.

Enter Koin

Koin is a lightweight DI framework for Kotlin with first-class Ktor support. No annotations, no code generation: just a Kotlin DSL describing how to build things.

Add the dependency:

implementation("io.insert-koin:koin-ktor:4.0.0")
implementation("io.insert-koin:koin-logger-slf4j:4.0.0")

Declaring the graph

Describe your objects in a Koin module — a recipe for building each dependency:

import org.koin.dsl.module

val appModule = module {
    single { TaskRepository() }
    single { TaskService(get()) }
}

single { } means “one shared instance for the app’s lifetime” (a singleton). The magic is get(): inside a definition, get() asks Koin to resolve a dependency by type. So TaskService(get()) reads as “build a TaskService, and fetch its TaskRepository argument from the graph.” You declare what depends on what; Koin works out the construction order.

Installing Koin

It’s a Ktor plugin, so it installs like the rest:

import io.ktor.server.application.*
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger

fun Application.configureKoin() {
    install(Koin) {
        slf4jLogger()
        modules(appModule)
    }
}

Injecting into routes

With Koin installed, pull dependencies out of the graph with inject() — an extension on Application — instead of threading them through parameters:

import org.koin.ktor.ext.inject

fun Application.configureRouting() {
    val service by inject<TaskService>()
    routing {
        taskRoutes(service)
    }
}

by inject<TaskService>() is a lazy delegate: the first time service is used, Koin resolves it from the graph. Notice configureRouting() no longer takes the service as a parameter — and neither does module() build it by hand. The object graph lives in appModule; everything else just asks for what it needs.

fun Application.module() {
    configureKoin()
    configureSerialization()
    configureStatusPages()
    configureRouting()
}

When to actually reach for this

Be honest about the trade. For a handful of objects, manual wiring is clearer and dependency-free — don’t add Koin to a three-class app to look enterprise-y. Reach for it when the graph is deep enough that hand-construction order becomes fiddly, when you want to swap implementations per environment (a fake repository in tests, the real one in prod), or when wiring-by-hand has become its own maintenance burden. DI is a tool for managing growth, not a starting requirement.

Final thoughts

The arc here is the real lesson: start with the plainest thing that works — pass dependencies as parameters — and adopt a container only when the object graph’s complexity actually demands it. Ktor’s refusal to bundle DI isn’t a gap; it’s the framework letting you make that call at the right time. When you do bring in Koin, keep the whole graph in appModule so there’s one place that answers “where does this object come from?”

Next: authentication with JWT — locking down routes so not just anyone can delete your tasks.

Comments