How do you write a custom Ktor plugin, and when should it be route-scoped instead of application-scoped?
answer
- Factory function, not an interface to implement
- Handlers named for the moment they fire
- Body runs once, handlers run per call
- A sibling factory narrows the install target
basics
~20 sBuild it with createApplicationPlugin, giving a name and handlers such as onCall, onCallReceive and onCallRespond; add a configuration class if it needs settings. Use createRouteScopedPlugin instead when the behaviour should apply to one route subtree rather than the whole server.
solid answer
~50 sKtor's modern plugin API is a factory function rather than an interface to implement. `createApplicationPlugin(name = "RequestId") { ... }` returns something installable, and inside the block you register handlers on the call lifecycle: `onCall` runs for each incoming call, `onCallReceive` wraps request-body deserialization, and `onCallRespond` wraps response sending. For settings, pass `createConfiguration = ::MyConfig` and read `pluginConfig` inside the block. Code written directly in the factory body — outside any handler — runs once at install time, which is the right place for validation and for setting up shared state. `createRouteScopedPlugin` has the same shape but produces a plugin installable inside a `route` block, so it only sees matching calls. Choose route scope when the behaviour is genuinely local — an audit trail for an admin subtree — and application scope when it must hold for every request.
code
kotlin · 17 linesclass AuditConfig {
var headerName: String = "X-Actor"
}
val Audit = createApplicationPlugin(name = "Audit", createConfiguration = ::AuditConfig) {
val headerName = pluginConfig.headerName // once, at install time
require(headerName.isNotBlank()) { "headerName must not be blank" }
onCall { call -> // per request
val actor = call.request.headers[headerName] ?: "anonymous"
call.application.log.info("audit {} {}", actor, call.request.uri)
}
}
fun Application.module() {
install(Audit) { headerName = "X-User" }
}go deeper
Recall that a custom plugin is created by a factory function with a name and that onCall is the per-request entry point; know that it is then installed like any built-in plugin.
Explain the once-at-install versus per-call split, how a configuration class is wired and read, and what each of the receive and respond handlers wraps.
Treat captured state as shared across concurrent calls, validate configuration at install time, and choose scope from whether the behaviour is a service invariant or a subtree policy.
Decide which cross-cutting concerns become shared plugins at all, and keep them handler-based rather than phase-coupled so they compose across services without ordering folklore.
## The modern plugin API Before Ktor 2.0 a custom plugin meant implementing a companion object against a framework interface, wiring an `AttributeKey`, and interacting with the pipeline directly. Ktor 2.0 introduced a much smaller API: you call a factory function and describe the behaviour with handlers. ``` val RequestId = createApplicationPlugin(name = "RequestId") { onCall { call -> val id = call.request.headers["X-Request-Id"] ?: UUID.randomUUID().toString() call.response.headers.append("X-Request-Id", id) } } ``` Install it exactly like a built-in plugin: `install(RequestId)`. ## The handlers - **`onCall { call -> ... }`** — invoked for every call. The place for inspecting or annotating the request and setting response headers. - **`onCallReceive { call -> ... }`** — wraps the point where the request body is converted into an object, so you can observe or influence deserialization. - **`onCallRespond { call, body -> ... }`** — wraps the point where a response is being sent, giving access to the object being responded with. There are also lifecycle hooks registered with `on(...)`, which is how a plugin reacts to events rather than to the plain call flow — for instance responding to an application-lifecycle event, or to a call failing. Using a hook rather than raw pipeline interception is what keeps a plugin readable: you say *when* you want to run, not *which phase index* you want to sit at. ## Configuration A plugin that needs settings declares a config class and passes its constructor: ``` class AuditConfig { var headerName: String = "X-Actor" var includeBody: Boolean = false } val Audit = createApplicationPlugin(name = "Audit", createConfiguration = ::AuditConfig) { val headerName = pluginConfig.headerName require(headerName.isNotBlank()) { "Audit headerName must not be blank" } onCall { call -> val actor = call.request.headers[headerName] ?: "anonymous" call.application.log.info("audit {} {}", actor, call.request.uri) } } ``` Users then write `install(Audit) { headerName = "X-User" }`. Two things to notice. First, `pluginConfig` gives typed access to the values the user set. Second, the `require` runs **once at install time**, so a misconfiguration fails at startup rather than per request. Reading configuration into a local `val` outside the handler is also a small but real optimisation: it happens once instead of on every call. ## Install-time versus per-call code This is the distinction interviewers probe. The factory body executes once when the plugin is installed. Only the code inside `onCall`, `onCallReceive`, `onCallRespond` and the hooks runs per request. So shared state — a metric registry, a compiled pattern, a validated setting — belongs in the body; anything touching the specific call belongs in a handler. Putting per-call logic in the body silently runs it once with no call at all. Because the body's locals are captured by the handlers and shared across all concurrent requests, mutable state there must be safe for concurrent access. A counter must be an atomic type, not a plain `var`. ## Application scope versus route scope `createRouteScopedPlugin` is the same API with a narrower installation target: ``` val AdminAudit = createRouteScopedPlugin(name = "AdminAudit") { onCall { call -> call.application.log.info("admin access {}", call.request.uri) } } routing { route("/admin") { install(AdminAudit) get("/stats") { call.respondText("...") } } } ``` Only calls matching `/admin` pass through it. The decision rule: - **Application scope** when the behaviour is an invariant of the service — request ids, uniform logging, a security header on every response. Something that must not be forgotten on a new route belongs globally, because global installation cannot be forgotten. - **Route scope** when the behaviour is a property of a subtree — heavier auditing for sensitive endpoints, a different policy for an upload path. Local installation both avoids paying the cost everywhere and documents itself: the route block states what applies to it. A useful sanity check: if you would be uncomfortable with a new endpoint *not* having the behaviour, it is application scope. ## When to drop to raw interception The handler-based API covers most needs. Direct `intercept(phase)` remains available and is the tool when you need control the handlers do not express — a very specific position relative to other plugins, or work in a phase the handlers do not surface. Reaching for it first, though, produces plugins that are harder to read and more coupled to install order than they need to be.
- Where in a custom Ktor plugin should a shared counter live, and what must be true of it?In the factory body, so it is created once and captured by the handlers. Because every concurrent request touches the same instance, it must be a thread-safe type such as an atomic counter; a plain var would be updated non-atomically across calls and lose increments under load.
- Why validate plugin configuration in the factory body rather than inside onCall?The body runs once at install time, so an invalid setting aborts startup where a deploy notices it. Validating inside onCall repeats the check on every request and turns a configuration mistake into runtime failures on live traffic instead of a failed boot.
- What does onCallRespond give you that onCall does not?Access to the response side — it wraps the point where a response object is being sent, so the plugin can observe or act on what is about to go out. onCall fires on the incoming call and cannot see the eventual response body.
saying these in an interview costs you the question
- Puts per-request logic in the plugin factory body
- Keeps a plain mutable var as shared plugin state
- Validates configuration inside onCall on every request
- Thinks createRouteScopedPlugin can be installed at application level for a subtree
- Implements raw pipeline interception when a lifecycle handler would do