Skip to content

Exposed R2DBC Workshop: Ktor Multi-Tenant Routing Patterns

Robotic builders arranging X-TENANT-ID and route key blocks between Ktor, Exposed, and R2DBC components on a 3D workbench
A tenant header is only the starting point. Do not let a raw string travel all the way to schema selection.

Multi-tenant examples look simple from a distance. A request has X-TENANT-ID, and the service should use the korean schema or the english schema. That can sound like one header read and one SET SCHEMA.

That shortcut becomes risky quickly. Raw header strings drift into repositories, request-scoped state starts acting like global state, and a reused R2DBC connection may still carry the previous tenant schema. The worst multi-tenancy failure is not always a loud crash. It is quietly reading the wrong tenant’s data.

This post covers two Ktor examples from exposed-r2dbc-workshop.

ModuleProblem
10-multi-tenant/07-multitenant-ktorSwitch tenant schemas inside one R2DBC database
11-high-performance/06-routing-datasource-ktor-r2dbcSelect an R2DBC target from tenant + read/write routing

The shapes differ, but the rule is the same: validate headers in the plugin, then pass only validated values to handlers and repositories.

The Ktor example stores request-scoped tenant state in ApplicationCall.attributes. The WebFlux example teaches ReactorContext. Spring MVC/JDBC examples teach ThreadLocal or AOP boundaries. The Ktor version does not copy those carriers. It uses the Ktor call as the request boundary.

Sequence diagram where a Ktor plugin validates X-TENANT-ID, stores a tenant enum in call attributes, and a tenant transaction helper switches the R2DBC schema
Raw headers stop at the plugin. Schema selection receives a validated enum.

The plugin makes that boundary explicit.

val TenantPlugin = createApplicationPlugin(name = "TenantPlugin") {
onCall { call ->
val rawValues = call.request.headers.getAll(TenantHeader).orEmpty()
val tenantId = normalizeTenantHeader(rawValues)
val tenant = Tenants.findById(tenantId)
?: throw InvalidTenantException("Unknown tenant id: $tenantId")
call.attributes.put(TenantAttributeKey, tenant)
}
}

The important part is that TenantAttributeKey stores Tenants.Tenant, not a string. X-TENANT-ID: korean does not escape the plugin as raw text. If repositories or transaction helpers start parsing "korean" again, validation spreads across the codebase and fixes become harder.

Duplicate headers are handled deliberately.

private fun normalizeTenantHeader(values: List<String>): String {
if (values.isEmpty()) {
throw InvalidTenantException("Missing tenant id header: $TenantHeader")
}
val normalized = values
.flatMap { it.split(',') }
.map { it.trim() }
if (normalized.any { it.isEmpty() }) {
throw InvalidTenantException("Blank tenant id header: $TenantHeader")
}
val distinct = normalized.toSet()
if (distinct.size != 1) {
throw InvalidTenantException("Conflicting tenant id headers: $TenantHeader")
}
return distinct.single()
}

Repeated equivalent values are accepted after trimming. Conflicting korean and english values return 400.

Once the tenant is known, the next question is where to switch schema. The example keeps that decision inside a tenant transaction helper.

suspend fun <T> suspendTransactionWithTenant(
tenant: Tenants.Tenant,
db: R2dbcDatabase,
readOnly: Boolean = false,
statement: suspend R2dbcTransaction.() -> T,
): T =
suspendTransaction(db = db, readOnly = readOnly) {
SchemaUtils.setSchema(getSchemaDefinition(tenant))
statement()
}

That helper matters because schema is connection state. If an R2DBC pool reuses a connection, the previous schema can still matter unless the transaction boundary resets it for the current tenant.

The tests target that risk directly.

val observed = (1..20).map { index ->
val tenant = if (index % 2 == 0) Tenant.ENGLISH else Tenant.KOREAN
client.get("/actors/2") {
header(TenantHeader, tenant.id)
}.body<ActorRecord>().firstName
}

The test constrains the pool to size 1 and alternates tenants. 브래드 and Brad must not mix. Without this kind of test, schema leakage can hide for a long time because the endpoint still returns a valid actor.

The chapter 11 Ktor R2DBC example demonstrates target selection instead of schema switching. It reads X-Tenant-Id, X-Read-Only, and the /readonly path, then selects one of default:rw, default:ro, acme:rw, or acme:ro.

The same rule applies: parse once in the plugin and pass a validated RoutingRequest.

private fun ApplicationCall.resolveRoutingRequest(): RoutingRequest {
val tenant = request.headers[TENANT_HEADER]
?.trim()
?.takeUnless { it.isBlank() }
?.let { tenantId ->
RoutingTenant.from(tenantId)
?: throw InvalidRoutingRequestException("Unknown tenant id: $tenantId")
}
?: RoutingTenant.DEFAULT
val explicitReadOnly = request.headers[READ_ONLY_HEADER]
?.trim()
?.let { raw ->
raw.toBooleanStrictOrNull()
?: throw InvalidRoutingRequestException("$READ_ONLY_HEADER must be true or false")
}
?: false
return RoutingRequest(
tenant = tenant,
readOnly = explicitReadOnly || request.path().endsWith("/readonly"),
)
}

RoutingRequest is small, but it is the routing contract.

data class RoutingRequest(
val tenant: RoutingTenant,
val readOnly: Boolean,
) {
val mode: RoutingMode = if (readOnly) RoutingMode.READ_ONLY else RoutingMode.READ_WRITE
val key: String = "${tenant.id}:${mode.suffix}"
val expectedMarker: String = "${tenant.id}-${mode.suffix}"
fun asReadWrite(): RoutingRequest = copy(readOnly = false)
}

The repository does not know headers or paths. It selects a database through route.key.

suspend fun findMarker(route: RoutingRequest): RoutingMarkerResponse {
awaitReady()
return route.toResponse(findMarkerValue(route))
}
private suspend fun findMarkerValue(route: RoutingRequest): String? =
suspendTransaction(db = registry.database(route), readOnly = true) {
RoutingMarkerTable
.selectAll()
.singleOrNull()
?.getOrNull(RoutingMarkerTable.marker)
}

Tests verify the actual target with marker rows, not just response fields.

val pathResponse = client.get("/routing/marker/readonly") {
header(TENANT_HEADER, "acme")
}.body<RoutingMarkerResponse>()
val headerResponse = client.get("/routing/marker") {
header(TENANT_HEADER, "acme")
header(READ_ONLY_HEADER, "true")
}.body<RoutingMarkerResponse>()
pathResponse.marker shouldBeEqualTo "acme-ro"
headerResponse.marker shouldBeEqualTo "acme-ro"

PATCH /routing/marker rejects X-Read-Only: true instead of silently overriding it. Ambiguous routing input should fail early.

When moving from Spring/JDBC examples to Ktor/R2DBC examples, the common mistake is to copy the context carrier. ThreadLocal, ReactorContext, and Ktor call attributes can solve similar problems, but they are not the same mechanism. Copy the invariant, not the carrier.

Strategy map comparing how Spring JDBC and Ktor Exposed R2DBC solve tenant routing with different runtime boundaries
The carrier changes with the runtime. The invariant does not: validate, pass explicit values, and test isolation.
QuestionSpring/JDBC pathKtor/R2DBC path
Where is request state stored?ThreadLocal, ScopedValue, ReactorContextApplicationCall.attributes
Where are headers parsed?Filter, interceptor, AOP boundaryKtor plugin
What reaches DB selection?Tenant context or routing keyTenants.Tenant, RoutingRequest
Transaction boundaryJDBC transaction / Spring transaction operatorsuspendTransaction, suspendTransactionWithTenant
Tests that matterTenant isolation, cleanup, read/write separationCall attribute isolation, pool reuse, target marker checks

Ktor services should not start with “let’s use ThreadLocal here too.” The point of the example is coroutine-safe request routing. Resolve once, store it on the call, and pass the value explicitly.

The schema-per-tenant example separates actor data by tenant.

Terminal window
curl -H 'X-TENANT-ID: korean' http://localhost:8080/actors/2
# {"id":2,"firstName":"브래드","lastName":"피트",...}
curl -H 'X-TENANT-ID: english' http://localhost:8080/actors/2
# {"id":2,"firstName":"Brad","lastName":"Pitt",...}

The routing-datasource example changes the selected target marker.

Terminal window
curl -H 'X-Tenant-Id: acme' http://localhost:8080/routing/marker
# {"tenant":"acme","readOnly":false,"routingKey":"acme:rw","marker":"acme-rw"}
curl -H 'X-Tenant-Id: acme' http://localhost:8080/routing/marker/readonly
# {"tenant":"acme","readOnly":true,"routingKey":"acme:ro","marker":"acme-ro"}

Those marker values are not decoration. The tests use them to prove that the request reached the intended database target.

Comments

Leave a note or reaction with your GitHub account.