Skip to content

Endpoint Definition DSL

Arda endpoints are wired to Ktor through one of two routes, depending on whether the surface fits the CRUDQ resource model or not. This page describes both routes, the DSL blocks used in the lightweight path, and how they are mounted at start-up.

Never declare a module’s routes with raw io.ktor.server.routing.get/post/put/delete. The DSL is not a documentation wrapper over Ktor routing — it is the mounting mechanism, and a route mounted around it is missing the capabilities in the next section. There is no way to describe such a route through the DSL afterwards; you would be registering a second, parallel route.

Each row is a capability a raw route group simply does not have.

CapabilityMechanismWhat raw Ktor gives you instead
Secure / non-secure partitioningGroup/Leaf carry a secure flag that propagates down the tree (isSecured get() = parent.isSecured || secure); buildSecureRoutes / buildNonSecureRoutes mount into the matching blockCorrect only if whoever mounts the group remembers to place it inside the secured block. The invariant lives in a code comment
OpenAPI registrationLeaf.documentation builds the full RouteConfig — operationId, summary, description, parameters, request and response bodiesNothing. The route is invisible to the generated spec, so a client generated from it cannot call the endpoint at all
Typed parameter extractionwithParameters(p0, p1, …) binds QueryParameter / HeaderParameter / PathParameter / BodyMessage and hands the handler decoded valuesHand-written call.receive() plus manual runCatching per parameter
Route-collision detectionGroup.addChild intersects allRoutes() and throws AppError.ArgumentValidation at start-upA silently shadowed route, discovered in production
Start-up configuration validationGroup.validateConfiguration() / Leaf.validateConfiguration() — an endpoint with an unset body or response fails fast at bootAn endpoint that 500s on its first call
Canonical path shapeforService calls CanonicalSegment.require(...), enforcing kebab-case segmentsFree-form strings
Uniform error emissionInvocation.responder folds failure as { err -> throw err }, reaching the central StatusPages boundaryA hand-rolled responder that loses Sentry capture, the correlation id, Retry-After, and severity-aware logging — see API Design

The security row is the one that matters most and the one most often overlooked. For any surface whose authorization depends on running inside a verified ApplicationContext, the DSL makes that structural; a raw route group leaves it to caller discipline.

The shortest complete example in the codebase is ItemPrintRoutes.settingsNode() — a fragment class contributing one authenticated GET that returns a service value:

// 1. Declare the response body once, at file scope. The name is the OpenAPI schema name.
val printingSettingsBody = RequiredBodyMessage<PrintTemplates>("settings-printing") {
summary = "Printing settings"
description = "Full print-template configuration: card / label / breadcrumb templates by size."
}
// 2. A floatingNode is a subtree a parent endpoint composes. `true` = secured.
fun settingsNode() = floatingNode(true) {
static("settings") // path segment
// 3. get<ResponseType>(specId, operationId, secure)
get<PrintTemplates>("settings-printing", "get-printing", true) {
static("printing") // …/settings/printing
summary = "Get the printing settings"
description = "Returns the in-memory print-template configuration. …"
// 4. Bind the response body, then declare the inputs the handler needs.
responds(printingSettingsBody) {
withParameters(
HeaderParameter.tenantId,
QueryParameter.asOf,
) { tenantId, asOf ->
// 5. The handler returns Result<PrintTemplates>. It never touches `call`,
// never serializes, and never builds an error response.
run { service.printSettings(tenantId, asOf) }
}
}
}
}

Five things to carry from that:

  1. Body messages are declared once and reused. The specId string is the schema name in the generated spec, so it must be stable and unique.
  2. floatingNode produces a composable subtree, which a parent *Endpoint mounts alongside its other nodes. That is the difference between a *Routes fragment and an *Endpoint — see Naming Conventions.
  3. Inputs are declared, not extracted. withParameters hands the lambda already-decoded values, and each declared parameter appears in the spec.
  4. The handler returns Result<T>. No call.respond, no serialization, no error handling. Failures propagate to the component’s StatusPages boundary.
  5. secure is passed structurally, at both the node and the leaf.

Some handlers need a value derived from the verified request context rather than supplied by the caller — a tenant-scoped ApplicationContext, a resolved principal. Use customParameter: its extractor is suspend, so it can read the coroutine context and ignore its RoutingCall receiver entirely.

private val callerContextParam = customParameter<CallerContext>(
"caller-context",
extractor = { // suspend; the RoutingCall receiver is unused
ApplicationContext.current().map { ctx -> CallerContext.from(ctx) }
},
) {
summary = "Authorization context derived from the verified request"
description = "Scope ids come only from the verified ApplicationContext, never from the request."
}
// …then bind it like any other parameter:
withParameters(callerContextParam, writeBody) { caller, request -> … }

It contributes nothing to the OpenAPI spec, deliberately. Leaf.documentation matches parameters with when (p) { is QueryParameter<*> -> …; is HeaderParameter<*> -> …; is PathParameter<*> -> …; else -> {} }, and a CustomParameter falls into the else branch. That is the correct rendering: the value is not part of the wire contract, and a client must have no way to supply it. The invariant that would otherwise live in a KDoc — “this id comes only from the verified context” — becomes structural.

DataAuthorityEndpoint.reify uses the same customParameter / metadataExtractor pattern.

DataAuthorityEndpoint exposes its route catalog through an overridable declareOperations(node), so a consuming endpoint mounts only the operations it wants rather than all of them:

override fun declareOperations(node: Node) {
routes.create()(node); routes.readByEid()(node); routes.readByRId()(node)
routes.queryNode()(node); routes.historyNode()(node); routes.bulkNode()(node)
deleteRoute(node) // domain-guarded, substituted for the generic delete
provisionRoute(node); unlockRoute(node); reProvisionRoute(node)
reVerifyRoute(node); restoreRoute(node)
}

That is EmailConfigurationEndpoint. Note what is absent: the generic update(), deliberately omitted because the release exposes no field-edit surface. Selectivity is per operation, not per endpoint — a module that only reads a resource mounts the read routes and never exposes a write surface at all.

Follow this shape for any endpoint whose consumers legitimately differ in what they should expose.

Derive from DataAuthorityEndpoint when the surface is a first-class data-authority resource managed through the bitemporal framework. Concretely, that means all five standard operations are in scope:

  • Create — mint a new entity.
  • Read by eId — retrieve the current state of an entity by its entity identifier.
  • Read as-of — retrieve the state of an entity at a specific point in time.
  • Update — apply a mutating payload to an existing entity.
  • Query — filter/sort/paginate the entity collection.

If the surface has all five of these operations and maps cleanly to a single Universe<EP, M>, use DataAuthorityEndpoint. See Data Authority Pattern for the four-layer module structure that DataAuthorityEndpoint sits at the top of.

Use the serviceDefinition DSL for any surface that does not fit CRUDQ:

  • POST + GET pairs without bitemporal read semantics.
  • Webhook ingestion endpoints (POST only).
  • Async job submission (POST to enqueue, GET to poll status).
  • Any surface whose request/response types are not Universe-backed entity payloads.

Worked references:

  • CsvUploadRoutes — existing non-CRUDQ precedent.
  • EmailJobEndpoint — job submission (POST) and status query (GET), with idempotency on the POST path.
  • PostmarkEventsEndpoint — webhook ingestion (POST), unauthenticated inbound surface.

A serviceDefinition block declares the endpoint’s identity and security posture, then enumerates its HTTP operations via a forService sub-block.

val definition = serviceDefinition(
moduleName = "email", // logical module name; used in route prefixes
specId = "email-jobs", // stable spec identifier for the OpenAPI registry
secure = true, // false for unauthenticated surfaces (e.g., webhooks)
) {
forService("email-jobs") {
post<CreateEmailJobRequest, CreateEmailJobResponse>(
path = "/email-jobs",
summary = "Submit an email job",
) {
responds(createJobBodyMessage) {
withParameters(idempotencyKeyParam) {
run { req, params ->
emailJobService.create(req, params.idempotencyKey)
}
}
}
}
get<EmailJobStatusResponse>(
path = "/email-jobs/{jobId}",
summary = "Get email job status",
) {
responds(jobStatusBodyMessage) {
run { _, params ->
emailJobService.getStatus(params.jobId)
}
}
}
}
}

The three required arguments at the serviceDefinition level:

ArgumentTypePurpose
moduleNameStringLogical module name; contributes to the route prefix.
specIdStringStable identifier registered with the OpenAPI spec registry. Must be unique per component.
secureBooleantrue applies the component’s configured Authentication; false marks the surface as unauthenticated.

Each responds(...) call takes a body message that names and describes the response type. Two constructors cover the common cases.

Reified constructor for directly @Serializable types:

val jobStatusBodyMessage = RequiredBodyMessage<EmailJobStatusResponse>("email-job-status") {
summary = "Email job status"
description = "Current status and metadata for an email job."
}

Four-argument constructor for types that need a point-of-use KSerializer:

Some types are not directly @Serializable — either because the type is a sealed class whose serializer requires a custom KSerializer at the construction site, or because the type lives in a library that does not yet carry @Serializable (see Idempotency — Serialization for the TypedIdempotencyOutcome case):

val idempotencyOutcomeBodyMessage = RequiredBodyMessage(
name = "email-job-idempotency-outcome",
kType = typeOf<EmailJobIdempotencyOutcome>(),
kSerializer = EmailJobIdempotencyOutcomeSerializer,
typeInfo = typeInfo<EmailJobIdempotencyOutcome>(),
)

The four-argument form sidesteps reified resolution and pins the serializer explicitly at the call site. See EmailJobIdempotencyOutcomeSerializer in cards.arda.operations.shopaccess.email.api.rest for the reference implementation.

Endpoints defined with the serviceDefinition DSL are collected into a MultiEndpointKtorModule and mounted in the module entry-point:

fun Application.email(
cfgProvider: ConfigurationProvider,
authentication: Authentication,
registry: ModuleRegistry,
): EmailServices {
// ... service construction ...
MultiEndpointKtorModule(
component = cfgProvider.component(),
moduleConfig = cfg,
authentication = authentication,
endpoints = listOf(emailJobEndpoint, postmarkEventsEndpoint),
).configureServer(this, registry)
return EmailServices(/* ... */)
}

The four constructor arguments:

ArgumentPurpose
componentComponent identity; used for route prefix and OpenAPI metadata.
moduleConfigModule-level configuration (base path, feature flags, etc.).
authenticationAuthentication instance from the canonical entry-point parameter.
endpointsList of serviceDefinition-backed endpoint instances to mount.

configureServer(application, registry) registers all routes and publishes the module to the OpenAPI spec registry in one call.

SituationUse
CRUDQ resource backed by a Universe<EP, M>DataAuthorityEndpoint
POST + GET without bitemporal semantics, or webhook ingestserviceDefinition DSL + MultiEndpointKtorModule
Unauthenticated inbound surface (webhooks, health checks)serviceDefinition with secure = false
  • Idempotency — the TypedIdempotencyOutcome type and its KSerializer workaround referenced in “Body message declarations”.
  • L1 Proxy Pattern — the transport layer that endpoint handlers call.
  • Module Wiring Entry Point — where MultiEndpointKtorModule is mounted in Module.kt.