ktor-native-worker-tutorial

Part 5: Wire Everything with Koin

In this part, we’ll explore how Koin dependency injection wires all the components together, creating a cohesive and testable application architecture.

What is Koin?

Koin is a lightweight dependency injection framework for Kotlin that:

Koin Dependencies

In gradle/libs.versions.toml:

[versions]
koin = "4.1.0"

[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }

In build.gradle.kts:

sourceSets {
    commonMain.dependencies {
        implementation(libs.koin.core)
        implementation(libs.koin.ktor)
        // ... other dependencies
    }
}

Main Module Definition

File: src/commonMain/kotlin/me/nathanfallet/ktornativeworkertutorial/di/MainModule.kt

val Application.mainModule
get() = module {
    // Services
    single<MessageBroker> {
        RabbitMqMessageBroker(
            coroutineScope = this@mainModule,
            host = getEnv("RABBITMQ_HOST") ?: "localhost",
            port = getEnv("RABBITMQ_PORT")?.toIntOrNull() ?: 5672,
            user = getEnv("RABBITMQ_USER") ?: "guest",
            password = getEnv("RABBITMQ_PASSWORD") ?: "guest",
        )
    }
    single<NotificationService> {
        NotificationServiceImpl(
            serviceAccountPath = getEnv("SERVICE_ACCOUNT_PATH") ?: "firebase-admin-sdk.json",
        )
    }

    // Handlers
    single { SendNotificationHandler(get()) }

    // Routes
    single { RoutesDependencies(get()) }
}

Understanding the Module Structure

  1. Extension Property on Application:
    • val Application.mainModule makes the module available as a property
    • Provides access to the Application scope within the module
    • Enables coroutine scope sharing with the application
  2. Module DSL:
    • module { } is Koin’s DSL for defining a dependency module
    • All service definitions go inside this block
  3. Single vs Factory:
    • single<T> { } creates a singleton (one instance for the entire application)
    • All dependencies here use single for resource efficiency

Service Definitions

MessageBroker:

single<MessageBroker> {
    RabbitMqMessageBroker(
        coroutineScope = this@mainModule,
        host = getEnv("RABBITMQ_HOST") ?: "localhost",
        port = getEnv("RABBITMQ_PORT")?.toIntOrNull() ?: 5672,
        user = getEnv("RABBITMQ_USER") ?: "guest",
        password = getEnv("RABBITMQ_PASSWORD") ?: "guest",
    )
}

NotificationService:

single<NotificationService> {
    NotificationServiceImpl(
        serviceAccountPath = getEnv("SERVICE_ACCOUNT_PATH") ?: "firebase-admin-sdk.json",
    )
}

Handler Definitions

SendNotificationHandler:

single { SendNotificationHandler(get()) }

Route Dependencies

RoutesDependencies:

single { RoutesDependencies(get()) }

Koin Configuration

File: src/commonMain/kotlin/me/nathanfallet/ktornativeworkertutorial/config/Koin.kt

fun Application.configureKoin() {
    install(Koin) {
        modules(mainModule)
    }
}

This function:

Using Koin in Configuration

In Message Broker Setup (src/commonMain/kotlin/me/nathanfallet/ktornativeworkertutorial/config/MessageBroker.kt):

fun Application.configureMessageBroker() = runBlocking {
    val messageBroker by inject<MessageBroker>()
    messageBroker.initialize()
    messageBroker.startConsuming(Constants.RABBITMQ_QUEUE, get<SendNotificationHandler>())
}

Two ways to get dependencies:

In Routing Setup (src/commonMain/kotlin/me/nathanfallet/ktornativeworkertutorial/config/Routing.kt):

fun Application.configureRouting() {
    routing {
        registerRoutes(get())
    }
}

Application Bootstrap

File: src/commonMain/kotlin/me/nathanfallet/ktornativeworkertutorial/Application.kt

suspend fun Application.module() {
    configureKoin()
    configureMessageBroker()
    configureSerialization()
    configureRouting()
}

Order is critical:

  1. configureKoin(): Must be first - sets up the DI container
  2. configureMessageBroker(): Uses Koin to get MessageBroker and handlers
  3. configureSerialization(): Sets up JSON handling (no dependencies)
  4. configureRouting(): Uses Koin to get RoutesDependencies

Environment Configuration

The project uses environment variables for configuration, accessed through getEnv():

Variable Default Purpose
RABBITMQ_HOST localhost RabbitMQ server hostname
RABBITMQ_PORT 5672 RabbitMQ server port
RABBITMQ_USER guest RabbitMQ username
RABBITMQ_PASSWORD guest RabbitMQ password
SERVICE_ACCOUNT_PATH firebase-admin-sdk.json Path to Firebase service account JSON

This approach follows the 12-factor app methodology for configuration management.

Dependency Graph

Here’s the complete dependency graph:

Application
└── MainModule
    ├── MessageBroker (RabbitMqMessageBroker)
    │   └── coroutineScope (from Application)
    ├── NotificationService (NotificationServiceImpl)
    │   └── serviceAccountPath (from environment)
    ├── SendNotificationHandler
    │   └── NotificationService ← injected
    └── RoutesDependencies
        └── MessageBroker ← injected

Benefits of Dependency Injection

  1. Testability:
    • Easy to mock dependencies in tests
    • Can create test modules with mock implementations
    • No global state or singletons to reset
  2. Modularity:
    • Components are loosely coupled
    • Can swap implementations without changing consumers
    • Clear contracts via interfaces
  3. Configuration Management:
    • Centralized dependency configuration
    • Easy to see all service instantiations
    • Simple to add new dependencies
  4. Type Safety:
    • Compile-time dependency resolution
    • IDE support for refactoring
    • Clear error messages for missing dependencies
  5. Multiplatform Support:
    • Koin works across all Kotlin platforms
    • Same DI code for JVM and Native
    • Platform-specific implementations via expect/actual

Common Patterns

Interface Binding:

single<Interface> { ConcreteImplementation(...) }

Constructor Injection:

single { SomeService(get(), get()) }

Named Dependencies (if needed):

single(named("primary")) { PrimaryService() }
single(named("secondary")) { SecondaryService() }

Lazy Injection:

val service by inject<Service>()

Direct Retrieval:

val service = get<Service>()

Summary

The Koin integration demonstrates:

In the final part, we’ll explore how to test and demo the complete application.