As your Ktor application grows, you create more repositories, services, and routes. Without dependency injection, you end up passing objects manually through every function. This makes your code hard to test and hard to change.

In this tutorial, you will add Koin to your Ktor application. You will create a service layer, define dependency modules, and inject dependencies into your routes.

What is Dependency Injection?

Dependency injection (DI) means your classes receive their dependencies from the outside instead of creating them.

Without DI:

class NoteService {
    // Creates its own dependency — hard to test
    private val repository = NoteRepository()
}

With DI:

class NoteService(private val repository: NoteRepository) {
    // Receives dependency — easy to test with a mock
}

When you test NoteService, you can pass a fake NoteRepository. When you run the application, the DI framework provides the real one.

Why Koin?

Koin is a lightweight dependency injection framework for Kotlin. It uses Kotlin DSL instead of annotations. This makes it simple to learn and use.

Koin has official Ktor support. You install it as a Ktor plugin, define your dependencies in modules, and inject them into routes.

Koin vs other DI frameworks:

FeatureKoinDagger/HiltManual DI
SetupSimple DSLAnnotations + code genNo framework
Compile timeFastSlow (code gen)Fast
Learning curveLowHighNone
Ktor supportOfficial pluginNoYes
Error detectionRuntimeCompile timeRuntime

Koin catches missing dependencies at runtime, not compile time. This is the main tradeoff. You solve this by writing tests that verify your DI configuration.

Dependencies

Add Koin to your build.gradle.kts:

dependencies {
    // Dependency Injection - Koin
    implementation("io.insert-koin:koin-ktor:4.0.3")
    implementation("io.insert-koin:koin-logger-slf4j:4.0.3")
}

koin-ktor provides the Ktor plugin. koin-logger-slf4j integrates Koin’s logging with SLF4J, which we set up in Tutorial #3.

The Service Layer

Before adding DI, let’s create a service layer. Until now, our routes called repositories directly. A service layer sits between routes and repositories:

Route → Service → Repository → Database

The service layer holds business logic. The repository layer handles database queries. This separation makes each layer easier to test.

AuthService

package com.kemalcodes.service

import com.kemalcodes.models.*
import com.kemalcodes.plugins.JwtConfig
import com.kemalcodes.repository.RefreshTokenRepository
import com.kemalcodes.repository.UserRepository

// Service layer for authentication business logic
class AuthService(
    private val userRepository: UserRepository,
    private val refreshTokenRepository: RefreshTokenRepository
) {

    suspend fun register(request: RegisterRequest): TokenResponse? {
        // Check if email already exists
        val existing = userRepository.findByEmail(request.email)
        if (existing != null) return null

        val user = userRepository.register(request)
        return generateTokens(user.id, user.email, user.role)
    }

    suspend fun login(email: String, password: String): TokenResponse? {
        val user = userRepository.verifyPassword(email, password) ?: return null
        return generateTokens(user.id, user.email, user.role)
    }

    suspend fun refresh(refreshToken: String): TokenResponse? {
        val tokenInfo = refreshTokenRepository.findValidToken(refreshToken)
            ?: return null

        // Revoke old token (rotation)
        refreshTokenRepository.revoke(refreshToken)

        val profile = userRepository.findProfileById(tokenInfo.userId)
            ?: return null
        return generateTokens(profile.id, profile.email, profile.role)
    }

    suspend fun logout(userId: Int) {
        refreshTokenRepository.revokeAllForUser(userId)
    }

    suspend fun getProfile(userId: Int): ProfileResponse? {
        return userRepository.findProfileById(userId)
    }

    private suspend fun generateTokens(
        userId: Int,
        email: String,
        role: String
    ): TokenResponse {
        val accessToken = JwtConfig.generateAccessToken(userId, email, role)
        val refreshToken = JwtConfig.generateRefreshToken()
        refreshTokenRepository.create(userId, refreshToken)
        return TokenResponse(accessToken, refreshToken)
    }
}

AuthService receives UserRepository and RefreshTokenRepository through its constructor. It does not create them. This is constructor injection — the simplest form of DI.

NoteService

package com.kemalcodes.service

import com.kemalcodes.models.CreateNoteRequest
import com.kemalcodes.models.NoteResponse
import com.kemalcodes.models.UpdateNoteRequest
import com.kemalcodes.repository.NoteRepository

// Service layer for note business logic
class NoteService(private val noteRepository: NoteRepository) {

    suspend fun getAllNotes(
        page: Int = 1,
        size: Int = 10,
        userId: Int? = null,
        tag: String? = null,
        sortBy: String = "id",
        sortOrder: String = "desc"
    ): List<NoteResponse> {
        return noteRepository.findAll(page, size, userId, tag, sortBy, sortOrder)
    }

    suspend fun getNoteById(id: Int): NoteResponse? {
        return noteRepository.findById(id)
    }

    suspend fun createNote(request: CreateNoteRequest): NoteResponse {
        return noteRepository.create(request)
    }

    suspend fun updateNote(id: Int, request: UpdateNoteRequest): NoteResponse? {
        return noteRepository.update(id, request)
    }

    suspend fun deleteNote(id: Int): Boolean {
        return noteRepository.delete(id)
    }

    suspend fun getNoteCount(): Long {
        return noteRepository.count()
    }
}

Right now, the services are thin wrappers around repositories. As your application grows, business logic accumulates here — validation rules, authorization checks, event publishing, caching.

Defining the Koin Module

A Koin module declares all your dependencies:

package com.kemalcodes.plugins

import com.kemalcodes.repository.NoteRepository
import com.kemalcodes.repository.RefreshTokenRepository
import com.kemalcodes.repository.UserRepository
import com.kemalcodes.service.AuthService
import com.kemalcodes.service.NoteService
import io.ktor.client.*
import io.ktor.client.engine.java.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import kotlinx.serialization.json.Json
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger

// Define all application dependencies
val appModule = module {
    // Repositories (singletons)
    single { UserRepository() }
    single { NoteRepository() }
    single { RefreshTokenRepository() }

    // Services (singletons, depend on repositories)
    single { AuthService(get(), get()) }
    single { NoteService(get()) }

    // HTTP client for OAuth
    single {
        HttpClient(Java) {
            install(ContentNegotiation) {
                json(Json { ignoreUnknownKeys = true })
            }
        }
    }
}

// Install Koin dependency injection
fun Application.configureDI() {
    install(Koin) {
        slf4jLogger()
        modules(appModule)
    }
}

How It Works

  • single { UserRepository() } — creates one instance for the entire application
  • single { AuthService(get(), get()) }get() resolves dependencies automatically. Koin looks up UserRepository and RefreshTokenRepository by type
  • slf4jLogger() — routes Koin’s internal logs through SLF4J

Singleton vs Factory

Koin provides two scoping options:

// One instance for the entire application
single { NoteRepository() }

// New instance every time it is requested
factory { NoteService(get()) }

Use single for repositories and services — they are stateless and thread-safe. Use factory when you need a new instance each time, like request-scoped objects.

Installing Koin in the Application

Add configureDI() as the first call in your application module:

fun Application.module() {
    configureDI()          // Must be first — other plugins may need dependencies
    configureDatabase()
    configureSerialization()
    configureAuthentication()
    configureCors()
    configureRateLimit()
    configureSecurityHeaders()
    configureWebSockets()
    configureStatusPages()
    configureOpenApi()
    configureRouting()
}

configureDI() must run before configureRouting(). Routes inject dependencies from Koin, so Koin must be installed first.

Injecting Dependencies into Routes

Use inject() from the Koin Ktor extension:

package com.kemalcodes.plugins

import com.kemalcodes.repository.NoteRepository
import com.kemalcodes.repository.RefreshTokenRepository
import com.kemalcodes.repository.UserRepository
import com.kemalcodes.routes.*
import io.ktor.client.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.inject

// Configure all routes for the application
fun Application.configureRouting() {
    // Inject dependencies from Koin
    val noteRepository by inject<NoteRepository>()
    val userRepository by inject<UserRepository>()
    val refreshTokenRepository by inject<RefreshTokenRepository>()
    val httpClient by inject<HttpClient>()

    routing {
        staticResources("/static", "static")

        get("/") {
            call.respondText("Hello, Ktor!")
        }

        get("/health") {
            call.respondText("OK")
        }

        webSocketRoutes()
        htmxRoutes(noteRepository)

        route("/api") {
            authRoutes(userRepository, refreshTokenRepository)
            oauthRoutes(userRepository, refreshTokenRepository, httpClient)
            userRoutes(userRepository)
            noteRoutes(noteRepository)
            fileRoutes()
        }
    }
}

by inject<NoteRepository>() is a lazy delegate. It resolves the dependency from Koin the first time it is accessed. If the dependency is missing, Koin throws an error at runtime.

Before and After DI

Before — Manual Wiring

fun Application.configureRouting() {
    val userRepository = UserRepository()
    val noteRepository = NoteRepository()
    val refreshTokenRepository = RefreshTokenRepository()
    val authService = AuthService(userRepository, refreshTokenRepository)
    val noteService = NoteService(noteRepository)
    val httpClient = HttpClient(Java) { /* config */ }

    routing {
        authRoutes(userRepository, refreshTokenRepository)
        noteRoutes(noteRepository)
        // ...
    }
}

After — Koin DI

fun Application.configureRouting() {
    val noteRepository by inject<NoteRepository>()
    val userRepository by inject<UserRepository>()
    val refreshTokenRepository by inject<RefreshTokenRepository>()
    val httpClient by inject<HttpClient>()

    routing {
        authRoutes(userRepository, refreshTokenRepository)
        noteRoutes(noteRepository)
        // ...
    }
}

The difference seems small in this example. The real benefit shows when:

  • You have 20+ services and repositories
  • Multiple routes need the same service
  • You want to swap implementations for testing
  • You add new dependencies without changing route code

Resource Lifecycle Management

Dependencies sometimes need cleanup. For example, the HTTP client should be closed when the application shuts down:

val appModule = module {
    single {
        HttpClient(Java) {
            install(ContentNegotiation) {
                json(Json { ignoreUnknownKeys = true })
            }
        }
    }
}

Register a shutdown hook in your application module:

fun Application.module() {
    configureDI()
    // ... other plugins

    // Cleanup on shutdown
    environment.monitor.subscribe(ApplicationStopped) {
        val client: HttpClient = org.koin.java.KoinJavaComponent.getKoin().get()
        client.close()
    }
}

This ensures the HTTP client closes its connections when the server stops.

Testing with DI Overrides

One of the biggest benefits of DI is testing. In tests, you can override dependencies:

@Test
fun `test with custom dependency`() = testApplication {
    application {
        install(Koin) {
            modules(module {
                single { FakeNoteRepository() }
                single { NoteService(get()) }
            })
        }
        configureRouting()
    }

    val client = createClient {
        install(ContentNegotiation) { json() }
    }

    // Test with fake repository
    val response = client.get("/api/notes")
    assertEquals(HttpStatusCode.OK, response.status)
}

You replace the real NoteRepository with a fake one. The rest of the application works the same. We cover testing in detail in the next tutorial.

Multiple Modules

As your application grows, split dependencies into multiple modules:

val repositoryModule = module {
    single { UserRepository() }
    single { NoteRepository() }
    single { RefreshTokenRepository() }
}

val serviceModule = module {
    single { AuthService(get(), get()) }
    single { NoteService(get()) }
}

val httpModule = module {
    single {
        HttpClient(Java) {
            install(ContentNegotiation) {
                json(Json { ignoreUnknownKeys = true })
            }
        }
    }
}

fun Application.configureDI() {
    install(Koin) {
        slf4jLogger()
        modules(repositoryModule, serviceModule, httpModule)
    }
}

Each module groups related dependencies. This keeps your DI configuration organized.

Verifying DI Configuration

Koin catches missing dependencies at runtime, not compile time. To catch errors early, verify your modules in a test:

@Test
fun `verify Koin modules`() {
    appModule.verify()
}

Or verify by starting the full application:

@Test
fun `application starts with DI`() = testApplication {
    application { module() }

    val response = client.get("/health")
    assertEquals(HttpStatusCode.OK, response.status)
}

If any dependency is missing, the application fails to start, and the test fails. Run this test in CI to catch DI configuration errors before deployment.

Koin vs Ktor Built-in DI

Ktor 3.2 introduced a built-in DI plugin. It is still experimental and requires @OptIn. Koin is the more mature option:

FeatureKoinKtor Built-in DI
MaturityStable, widely usedExperimental
Module supportMultiple modulesSingle registry
TestingOverride supportLimited
CommunityLargeSmall
Extra dependencyYesNo

If you want zero extra dependencies, use the built-in DI. For production applications, Koin is the safer choice today.

Debugging DI Issues

When something goes wrong with Koin, you see errors like:

No definition found for class 'NoteService'

This means NoteService was not registered in any Koin module. Check that you included it in your module definition.

Another common error:

Multiple definitions found for class 'NoteRepository'

This means the same type was registered twice. Remove the duplicate from your module definitions.

Koin’s SLF4J logger helps with debugging. When you call slf4jLogger(), Koin logs all registrations at startup:

[Koin] bind: NoteRepository (singleton)
[Koin] bind: NoteService (singleton)
[Koin] bind: AuthService (singleton)

Check these logs when dependencies are not resolving as expected.

Common Mistakes

  1. Creating instances instead of injecting — If you write NoteRepository() inside a route, you bypass DI. Always use inject() or constructor injection.

  2. Circular dependencies — Service A depends on Service B, and Service B depends on Service A. Koin throws an error at startup. Redesign your services to break the cycle.

  3. Not cleaning up resources — HTTP clients, database connections, and other resources need cleanup on shutdown. Register shutdown hooks.

  4. Using factory when you need single — Creating a new database connection pool for every request would crash your server. Repositories and services should be singletons.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-18-di

What’s Next?

Your application now has proper dependency injection. In the next tutorial, you will learn Testing Ktor Applications — writing unit tests and integration tests using Ktor’s test engine and Koin overrides.