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.

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.