API Design
This document consolidates conventions for Arda’s REST API design: URL naming, required headers, error responses, filtering and pagination, endpoint usage patterns, known limitations, and hard-won gotchas.
URL Naming
Section titled “URL Naming”Network Domains
Section titled “Network Domains”Arda’s platform uses four network domains:
| Domain | Purpose |
|---|---|
app.arda.cards | End-user-oriented applications |
io.arda.cards / api.arda.cards | APIs exposing platform functionality |
auth.arda.cards | OAuth2 authorization endpoints |
Purpose Subdomains
Section titled “Purpose Subdomains”Each Purpose defines subdomains:
<purpose>.<infrastructure>.app.arda.cards<purpose>.<infrastructure>.io.arda.cards<purpose>.<infrastructure>.api.arda.cards<purpose>.<infrastructure>.auth.arda.cardsProduction Entry Points
Section titled “Production Entry Points”The main production system uses canonical short hostnames:
live.app.arda.cardslive.io.arda.cardslive.api.arda.cardslive.auth.arda.cardsAPI Route Pattern
Section titled “API Route Pattern”API endpoint routes follow:
https://<purpose>.<infrastructure>.api.arda.cards/<major-version>/<endpoint-name>/<specific-route>Where:
<major-version>is the SemVer major version of the API definition (not the module or component version)<endpoint-name>is the functional name of the endpoint, following REST naming conventions, in hyphenated lower-kebab-case plural form (e.g.,lookup-suppliers,kanban-card)<specific-route>is the route as understood in OpenAPI specification
Route Naming Consistency
Section titled “Route Naming Consistency”Existing routes use hyphenated, plural naming: lookup-suppliers, lookup-units. New routes must follow the same pattern.
Required Headers
Section titled “Required Headers”Every API call requires:
Authorization: Bearer <token>X-Author: <author-uuid>X-Tenant-Id: <tenant-uuid>X-Request-ID: <fresh-uuid-per-request> # Must be valid UUID formatContent-Type: application/jsonCritical notes:
- Missing
X-Request-IDcauses a misleading400error about “Invalid UUID format” - Missing
X-Tenant-Idalso causes400 - Generate a fresh UUID for each request (e.g.,
$(uuidgen)in bash)
Error Responses
Section titled “Error Responses”HTTP Status Codes
Section titled “HTTP Status Codes”All error responses use standard HTTP status codes:
Client Errors (4xx):
400 Bad Request— Invalid request payload, malformed JSON, missing required parameters401 Unauthorized— Missing or invalid authentication token403 Forbidden— Authenticated user lacks permission404 Not Found— Requested resource does not exist405 Method Not Allowed409 Conflict— Conflict in current resource state (e.g., edit conflict, update without draft)422 Unprocessable Entity429 Too Many Requests
Server Errors (5xx):
500 Internal Server Error— Unexpected server error501 Not Implemented502 Bad Gateway503 Service Unavailable— Transient failure; retry recommended. Accompanied by aRetry-Afterheader (in seconds). See Transient failures and retry contract below.504 Gateway Timeout
Transient failures and retry contract
Section titled “Transient failures and retry contract”PDEV-490 introduced an AppError.Transient branch for failures that callers may retry. Currently it is used to surface AWS Advanced JDBC Wrapper failover-window exceptions (the operations component runs on Aurora; the wrapper raises typed exceptions when an Aurora failover interrupts an in-flight transaction).
Response contract:
- HTTP status:
503 Service Unavailable. - HTTP header:
Retry-After: 2(seconds). - Response body: standard
ErrorResponseshape (responseMessage,code: 503, optionaldetailscarrying the underlying cause).
Caller behaviour expected:
- Wait for the duration in
Retry-After. - Re-issue the same request. Idempotent endpoints will succeed once the underlying failover completes (typically 2–5 seconds for an Aurora failover).
- Non-idempotent endpoints should retry only when the caller can tolerate the operation being applied twice; otherwise surface the 503 to the user.
Server-side absorption: the inTransactionAsync / inTransactionSync boundary in common-module already retries transient failures up to PoolConfig.maxAttempts (default 2) with PoolConfig.backoffMs (default 300 ms) between attempts. The 503 is surfaced only when in-process retries are exhausted; most failovers are absorbed transparently and the request completes with HTTP 200.
Sub-types (visible only in logs / details):
| AppError subtype | Wrapper exception | Meaning |
|---|---|---|
AppError.Transient.FailoverSucceeded | FailoverSuccessSQLException | Aurora failover completed mid-transaction; the in-flight transaction must be retried. |
AppError.Transient.TransactionStateUnknown | TransactionStateUnknownSQLException | A connection event left the transaction in an unknown state; caller should retry. |
AppError.Transient.FailoverFailed | FailoverFailedSQLException | Aurora failover did not complete within the wrapper’s retry window; caller may retry after a longer back-off. |
Error Response JSON Format
Section titled “Error Response JSON Format”All error responses conform to the ErrorResponse data class:
@Serializabledata class ErrorResponse( override val responseMessage: String, override val code: Int, // HTTP Status Code override val details: JsonElement? = null) : Throwable(...), HttpResponseThe details field can contain:
- Simple Cause: JSON representation of a cause
ErrorResponse - Contextual Information (
SingleDetails):data class SingleDetails(val error: ErrorResponse,override val context: String?): ErrorDetails - Composite Errors (
CompositeDetails):data class CompositeDetails(override val context: String?,val errors: List<ErrorResponse>): ErrorDetails
Example composite error JSON:
{ "responseMessage": "Multiple errors occurred", "code": 500, "details": { "context": "Validating user input", "errors": [ { "responseMessage": "username is Invalid: cannot be empty", "code": 400, "details": null }, { "responseMessage": "email is Invalid: must be a valid email format", "code": 400, "details": null } ] }}Ktor StatusPages Integration
Section titled “Ktor StatusPages Integration”A route handler never constructs an error response. It returns a Result and lets the framework throw, or it throws an AppError directly. A single StatusPages handler, installed once per component in common-module’s Component.kt, turns every failure into the response.
This is the rule that most often gets reinvented, so it is worth being explicit about what a hand-rolled responder costs. The central handler does five things, each of which is silently lost by a handler that calls call.respond on its own failure path:
| What the boundary does | Ticket | |
|---|---|---|
| 1 | Captures to Sentry via captureFromKtorBoundary(throwable, route, callId). Internal.* and Generic become events tagged boundary=http; Invocation.* are dropped by the reportable() policy. | — |
| 2 | Attaches the correlation id to the response body, so a caller can pivot from a user-facing error to the matching log line. The CallId plugin already sets the header; this puts it in the payload. | PDEV-378 |
| 3 | Sets Retry-After on AppError.Transient — and it must happen before call.respond(...), because Ktor commits headers once the body starts being written. | PDEV-490 |
| 4 | Logs by severity. 4xx logs message and context only — a legitimate user error is not a stack trace. 5xx logs the full stack. MDC carries callId, method, and route into every appender. | PDEV-378 |
| 5 | Normalizes and classifies non-AppError exceptions. BadRequestException, JsonConvertException, SerializationException, and IllegalArgumentException become AppError.GeneralValidation, which reportable() then drops — keeping bad client input out of Sentry. | — |
An abridged view of the handler (see common-module/lib/.../component/Component.kt for the current source):
install(StatusPages) { exception<Throwable> { call, ktorExc -> val cause = ktorExc.cause val toProcess = if (cause != null && ktorExc.message == cause.message) cause else ktorExc val appError = toProcess.normalizeToAppError()
// PDEV-490: before respond() — Ktor commits headers when the body starts. if (appError is AppError.Transient) call.response.header(HttpHeaders.RetryAfter, "2")
val callId = call.attributes.getOrNull(CallFieldNames.callIdAttributeKey) val response = appError.toErrorResponse(normalizing = false) .let { base -> if (callId == null) base else base.withCorrelationId(callId) } // PDEV-378
captureFromKtorBoundary(throwable = appError, route = call.request.path(), callId = callId) // …severity-aware logging: info+context for 4xx, full stack for 5xx… call.respond(status = response.httpCode, message = response) }}How a handler reaches it
Section titled “How a handler reaches it”In a DSL-declared endpoint — which is every module endpoint — you write nothing. Invocation.responder folds the handler’s Result and rethrows the failure:
execute(rCtx.call).fold( { r -> … respond … }, { err -> throw err }, // deliberate: this is how the boundary is reached)So the handler simply returns Result<T> and the framework does the rest. See Endpoint Definition DSL.
In a hand-written Ktor handler, throw the AppError yourself:
get("/my-resource/{id}") { val id = call.parameters["id"] ?: throw AppError.ArgumentValidation("id", "ID path parameter is missing") val resource = fetchResource(id) ?: throw AppError.NotFound("MyResource", context = { "Resource ID: $id" }) call.respond(resource)}On
getOrThrow(). The Kotlin coding standards forbidgetOrThrowandgetOrNullfor extracting a value from aResult. The HTTP boundary is the single sanctioned exception: it is the one place where aResultlegitimately becomes a throw. Even there you rarely write it —Invocation.responderalready does — sogetOrThrow()is confined to hand-written Ktor handlers, and a hand-written Ktor handler inside a module is itself a signal that the endpoint should be on the DSL.
Data Types at API Boundaries
Section titled “Data Types at API Boundaries”Strong Types Preferred
Section titled “Strong Types Preferred”Prefer strong types (UUID/EntityId) over String at API boundaries to reduce parsing boilerplate.
Money vs Quantity
Section titled “Money vs Quantity”The Money type uses value (not amount) for the numeric field:
// CORRECT"unitCost": { "value": 0.15, "currency": "USD" }
// WRONG — returns 400"unitCost": { "amount": 0.15, "currency": "USD" }The Quantity type uses amount and unit:
"quantityPerOrder": { "amount": 100, "unit": "EA" }Strict Integer Types for Quantities
Section titled “Strict Integer Types for Quantities”The API is strict about types for Quantity objects. Float values fail:
{"amount": 1.0, "unit": "each"} // Returns 400{"amount": 1, "unit": "each"} // CorrectField Naming Consistency
Section titled “Field Naming Consistency”Watch for camelCase variations — defaultSupplyEid (lowercase ‘id’) vs supplyEId.
Serialization
Section titled “Serialization”For kotlinx.serializable data classes with UUID fields, the @Contextual annotation is required.
Filtering and Query Endpoints
Section titled “Filtering and Query Endpoints”Query Route Pattern
Section titled “Query Route Pattern”The /query sub-path supports filtering, sorting, and pagination:
- Root path:
/<version>/<resource>(e.g.,/v1/items) - Query sub-path:
/<version>/<resource>/query(e.g.,/v1/items/query)
Initiating a Query
Section titled “Initiating a Query”POST /<version>/<resource>/query with a JSON Query object body:
{ "filter": { "EQ": { "locator": "status", "value": "ACTIVE" } }, "sort": [ { "field": "name", "direction": "ASC" } ], "paginate": { "index": 0, "size": 20 }}Response is a PageResult:
@Serializabledata class PageResult<P: EntityPayload, M : PayloadMetadata>( val thisPage: String, val nextPage: String, val previousPage: String?, val results: List<EntityRecord<P, M>>)Page Navigation
Section titled “Page Navigation”GET /<version>/<resource>/query/{page} — retrieve a specific page using a token from nextPage or previousPage. Returns the same PageResult structure.
If {page} cannot be decoded to a proper Query, returns an Argument Validation error.
Query Behavior
Section titled “Query Behavior”- The
/queryendpoints return only non-retired, visible records by default - An empty result does not mean no data exists — verify with direct
GET /entity-idcalls - The
filterparameter is optional; omitting it returns all records - Query parameter defaults:
effectiveAsOfandrecordedAsOfdefault toTimeCoordinates.now()when absent
See Query DSL for filter syntax.
Locator Naming
Section titled “Locator Naming”Universes configured with EntityServiceConfiguration accept locators as JSON field paths (camelCase, e.g., identity.email, cardQuantity.amount) in addition to raw database column names (snake_case, e.g., identity_email, card_quantity_amount). The structured translator resolves both forms, so API clients can use whichever style matches their context. See Query DSL: EntityServiceConfiguration.
CRUD Operations
Section titled “CRUD Operations”Item Update Workflow (Draft → Publish)
Section titled “Item Update Workflow (Draft → Publish)”Updating an item requires a strict Draft → Publish workflow due to the bitemporal model:
- Get/Create Draft:
GET /v1/item/item/{eId}/draft - Publish:
PUT /v1/item/item/{eId}with the Item payload as the body
Calling PUT /item/{eId} without a draft existing returns 400 Cannot update... without a draft.
Delete Semantics
Section titled “Delete Semantics”Delete is a logical retirement, not physical removal — data is preserved to maintain bitemporal history. The response includes "retired": true.
Items with kanban card “successors” in the bitemporal timeline cannot be deleted. The API returns: "Record[ITEM] cannot be deleted because it has a successor". Retire items instead, or delete all associated cards first.
Lookup Endpoints
Section titled “Lookup Endpoints”The /lookup-* endpoints (suppliers, facilities, units, types, subtypes, usecases, departments) are typeahead/autocomplete endpoints. They require a name query parameter and return matches, not full lists. Use the /query endpoints for full data listing.
API Response Structure
Section titled “API Response Structure”All API responses wrap data in a payload property. For queries:
payload.datais the results arraypayload.totalis the count
The locator field on items is a nested object {facility, department, location, subLocation}, not a flat string.
Known Limitations
Section titled “Known Limitations”Query Limitations
Section titled “Query Limitations”- Query Object Complexity: Clients are responsible for constructing the JSON
Queryobject; errors produce runtime failures, not schema validation errors. - Page Token Security: Current page tokens are strings (serialized
Query); no integrity checking is implemented. - Performance: Query performance depends on
Universeimplementation and database indexing. No projection support — fullEntityRecordis always returned. - No Full-Text Search: The filter mechanism is for structured data only.
- No Aggregations: No standard mechanism for
COUNT,SUM,AVGvia query API. - GET Pagination: Filters only apply on the initial
POSTrequest;GET /query/{page}does not accept a filter body.
Kanban Card Locators
Section titled “Kanban Card Locators”Universes with EntityServiceConfiguration accept both camelCase JSON field paths and snake_case column names as locators. For universes not yet configured with a structured translator, only raw column names are accepted — test before relying on camelCase paths.
Orphaned References
Section titled “Orphaned References”Orphaned card references (cards that reference deleted items) can cause 500 errors on the /details endpoint.
Browser Refresh After Mutations
Section titled “Browser Refresh After Mutations”The frontend does not automatically poll for data changes. After API mutations, manual browser refresh is required to see changes in the UI.
Copyright: © Arda Systems 2025-2026, All rights reserved