How would you organize a Ktor service's routing once it spans dozens of endpoints across features?
answer
- The receiver is the extension point
- One file per feature, beside its code
- The central block is a table of contents
- Dependencies arrive as parameters
- Handlers parse, delegate, respond
basics
~20 sSplit the tree into Route extension functions, one per feature — fun Route.orderRoutes(service: OrderService) — living beside that feature's code, and call them from a single routing block that owns shared prefixes. Handlers stay thin: parse, delegate, respond.
solid answer
~40 sThe unit of modularity is `fun Route.orderRoutes(...)`: an extension on `Route` that declares one feature's endpoints and takes its dependencies as parameters. Each feature package owns its file, and the application's single `routing { }` block becomes a table of contents — `route("/api/v1") { orderRoutes(orderService); userRoutes(userService) }` — where the shared prefix and any cross-cutting grouping are declared once rather than repeated in every path string. Two rules keep this from decaying. Handlers stay thin: parse and validate input, call a service, respond; no business logic or SQL in the DSL. And route functions declare routes only — they must not construct their own dependencies, or wiring becomes invisible and tests cannot substitute anything. Test each feature's routes with `testApplication`, installing just that function rather than the whole application.
code
kotlin · 20 lines// order/OrderRoutes.kt
fun Route.orderRoutes(orders: OrderService) {
route("/orders") {
get { call.respond(orders.list()) }
get("/{id}") {
val id = call.parameters.getOrFail<Long>("id")
call.respond(orders.byId(id))
}
}
}
// Application.kt
fun Application.module() {
routing {
route("/api/v1") {
orderRoutes(orderService)
userRoutes(userService)
}
}
}go deeper
Know that route declarations can be moved into functions with a Route receiver and called from the main routing block, so one file does not hold every endpoint.
Explain the pattern precisely: extension functions on Route per feature, dependencies as parameters, shared prefix declared once in the outer block.
Show judgment on a real codebase — name the decay smells, say which you fix first, and connect the wiring rule to being able to test one feature in isolation.
Own the convention across services: where route code lives relative to feature code, how versioning is expressed structurally, and what the team's handler-thinness rule is.
## Why this question gets asked Ktor's DSL is pleasant for twenty lines and unmanageable at eight hundred. Because there is no annotation scanning and no per-controller convention, nothing in the framework forces a structure on you — the structure is a choice your team makes, and interviewers ask this to find out whether you have made it deliberately or let a single `routing { }` block grow. ## The core idiom: Route extension functions `routing { }` runs with a `Route` receiver, and every nested `route`/`get`/`post` is just a function on that receiver. So any extension function on `Route` can declare routes: `fun Route.orderRoutes(orders: OrderService) { route("/orders") { get { ... }; post { ... }; get("/{id}") { ... } } }` That file lives with the order feature's code, not in a shared routing package. The application module then reads as a manifest: `routing { route("/api/v1") { orderRoutes(orderService); userRoutes(userService); reportRoutes(reports) } }` One place lists what the service exposes; each feature owns its own paths. New endpoints are added inside the feature's function and require no edit to the central block, which removes the merge-conflict hotspot that a single monolithic routing block always becomes. ## Dependencies as parameters Pass what a route function needs as parameters. The temptation is to have it construct its own service or reach into a global container; both make the wiring invisible at the call site and make substituting a fake in a test awkward. Explicit parameters keep the dependency graph readable from the module function downwards, and they work identically whether you use a DI framework or plain constructor wiring. If a feature needs many collaborators, that is a signal to introduce one façade for the feature rather than to widen the parameter list indefinitely. ## Shared prefixes and grouping Declare a shared prefix once, in the outer block, and never repeat it inside feature functions — a feature function should declare paths relative to wherever it is mounted, which is what makes it reusable and what lets you move a whole feature under a different prefix in one line. The same applies to grouping by cross-cutting boundary: routes that share an authorization boundary are grouped together in the tree, so that boundary is declared once and visibly covers everything nested inside it rather than being restated per handler. ## Keeping handlers thin The second failure mode is not file size but handler depth. A handler that parses input, runs queries, applies rules and formats a response has put business logic inside a DSL that cannot be unit-tested without an HTTP call. Keep the body to three moves: read and validate the request, call a service, respond. Everything the handler needs from the request should be extracted through small shared helpers — one place that converts an id parameter and answers 400 consistently, one place that reads paging — so the error contract does not drift between endpoints written months apart. ## Versioning and evolution Because the prefix is declared once, introducing a second version is a structural edit rather than a rewrite: mount the new feature functions under a new prefix and keep the old branch pointing at the previous implementations for as long as it must live. Whether that is the right versioning strategy is an API-design question, not a Ktor one; what routing gives you is a cheap way to express whichever strategy you chose. ## Testing the seams `testApplication { }` lets you build an application containing only the pieces under test — install the plugins that feature needs, call just `orderRoutes(fakeOrders)`, and exercise it with a client. That is only possible if the route function takes its dependencies as parameters and does not assume the rest of the application exists, so the testability requirement and the wiring rule reinforce each other. Suites built this way stay fast because they never construct the whole service. ## Smells to name in the interview A single routing file over a few hundred lines. Prefixes repeated in path strings, so a rename touches thirty places. Route functions that build their own dependencies. Handlers containing transactions or business rules. Two features declaring overlapping paths because nobody can see the whole tree at once. Naming these concretely, and saying which one you would fix first in a codebase you inherited, is what separates a senior answer from a description of the DSL.
- Why pass dependencies into a route function instead of letting it resolve them?The call site then shows the whole wiring, and a test can mount that one function with fakes. A route function that constructs or looks up its own collaborators hides the graph, couples routing to your container, and forces tests to build the full application to exercise three endpoints.
- How do you avoid repeating a shared prefix like /api/v1 across feature files?Declare it once in the outer block — `route("/api/v1") { orderRoutes(...); userRoutes(...) }` — and have each feature function declare paths relative to its mount point. Moving or versioning the whole surface then becomes a one-line change instead of a rename across every file.
- What belongs in a route handler body, and what does not?Belongs: reading and validating request input, calling a service, mapping the result to a response. Does not: transactions, queries, business rules, or multi-step orchestration. Logic inside the DSL can only be tested through HTTP, and it is the reason large Ktor routing files become untouchable.
- How do you test one feature's routes without starting the whole service?Use `testApplication { }` to assemble a minimal application: install only the plugins that feature requires, call just its Route extension function with fake collaborators, and drive it with the test client. This stays possible only while the function takes dependencies as parameters.
saying these in an interview costs you the question
- Keeps every endpoint in one routing block and calls it fine
- Route functions that instantiate their own services
- Repeats the /api/v1 prefix inside every path string
- Puts queries, transactions or business rules in handler bodies
- Expects Ktor to discover handlers by scanning the classpath