Skip to main content
Version: v0.25.0 (Latest)

Ktor Server Integration

The IDK provides a Ktor server plugin, KotlinInjectPlugin, that bridges the IDK's Metro-powered three-scope architecture (App, User, Session) with Ktor's request handling pipeline. On every incoming request, the plugin automatically resolves the tenant and principal, creates the matching DI scopes, and makes all scoped services accessible from your route handlers.

Installation

Add the Ktor server plugin to your dependencies:

build.gradle.kts
dependencies {
implementation("com.sphereon.idk:ktor-server-kotlin-inject:0.25.0")
}

Plugin Setup

Install the KotlinInjectPlugin in your Ktor application and provide your root AppGraph:

import com.sphereon.ktor.server.inject.KotlinInjectPlugin
import io.ktor.server.application.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*

fun main() {
embeddedServer(CIO, port = 8080) {
configureKotlinInject()
configureRouting()
}.start(wait = true)
}

fun Application.configureKotlinInject() {
val appGraph = MyAppGraph.init(
application = this,
appId = "my-app",
profile = "production",
version = "1.0.0"
)

install(KotlinInjectPlugin) {
this.appGraph = appGraph
}
}

Configuration Options

The plugin is configured through KotlinInjectConfiguration:

OptionDefaultDescription
appGraph(required)Your root IDK AppGraph
tenantHeader"X-Tenant-ID"HTTP header from which the tenant ID is extracted
principalHeader"X-User-ID"HTTP header from which the principal ID is extracted
tenantResolverDefaultTenantResolverCustom strategy for resolving the tenant from a request
principalResolverDefaultPrincipalResolverCustom strategy for resolving the principal from a request

Request Lifecycle

When a request arrives, the plugin's UserContextInterceptor runs before your route handlers:

  1. Resolve tenant: calls tenantResolver.resolve(call) to extract a TenantInput
  2. Resolve principal: calls principalResolver.resolve(call) to extract a PrincipalInput
  3. Create or retrieve UserContext: finds or creates the UserContextInstance for this tenant+principal pair
  4. Create Session: generates a unique session ID and creates a SessionInstance
  5. Attach to call: stores both as a RequestScopedContext in the call attributes

All of this happens automatically. By the time your handler runs, scoped services are ready.

Accessing Services in Routes

The plugin provides extension properties and functions on ApplicationCall for accessing services at each scope level.

Extension Properties

routing {
get("/info") {
// Root DI component
val appGraph = call.appGraph

// User context for this request (tenant + principal)
val user = call.userInstance
val tenant = user.context.tenant
val principal = user.context.principal

// Session context for this request
val session = call.sessionInstance
val sessionId = session.sessionId

call.respondText("Tenant: $tenant, Principal: $principal, Session: $sessionId")
}
}

Scope-Typed Service Access

Use the generic extension functions to retrieve any service from the appropriate DI scope:

routing {
get("/config") {
// App-scoped singleton
val configService = call.getAppService<AppConfigService>()
val appName = configService.getAppName()
call.respondText("App: $appName")
}

get("/user-info") {
// User-scoped service (tenant + principal isolated)
val logManager = call.getUserService<UserContextLogManager>()
logManager.withTag("API").info("User endpoint accessed")

val tenantConfig = call.getUserService<TenantConfigService>()
val apiKey = tenantConfig.getProperty("api.subscription.key", String::class)
call.respondText("API Key: $apiKey")
}

get("/session-info") {
// Session-scoped service (per-request)
val keyManager = call.getSessionService<KeyManagerService>()
val httpClientFactory = call.getSessionService<HttpClientFactory>()
// ...
}
}

If a service is not found in the requested scope, the functions throw IllegalStateException with a diagnostic message listing available services.

Tenant and Principal Resolvers

Default Resolvers

The DefaultTenantResolver extracts the tenant ID from an HTTP header (default: X-Tenant-ID). If the header is missing, it falls back to "default".

The DefaultPrincipalResolver checks three sources in order:

  1. The configured HTTP header (default: X-User-ID)
  2. Ktor's authentication plugin (UserIdPrincipal, JVM only)
  3. Falls back to "anonymous"

Custom Resolvers

Implement the TenantResolver or PrincipalResolver interfaces to resolve from tokens, cookies, path segments, or any other source:

class JwtTenantResolver : TenantResolver {
override fun resolve(call: ApplicationCall): TenantInput {
val jwt = call.request.headers["Authorization"]
?.removePrefix("Bearer ")
?.let { decodeJwt(it) }

val tenantId = jwt?.getClaim("tenant_id")?.asString() ?: "default"
return DefaultTenantInputString(tenantId)
}
}

class JwtPrincipalResolver : PrincipalResolver {
override fun resolve(call: ApplicationCall): PrincipalInput {
val jwt = call.request.headers["Authorization"]
?.removePrefix("Bearer ")
?.let { decodeJwt(it) }

val principalId = jwt?.subject ?: "anonymous"
return DefaultPrincipalInputString(principalId)
}
}

Install them when configuring the plugin:

install(KotlinInjectPlugin) {
appGraph = myAppGraph
tenantResolver = JwtTenantResolver()
principalResolver = JwtPrincipalResolver()
}

Universal HTTP Adapters

For API endpoints that need to work across multiple frameworks, use the Universal HTTP Adapter pattern. The adapter system converts between Ktor's request/response model and a framework-agnostic GenericHttpRequest/GenericHttpResponse.

Application-Level Installation

Mount adapters at the application root or under a path prefix:

fun Application.module() {
install(KotlinInjectPlugin) {
appGraph = myAppGraph
}

installUniversalHttpAdapters {
pathPrefix = "/api"
verboseLogging = true
errorHandler = { call, error ->
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to error.message))
}
}
}

Route-Level Installation

Or mount adapters within a specific route:

routing {
route("/api/v1") {
installUniversalHttpAdapters()
}
}

How It Works

  1. A catch-all route captures all unmatched requests under the configured prefix
  2. The Ktor ApplicationRequest is converted to a GenericHttpRequest (with lazy header/body evaluation for performance)
  3. The HttpAdapterDispatcher (from SessionScope) routes the request to the matching registered HttpAdapter
  4. The adapter's GenericHttpResponse is converted back to a Ktor response

Configuration Options

OptionDefaultDescription
pathPrefixnullOptional prefix for the catch-all route
verboseLoggingfalseLog incoming requests
errorHandlernullCustom error handler for adapter failures

See the EDK Universal HTTP Adapter documentation for details on creating adapter implementations.

YAML Configuration Integration

On JVM, the plugin automatically registers Ktor YAML property sources that load application.yml from the working directory, config directory, or classpath. These sources participate in the IDK configuration system at all three scope levels.

The YAML file uses direct config keys. Scope is determined by the directory the file lives in (config/ for app-level, config/tenant/{id}/ for tenant-level, etc.):

application.yml
api:
base-url: https://api.example.com
timeout-ms: 30000
config/tenant/acme/application.yml
api:
subscription-key: acme-key-123
config/tenant/acme/principal/alice/application.yml
ui:
theme: dark
Config locationScopeResolved as
config/APPAvailable to all tenants
config/tenant/{tenantId}/TENANTAvailable within that tenant's context
config/tenant/{tenantId}/principal/{principalId}/PRINCIPALAvailable within that principal's context

The YAML sources are registered via the PropertySourceContribution mechanism with Order.HIGH priority and are auto-discovered at startup.

Next Steps