Exposed R2DBC Workshop: Ktor Multi-Tenant Routing Patterns

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.
| Module | Problem |
|---|---|
10-multi-tenant/07-multitenant-ktor | Switch tenant schemas inside one R2DBC database |
11-high-performance/06-routing-datasource-ktor-r2dbc | Select 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.
Stop Raw Headers At The Plugin
Section titled “Stop Raw Headers At The Plugin”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.

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.
Switch Schema Inside The Transaction
Section titled “Switch Schema Inside The Transaction”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.
Route Datasource With A Value
Section titled “Route Datasource With A Value”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.
Do Not Copy The Carrier
Section titled “Do Not Copy The Carrier”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.

| Question | Spring/JDBC path | Ktor/R2DBC path |
|---|---|---|
| Where is request state stored? | ThreadLocal, ScopedValue, ReactorContext | ApplicationCall.attributes |
| Where are headers parsed? | Filter, interceptor, AOP boundary | Ktor plugin |
| What reaches DB selection? | Tenant context or routing key | Tenants.Tenant, RoutingRequest |
| Transaction boundary | JDBC transaction / Spring transaction operator | suspendTransaction, suspendTransactionWithTenant |
| Tests that matter | Tenant isolation, cleanup, read/write separation | Call 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.
Try The Examples
Section titled “Try The Examples”The schema-per-tenant example separates actor data by tenant.
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.
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.
Source Links
Section titled “Source Links”- Ktor schema-per-tenant README: 10-multi-tenant/07-multitenant-ktor/README.md
TenantPlugin.kt: sourceTenantTransaction.kt: source- Ktor routing datasource README: 11-high-performance/06-routing-datasource-ktor-r2dbc/README.md
RoutingRequestPlugin.kt: sourceRoutingMarkerRepository.kt: source- Issue #34: Blog: Ktor and Exposed R2DBC multi-tenant routing patterns
Comments
Leave a note or reaction with your GitHub account.