Skip to content

Type-Safe Routing DSL (Mini-Ktor)

Build a type-safe routing DSL that mimics Ktor’s routing { } block — the kind of API where nested route("/api/v1") { ... } calls accumulate path prefixes, get/post/etc. register handlers, and use(middleware) wraps everything. The point is to learn how Kotlin’s lambda-with-receiver and @DslMarker turn ordinary function calls into a clean, statically-typed configuration language.

The finished DSL lets you write this:

val router = router {
get("/health") {
respond(200, "OK")
}
route("/api/v1") {
// Middleware applied to all routes in this block
use(loggingMiddleware)
use(authMiddleware)
get("/users") {
val users = listOf("Alice", "Bob", "Charlie")
respondJson(200, users)
}
get("/users/{id}") {
val id = pathParam("id")
respondJson(200, mapOf("id" to id, "name" to "User $id"))
}
post("/users") {
val body = requestBody()
respondJson(201, mapOf("created" to body))
}
route("/admin") {
use(adminOnlyMiddleware)
delete("/users/{id}") {
val id = pathParam("id")
respond(204, "")
}
}
}
}
  1. A @DslMarker annotation to prevent scope leakage between nested blocks.
  2. Route nesting with path-prefix accumulation (so the inner delete above resolves to /api/v1/admin/users/{id}).
  3. HTTP method handlers: get, post, put, delete, patch.
  4. Path parameters extracted from {param} placeholders.
  5. Middleware support via use() — middleware functions wrap handlers.
  6. A request/response context exposing respond(), respondJson(), pathParam(), and requestBody().
  7. Route matching — given a method + path, find and execute the matching handler.

If you’ve used Express in TS (app.use(...), router.get(...)) or Go’s http.ServeMux / chi, this is the same idea — but Kotlin lets the route tree be a nested block of typed builder calls instead of a flat list of registrations.

A single Gradle module. One file defines the whole DSL; Main.kt exercises it.

  • Directoryrouting-dsl/
    • build.gradle.kts deps + build config
    • settings.gradle.kts project name
    • Directorysrc/
      • Directorymain/kotlin/com/example/dsl/
        • RoutingDsl.kt the DSL: builders, context, router, middleware
        • Main.kt demo wiring + simulated requests
      • Directorytest/kotlin/com/example/dsl/
        • RoutingDslTest.kt behavior tests

A plain Kotlin/JVM application. The only runtime dependency is kotlinx-serialization-json, used by respondJson to turn any value into a JSON body.

build.gradle.kts
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.serialization") version "2.0.0"
application
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter:5.10.3")
}
application {
mainClass.set("com.example.dsl.MainKt")
}
tasks.test {
useJUnitPlatform()
}
kotlin {
jvmToolchain(21)
}

This is the whole engine. We’ll walk it top to bottom — it reads in four layers: the marker + HTTP types, the request context, the route builder, and the router that matches and dispatches.

@DslMarker is the key trick. Annotate your own annotation with it, then put that annotation on every DSL scope class (RequestContext, RouteBuilder). Now Kotlin forbids implicit access to an outer receiver from an inner block — so inside a nested route("/admin") { ... } you can’t accidentally call the outer builder’s get(...) without qualifying it. That’s what stops the “scope leakage” the requirements mention.

src/main/kotlin/com/example/dsl/RoutingDsl.kt
package com.example.dsl
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
// --- DSL Marker ---
@DslMarker
annotation class RoutingDsl
// --- HTTP Types ---
enum class HttpMethod { GET, POST, PUT, DELETE, PATCH }
data class HttpRequest(
val method: HttpMethod,
val path: String,
val body: String = "",
val headers: Map<String, String> = emptyMap()
)
data class HttpResponse(
val statusCode: Int,
val body: String,
val headers: Map<String, String> = emptyMap()
)

2. The request context — what this is inside a handler

Section titled “2. The request context — what this is inside a handler”

Every handler lambda runs with a RequestContext as its receiver. That’s why, inside get("/x") { ... }, you can just call pathParam("id") or respond(...) without a qualifier — they’re methods on the implicit this. Note respondJson is inline fun <reified T>: the reified type parameter lets Json.encodeToString(data) serialize any T without you passing a serializer explicitly. The response property has a private set, so handlers mutate it only through respond / respondJson.

src/main/kotlin/com/example/dsl/RoutingDsl.kt
@RoutingDsl
class RequestContext(
val request: HttpRequest,
val pathParams: Map<String, String> = emptyMap()
) {
var response: HttpResponse = HttpResponse(200, "")
private set
fun pathParam(name: String): String =
pathParams[name] ?: error("Path parameter '$name' not found")
fun requestBody(): String = request.body
fun header(name: String): String? = request.headers[name]
fun respond(statusCode: Int, body: String) {
response = HttpResponse(statusCode, body)
}
inline fun <reified T> respondJson(statusCode: Int, data: T) {
val json = Json.encodeToString(data)
response = HttpResponse(
statusCode = statusCode,
body = json,
headers = mapOf("Content-Type" to "application/json")
)
}
}

Two type aliases name the two function shapes the DSL is built from. A Middleware takes the context plus a next continuation (call it to proceed, skip it to short-circuit). A Handler is a RequestContext.() -> Unit — a lambda-with-receiver, which is exactly what makes respond(...) callable unqualified inside the block.

src/main/kotlin/com/example/dsl/RoutingDsl.kt
typealias Middleware = (RequestContext, () -> Unit) -> Unit
typealias Handler = RequestContext.() -> Unit
data class RouteEntry(
val method: HttpMethod,
val pathPattern: String,
val handler: Handler,
val middleware: List<Middleware>
)

3. The route builder — how the DSL composes routes

Section titled “3. The route builder — how the DSL composes routes”

RouteBuilder is the receiver of the router { ... } and route(...) { ... } blocks. The get/post/put/delete/patch methods each take a path plus a Handler and register a RouteEntry. The interesting one is route: it takes a block: RouteBuilder.() -> Unit, creates a child builder seeded with the accumulated pathPrefix and the parent’s middleware, runs the block against it with nestedBuilder.block(), and folds the child’s routes back up. That recursion is what produces the flattened /api/v1/admin/users/{id} paths and the additive middleware lists.

src/main/kotlin/com/example/dsl/RoutingDsl.kt
@RoutingDsl
class RouteBuilder(
private val pathPrefix: String = "",
private val parentMiddleware: List<Middleware> = emptyList()
) {
private val routes = mutableListOf<RouteEntry>()
private val middleware = mutableListOf<Middleware>()
fun use(mw: Middleware) {
middleware.add(mw)
}
fun get(path: String, handler: Handler) = addRoute(HttpMethod.GET, path, handler)
fun post(path: String, handler: Handler) = addRoute(HttpMethod.POST, path, handler)
fun put(path: String, handler: Handler) = addRoute(HttpMethod.PUT, path, handler)
fun delete(path: String, handler: Handler) = addRoute(HttpMethod.DELETE, path, handler)
fun patch(path: String, handler: Handler) = addRoute(HttpMethod.PATCH, path, handler)
fun route(path: String, block: RouteBuilder.() -> Unit) {
val nestedBuilder = RouteBuilder(
pathPrefix = pathPrefix + normalizePath(path),
parentMiddleware = parentMiddleware + middleware
)
nestedBuilder.block()
routes.addAll(nestedBuilder.build())
}
private fun addRoute(method: HttpMethod, path: String, handler: Handler) {
val fullPath = pathPrefix + normalizePath(path)
routes.add(RouteEntry(method, fullPath, handler, parentMiddleware + middleware))
}
internal fun build(): List<RouteEntry> = routes.toList()
private fun normalizePath(path: String): String {
val trimmed = path.trimEnd('/')
return if (trimmed.startsWith("/")) trimmed else "/$trimmed"
}
}

4. The router — matching and the middleware chain

Section titled “4. The router — matching and the middleware chain”

router { } is the top-level entry point: it builds a root RouteBuilder, runs your block against it, and wraps the resulting flat list in a Router. The Router.handle method finds a matching entry, builds a RequestContext, then assembles the middleware chain.

That chain is the elegant part. entry.middleware.foldRight(...) starts from the innermost function { entry.handler(context) } and wraps it outward: each middleware becomes { mw(context, next) }, where next is everything inside it. Calling chain() runs the outermost middleware first; each one decides whether to call next() (proceed) or not (short-circuit, e.g. an auth failure).

src/main/kotlin/com/example/dsl/RoutingDsl.kt
class Router(private val routes: List<RouteEntry>) {
fun handle(request: HttpRequest): HttpResponse {
val matchResult = findRoute(request.method, request.path)
?: return HttpResponse(404, """{"error":"Not Found","path":"${request.path}"}""")
val (entry, pathParams) = matchResult
val context = RequestContext(request, pathParams)
// Build the middleware chain
val chain = entry.middleware.foldRight({ entry.handler(context) }) { mw, next ->
{ mw(context, next) }
}
chain()
return context.response
}
private fun findRoute(method: HttpMethod, path: String): Pair<RouteEntry, Map<String, String>>? {
val normalizedPath = path.trimEnd('/')
for (entry in routes) {
if (entry.method != method) continue
val params = matchPath(entry.pathPattern, normalizedPath)
if (params != null) return entry to params
}
return null
}
/**
* Match a path pattern like "/users/{id}/posts/{postId}" against
* an actual path like "/users/42/posts/7".
* Returns extracted parameters or null if no match.
*/
private fun matchPath(pattern: String, path: String): Map<String, String>? {
val patternParts = pattern.split("/").filter { it.isNotEmpty() }
val pathParts = path.split("/").filter { it.isNotEmpty() }
if (patternParts.size != pathParts.size) return null
val params = mutableMapOf<String, String>()
for ((patternPart, pathPart) in patternParts.zip(pathParts)) {
if (patternPart.startsWith("{") && patternPart.endsWith("}")) {
val paramName = patternPart.substring(1, patternPart.length - 1)
params[paramName] = pathPart
} else if (patternPart != pathPart) {
return null
}
}
return params
}
fun printRoutes() {
println("Registered routes:")
routes.forEach { entry ->
val mwCount = entry.middleware.size
val mwInfo = if (mwCount > 0) " [$mwCount middleware]" else ""
println(" ${entry.method.name.padEnd(6)} ${entry.pathPattern}$mwInfo")
}
}
}
// --- Top-level DSL entry point ---
fun router(block: RouteBuilder.() -> Unit): Router {
val builder = RouteBuilder()
builder.block()
return Router(builder.build())
}

Path matching is deliberately simple: split both pattern and path on /, compare segment by segment, and treat any {name} segment as a wildcard that captures into the params map. Different segment counts mean no match — there’s no greedy wildcard.

Three sample middleware show the pattern. loggingMiddleware and timingMiddleware are plain Middleware values that call next() and do work before/after. authMiddleware is a factory — a function returning a Middleware closure over its validToken — and it short-circuits by calling ctx.respond(401, ...) and not calling next().

src/main/kotlin/com/example/dsl/RoutingDsl.kt
val loggingMiddleware: Middleware = { ctx, next ->
println("[LOG] ${ctx.request.method} ${ctx.request.path}")
next()
println("[LOG] -> ${ctx.response.statusCode}")
}
val timingMiddleware: Middleware = { ctx, next ->
val start = System.nanoTime()
next()
val elapsed = (System.nanoTime() - start) / 1_000_000.0
println("[TIMING] ${ctx.request.method} ${ctx.request.path} took ${elapsed}ms")
}
fun authMiddleware(validToken: String): Middleware = { ctx, next ->
val token = ctx.header("Authorization")
if (token == "Bearer $validToken") {
next()
} else {
ctx.respond(401, """{"error":"Unauthorized"}""")
}
}

Main.kt builds a small app with the DSL, prints the registered routes, then fires a series of simulated HttpRequests through app.handle(...). Notice how the nested route("/admin") inside route("/api/v1") inherits both the logging and timing middleware and adds auth on top.

src/main/kotlin/com/example/dsl/Main.kt
fun main() {
// Build the router using the DSL
val app = router {
get("/health") {
respond(200, "OK")
}
route("/api/v1") {
use(loggingMiddleware)
use(timingMiddleware)
get("/users") {
respondJson(200, listOf("Alice", "Bob", "Charlie"))
}
get("/users/{id}") {
val id = pathParam("id")
respondJson(200, mapOf("id" to id, "name" to "User $id"))
}
post("/users") {
val body = requestBody()
respondJson(201, mapOf("created" to true, "body" to body))
}
route("/admin") {
use(authMiddleware("secret-token"))
delete("/users/{id}") {
val id = pathParam("id")
respond(204, "")
println("Deleted user $id")
}
get("/stats") {
respondJson(200, mapOf(
"totalUsers" to 42,
"activeUsers" to 37
))
}
}
}
}
// Print registered routes
app.printRoutes()
println()
// Simulate requests
println("=== GET /health ===")
val r1 = app.handle(HttpRequest(HttpMethod.GET, "/health"))
println("Response: ${r1.statusCode} ${r1.body}")
println()
println("=== GET /api/v1/users/42 ===")
val r3 = app.handle(HttpRequest(HttpMethod.GET, "/api/v1/users/42"))
println("Response: ${r3.statusCode} ${r3.body}")
println()
println("=== DELETE /api/v1/admin/users/5 (no auth) ===")
val r5 = app.handle(HttpRequest(HttpMethod.DELETE, "/api/v1/admin/users/5"))
println("Response: ${r5.statusCode} ${r5.body}")
println()
println("=== DELETE /api/v1/admin/users/5 (with auth) ===")
val r6 = app.handle(HttpRequest(
HttpMethod.DELETE,
"/api/v1/admin/users/5",
headers = mapOf("Authorization" to "Bearer secret-token")
))
println("Response: ${r6.statusCode} ${r6.body}")
println()
println("=== GET /nonexistent ===")
val r7 = app.handle(HttpRequest(HttpMethod.GET, "/nonexistent"))
println("Response: ${r7.statusCode} ${r7.body}")
}

The unauthenticated DELETE returns 401 because authMiddleware short-circuits; the same request with the Authorization: Bearer secret-token header reaches the handler and returns 204.

  1. Run the demo main, which builds the router and fires the simulated requests:

    Terminal window
    ./gradlew run
  2. Run the test suite — it covers route matching, 404s, nested prefixes, path params, middleware ordering, short-circuiting, and trailing-slash normalization:

    Terminal window
    ./gradlew test

The tests are worth reading as a spec. For example, middleware is applied in order asserts the chain runs mw1-before, mw2-before, handler, mw2-after, mw1-after — confirming the foldRight produces correct onion-style nesting.