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.
What the DSL gives you
Section titled “What the DSL gives you”Each row is a capability a raw route group simply does not have.
| Capability | Mechanism | What raw Ktor gives you instead |
|---|---|---|
| Secure / non-secure partitioning | Group/Leaf carry a secure flag that propagates down the tree (isSecured get() = parent.isSecured || secure); buildSecureRoutes / buildNonSecureRoutes mount into the matching block | Correct only if whoever mounts the group remembers to place it inside the secured block. The invariant lives in a code comment |
| OpenAPI registration | Leaf.documentation builds the full RouteConfig — operationId, summary, description, parameters, request and response bodies | Nothing. The route is invisible to the generated spec, so a client generated from it cannot call the endpoint at all |
| Typed parameter extraction | withParameters(p0, p1, …) binds QueryParameter / HeaderParameter / PathParameter / BodyMessage and hands the handler decoded values | Hand-written call.receive() plus manual runCatching per parameter |
| Route-collision detection | Group.addChild intersects allRoutes() and throws AppError.ArgumentValidation at start-up | A silently shadowed route, discovered in production |
| Start-up configuration validation | Group.validateConfiguration() / Leaf.validateConfiguration() — an endpoint with an unset body or response fails fast at boot | An endpoint that 500s on its first call |
| Canonical path shape | forService calls CanonicalSegment.require(...), enforcing kebab-case segments | Free-form strings |
| Uniform error emission | Invocation.responder folds failure as { err -> throw err }, reaching the central StatusPages boundary | A 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.
A worked surface, end to end
Section titled “A worked surface, end to end”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:
- Body messages are declared once and reused. The
specIdstring is the schema name in the generated spec, so it must be stable and unique. floatingNodeproduces a composable subtree, which a parent*Endpointmounts alongside its other nodes. That is the difference between a*Routesfragment and an*Endpoint— see Naming Conventions.- Inputs are declared, not extracted.
withParametershands the lambda already-decoded values, and each declared parameter appears in the spec. - The handler returns
Result<T>. Nocall.respond, no serialization, no error handling. Failures propagate to the component’sStatusPagesboundary. secureis passed structurally, at both the node and the leaf.
Inputs that are not wire inputs
Section titled “Inputs that are not wire inputs”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.
Selecting which operations to expose
Section titled “Selecting which operations to expose”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.
When to use DataAuthorityEndpoint
Section titled “When to use DataAuthorityEndpoint”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.
When to use the lightweight DSL
Section titled “When to use the lightweight DSL”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.
serviceDefinition block structure
Section titled “serviceDefinition block structure”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:
| Argument | Type | Purpose |
|---|---|---|
moduleName | String | Logical module name; contributes to the route prefix. |
specId | String | Stable identifier registered with the OpenAPI spec registry. Must be unique per component. |
secure | Boolean | true applies the component’s configured Authentication; false marks the surface as unauthenticated. |
Body message declarations
Section titled “Body message declarations”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.
MultiEndpointKtorModule mounting
Section titled “MultiEndpointKtorModule mounting”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:
| Argument | Purpose |
|---|---|
component | Component identity; used for route prefix and OpenAPI metadata. |
moduleConfig | Module-level configuration (base path, feature flags, etc.). |
authentication | Authentication instance from the canonical entry-point parameter. |
endpoints | List 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.
Decision rubric
Section titled “Decision rubric”| Situation | Use |
|---|---|
CRUDQ resource backed by a Universe<EP, M> | DataAuthorityEndpoint |
| POST + GET without bitemporal semantics, or webhook ingest | serviceDefinition DSL + MultiEndpointKtorModule |
| Unauthenticated inbound surface (webhooks, health checks) | serviceDefinition with secure = false |
Related pages
Section titled “Related pages”- Idempotency — the
TypedIdempotencyOutcometype and itsKSerializerworkaround referenced in “Body message declarations”. - L1 Proxy Pattern — the transport layer that endpoint handlers call.
- Module Wiring Entry Point — where
MultiEndpointKtorModuleis mounted inModule.kt.
Copyright: © Arda Systems 2025-2026, All rights reserved