@ddd-ts-kit/http
Declare a route once, without a framework. Mount it on Express 5, Fastify 5, Hono 4 (on Node) or NestJS, and generate the OpenAPI 3.1 contract from the same declaration.
Every adapter goes through one pipeline (executeRoute): validation, handler, error mapping.
The behaviour is therefore the same on the four frameworks, and one test suite proves it.
The package lives in the presentation layer: it turns an HTTP request into a call of your handler, and the answer of the handler (or its refusal) into an HTTP response. It decides nothing about the business: the handler sends a command or a query to the application, and the use case decides.
Declare
Section titled “Declare”import { z } from 'zod';import type { Actor } from '@ddd-ts-kit/kernel';import { defineRoute, defineRoutes } from '@ddd-ts-kit/http';import { zodSchema } from '@ddd-ts-kit/validation/zod';
/** The read side of the application, handed to the routes by the composition root. */interface OrderQueries { byId(orderId: string, actor: Actor): Promise<{ id: string; total: number }>;}
const order = zodSchema(z.object({ id: z.string(), total: z.number() }), { io: 'output' });
export const orderRoutes = (orders: OrderQueries) => defineRoutes( defineRoute( { method: 'GET', path: '/orders/:orderId', operationId: 'getOrder', summary: 'Read an order', tags: ['orders'], request: { params: zodSchema(z.object({ orderId: z.string().uuid() })) }, responses: { 200: { description: 'The order.', body: order } }, errors: ['NOT_FOUND'], security: ['bearer'], }, async ({ params, context }) => ({ status: 200, // must be a declared status, and the body must match its schema body: await orders.byId(params.orderId, context.execution.actor), }), ), );params,query,headersandbodyare typed from the schemas. AnySchemaof@ddd-ts-kit/validationworks (zod, class-validator, TypeBox, Standard Schema).- Paths are literal segments and
:nameparameters, the form Express and Fastify share. - Frameworks hand query values over as strings: coerce in the schema (
z.coerce.number()). contextis{ requestId, execution, principal?, rawHeaders?, raw, expectedVersion?, stream? }.executionis the kernelExecutionContextof the request (see "The execution context of a request");rawis the framework's request object andrawHeadersthe headers as they arrived;expectedVersionis read fromIf-Match(see "Conditional requests") andstreamis the unparsed body of a route declared withrawBody.securityis required: the names of the security schemes that apply, orfalsefor a route anybody may call. A route is never public by omission: leaving it out does not compile, anddefineRouterefuses an empty list, which would say the same asfalsewithout saying it.defineRoutesand every mount refuse a duplicateoperationIdor method and path.prefixRoutes('/api', routes)moves routes under a prefix, for the server and the contract.
A refusal is thrown, not returned. The handler answers only the success it declares; what can
go wrong is a code in errors, which the contract documents and the pipeline answers:
// Instead of: a 404 written by hand, a second shape of answer that every route invents againresponses: { 200: { description: 'The order.', body: order }, 404: { description: 'No such order.' } },async ({ params }) => { const found = await orders.find(params.orderId); return found === undefined ? { status: 404 } : { status: 200, body: found };},
// Write: the use case throws a refusal (NotFoundError, or a factory of a ConceptError) and the// route lists its code; the answer is a problem document, the same on every route (see "Errors")responses: { 200: { description: 'The order.', body: order } },errors: ['NOT_FOUND'],async ({ params, context }) => ({ status: 200, body: await orders.byId(params.orderId, context.execution.actor) }),Controllers, route groups and guards
Section titled “Controllers, route groups and guards”A route may also be a class: one class per route, one method, handle. Its decorator is the
declaration, and the compiler holds handle to it: a part the schemas do not produce, or
another body than the declared response, is an error on the decorator.
import { z } from 'zod';import { AuthorizedCommand, type Actor, type CommandBus } from '@ddd-ts-kit/kernel';import { Authenticated, Controller, RouteGroup, controllerRoutes, type HttpRequest } from '@ddd-ts-kit/http';import type { Infer } from '@ddd-ts-kit/validation';import { zodSchema } from '@ddd-ts-kit/validation/zod';
/** The command of the use case, in the application layer (its @Requires permission left out here). */class PlaceOrder extends AuthorizedCommand<string> { constructor( actor: Actor, readonly customerId: string, ) { super(actor); }}
const body = zodSchema(z.object({ customerId: z.string().min(1) }));const placed = zodSchema(z.object({ id: z.string() }), { io: 'output' });
type PlaceOrderBody = Infer<typeof body>; // { customerId: string }type Placed = Infer<typeof placed>; // { id: string }
@RouteGroup({ prefix: '/orders', tags: ['orders'], guards: [Authenticated], errors: ['FORBIDDEN'] })export class OrdersRoutes {}
@Controller({ group: OrdersRoutes, method: 'POST', path: '/', summary: 'Place an order', body, response: { status: 201, body: placed }, errors: ['OUT_OF_STOCK'],})export class PlaceOrderController { constructor(private readonly commands: CommandBus) {}
async handle({ body, actor }: HttpRequest<{ body: PlaceOrderBody }>): Promise<Placed> { return { id: await this.commands.execute(new PlaceOrder(actor, body.customerId)) }; }}
declare const application: { resolve<T>(target: abstract new (...args: never) => T): T };
export const routes = controllerRoutes([PlaceOrderController], { resolve: application.resolve });The route of this controller is POST /orders: the prefix of its group, then its own path
(/ is the prefix itself).
It answers 201 with { id }, documents OUT_OF_STOCK (its own), FORBIDDEN (its group) and
UNAUTHORIZED (the Authenticated guard), and lets no anonymous caller reach handle.
| Export | What it is |
|---|---|
Controller(spec) |
the decorator of a controller: group, method, path (after the prefix of the group), operationId (the class name without Controller by default), summary, description, tags, params, query, headers, body, upload (see "Uploads and files"), response: { status, description?, body? }, errors, guards, deprecated, cache and ifMatch (see "HTTP caching"), produces and consumes (see Representations), form (see Forms described by the server) |
RouteGroup({ parent, prefix, tags, guards, errors, cache }) |
names a set of routes on an empty class; a child inherits from its parent and adds; the nearest cache wins |
Guard |
the interface { documents?, check(request: GuardRequest) }, and the decorator of a guard class that takes dependencies |
Authenticated, Public, HasRole(...roles), HasScope(...scopes), TenantMember |
the guards the package ships, over the Actor and the Session of the request: 401 to the anonymous caller, 403 without one of the roles, without every scope named (Session.scopes, typed by the SessionScopes of the project) or outside the tenant of the request; Public lets everybody in and stands alone. A refusal of a guard is reported to onGuardRefusal |
RequestMapper<Request, Message>, Serializer<Result, Body> |
the interfaces of the two optional translators (map, serialize), and the decorators that declare them |
HttpRequest<{ params, query, headers, body }> |
the request as handle, a mapper or a guard names it, with actor and context |
controllerRoutes(controllers, { resolve, security, guards }) |
the Route of each controller, given as a class (built by resolve) or as an instance; security names the schemes of the application (['bearer'] by default); guards: 'skip' is for the spec of a controller alone |
controllerRoute(controller, options) |
the one route of one controller, for a spec |
A controller with no group and no guard is refused when its class is defined. Guards run in
order, after validation and before handle, inside the execution scope of the request, and
what they throw is answered as a problem like any refusal. A guard is a control at the edge;
a permission is declared on the use case (@Requires), where a job and an event handler meet
it too. Controllers are declared classes of the edge (Edge of
@ddd-ts-kit/kernel/declarations): @ddd-ts-kit/di binds each one under itself and lists them
in DeclaredEdges. A controller has one response and answers its body: a route with several
success statuses, response headers or a raw body stays a defineRoute function. The reasons
are on the presentation layer page.
One request, step by step
Section titled “One request, step by step”executeRoute(route, request, options) is the whole behaviour of the package: an adapter only
copies the request of its framework into a RawRequest (params, query, headers, body,
raw, and the body stream), calls it, and writes back the RouteResponse it returns.
The steps that are not needed are skipped: no upload, no If-Match, no guard, no validator.
The first refusal ends the request: a request that is not valid never reaches a guard, and a
caller a guard refuses never reaches handle. Guards run after validation, so they read typed
parameters; a credential that resolvePrincipal rejects is refused before anything else.
Since executeRoute needs no server, a spec of a route calls it directly, as the adapters do:
import { z } from 'zod';import { NotFoundError } from '@ddd-ts-kit/kernel';import { defineRoute, executeRoute, type RawRequest } from '@ddd-ts-kit/http';import { zodSchema } from '@ddd-ts-kit/validation/zod';
const orders = new Map([['7', { id: '7', total: 42 }]]);
const getOrder = defineRoute( { method: 'GET', path: '/orders/:orderId', operationId: 'getOrder', summary: 'Read an order', request: { params: zodSchema(z.object({ orderId: z.string().regex(/^\d+$/) })) }, responses: { 200: { description: 'The order.', body: zodSchema(z.object({ id: z.string(), total: z.number() }), { io: 'output' }) }, }, errors: ['NOT_FOUND'], security: false, }, async ({ params }) => { const found = orders.get(params.orderId);
if (found === undefined) { throw new NotFoundError(`Order ${params.orderId} does not exist.`); }
return { status: 200, body: found }; },);
const request = (orderId: string): RawRequest => ({ params: { orderId }, query: {}, headers: {}, body: undefined, raw: undefined });
export const found = await executeRoute(getOrder, request('7')); // 200, { id: '7', total: 42 }export const missing = await executeRoute(getOrder, request('8')); // 404, a problem with the code NOT_FOUNDexport const invalid = await executeRoute(getOrder, request('x')); // 422, VALIDATION_FAILED, one issue at params.orderIdPass the options of the application as the third argument (validateResponses: true in
tests, resolvePrincipal to call as a given actor): the answer is the one the server would
send, headers included.
HTTP caching
Section titled “HTTP caching”A read says how the caches on the way may keep its answer, on its controller, on its route
group (every read of the group, GET and HEAD, unless a controller says otherwise: a write of
the group keeps the default of a write) or on a function route (cache of the spec):
import { CacheScope, Public, RouteGroup } from '@ddd-ts-kit/http';
@RouteGroup({ prefix: '/catalog', guards: [Public], cache: { scope: CacheScope.public, maxAge: '1m', sMaxAge: '5m', staleWhileRevalidate: '30s', staleIfError: '1d' },})export class CatalogRoutes {}| Field | Answers |
|---|---|
scope |
public (every cache: a CDN, Varnish, the browser), private (the browser of the caller only), no-store (nobody) |
maxAge, sMaxAge |
max-age, and s-maxage read by shared caches instead; whole seconds as text: '0s', '30s', '5m', '1h', '1d' |
staleWhileRevalidate, staleIfError |
how long a stale answer may still be served while the cache asks again, or while the server fails |
mustRevalidate |
must-revalidate: once stale, never served without asking |
vary |
Vary: the request headers the answer depends on, besides the URL |
The pipeline sends Cache-Control (and Vary) on a successful answer, no-store on every
refusal of a route that declares a policy, and no-store on every answer of a route that knows
its caller and declares none: an authenticated answer is never kept by default. A public route
that declares nothing says nothing, as before. A header the handler of a function route answers
itself wins. The contract documents the headers on the answer they apply to.
A policy that cannot stand is refused when the route is declared, with a DeclarationError:
- a policy other than
no-storeon a write (onlyGETandHEADare kept by a cache), the validators of a read (etag,lastModified) on a write,ifMatchon a read; publicorprivatewithoutmaxAge('0s'has the answer revalidated each time), a duration that is not whole seconds, a duration onno-store,sMaxAgeon a private answer;- a
publicanswer of a route that knows its caller (a guard other thanPublic, a function route withsecurity) that does not name invarywhat it depends on. A shared cache keys an answer by its URL: withoutvary: ['Authorization'](or the header carrying the tenant), it would hand the answer of one caller to the next.privateis the usual answer. The ESLint rulepresentation/shared-cache-variesreports the same declaration in the editor, for what one file shows.
// Instead of: refused with a DeclarationError; a CDN would hand the agenda of one caller to the next@RouteGroup({ prefix: '/me', guards: [Authenticated], cache: { scope: CacheScope.public, maxAge: '1m' } })
// Write: kept by the browser of the caller only@RouteGroup({ prefix: '/me', guards: [Authenticated], cache: { scope: CacheScope.private, maxAge: '1m' } })
// Or, when a shared cache must keep it: one copy per credential@RouteGroup({ prefix: '/me', guards: [Authenticated], cache: { scope: CacheScope.public, maxAge: '1m', vary: ['Authorization'] },})When the routes are mounted (mountExpress, mountFastify, createRoutesModule), the options of
the application are known, and a public answer is held to them with a DeclarationError:
- on a route that knows its caller,
varynamesAuthorization,Cookie, or a header the tenant is read from (resolveTenant):vary: ['Accept-Language']alone is refused; - on any route, even one anybody may call, an answer whose schema reserves fields to some
callers (
withFieldAccesswith areadrequirement, on the answer, its page or its stream) namesAuthorizationorCookieinvary: what it shows depends on who calls; - behind a cache that ignores
Vary(cacheTags.headerof Cloudflare or Bunny), a public answer that varies is refused, unlesscacheTags.honoursVary: truesays the cache keys on those headers; an answer chosen byAcceptamong several media types (produces) varies on it; - with tags filed per tenant (
cacheTags.perTenant), a tagged route anybody may call needsresolveTenant: an anonymous call names no tenant otherwise, and no purge would meet its answer.
Every answer a cache may keep varies on the headers the tenant is read from (TenantResolver.headers:
tenantFromHeader names its header, firstTenantOf the headers of its resolvers), added to Vary
by the pipeline. A tagged answer filed per tenant to a request that names no tenant is no-store.
Never cache a decision of authorization across actors: what a caller may see is decided by the use case for that caller, and a shared cache only ever keeps what is the same for everybody (see HTTP caching).
Conditional requests
Section titled “Conditional requests”A read that names its version answers ETag or Last-Modified, and 304 Not Modified
without a body to a caller that already holds that version:
@Controller({ group: CalendarsRoutes, method: 'GET', path: '/:calendarId/days/:day', summary: 'Get calendar day', params, response: { status: 200, body: answer }, cache: { scope: CacheScope.private, maxAge: '0s', mustRevalidate: true, etag: 'representation' },})| Field | Answers |
|---|---|
etag: 'representation' |
a strong tag, the SHA-256 of the bytes sent: right for any answer, and the handler still runs (it saves the transfer, not the work) |
etag: { version: 'version' } |
a weak tag, W/"7", from the version the body carries under that field (dotted: calendar.version): the version of the aggregate or of the read model shown |
lastModified: 'changedAt' |
Last-Modified, from the instant under that field (a Date or ISO 8601 text), compared to If-Modified-Since to the second |
If-None-Match is read first and compared weakly, If-Modified-Since only without it. A body
that lacks the field it names answers 500, as any answer its declaration does not allow. The
contract gains the If-None-Match and If-Modified-Since parameters, the ETag and
Last-Modified headers and the 304 answer. Under representations, the
version, the instant and the cache tags are read from what the handler answered, the same in
JSON, XML or CSV; a strong tag digests the bytes of the media type sent, so each representation
has its own, and a streamed list, whose bytes are not known when its headers go, has none.
A write made on the version a client read declares ifMatch (IfMatch.optional or
IfMatch.required). The pipeline reads the version named in If-Match (the weak tag the read
answered, or its strong form "7") and hands it to the handler as context.expectedVersion; the
use case compares it with the version of the aggregate it loaded:
// the controller: new SetOpeningHoursCommand(request.actor, calendarId, ranges, request.context.expectedVersion)// the handler of the command:const calendar = await this.calendars.findById(command.calendarId);expectVersion(calendar, command.expectedVersion); // PreconditionFailedError, 412, when it changed| Request | Answer |
|---|---|
If-Match names the current version, or * |
the change runs |
If-Match names another version, or a tag that is no version |
412 PRECONDITION_FAILED |
no If-Match, ifMatch: 'optional' |
the change runs unconditionally, as before |
no If-Match, ifMatch: 'required' |
428 PRECONDITION_REQUIRED, before the handler runs |
This is optimistic concurrency at the edge: the version a person saw on the screen, not only the
one the handler loaded a millisecond earlier. Two writes at the same instant still meet the
ConcurrencyError of the repository. RFC 9110 compares If-Match strongly, under which a weak
tag never matches: the kit compares versions instead, which is what both forms name here, and
documents it. The contract gains the If-Match parameter and the 412 (and 428) problems.
Cache tags and purge after the commit
Section titled “Cache tags and purge after the commit”A public answer names the resources it shows, so that a shared cache files it under their tags
and drops it when one of them changes, instead of waiting for maxAge:
cache: { scope: CacheScope.public, maxAge: '1m', sMaxAge: '1h', tags: [{ resource: 'calendar', param: 'calendarId' }, { resource: 'appointment', field: 'items.id' }],},| Source | Tag |
|---|---|
{ resource, param } |
<resource>:<value of the path parameter> |
{ resource, field } |
<resource>:<value> for each value at that dotted path of the body (a list is walked through) |
{ resource } |
<resource>: a change of any element of that kind purges the answer (a list, a search) |
Tags are made with cacheTag(resource, id) of the kernel, the identifier percent-encoded. A
multi-tenant application files them under the tenant of the request (acme/calendar:c-1) with
cacheTags: { header, perTenant: true }, and its purges with perTenant: true of
PublishedCacheInvalidation: both sides or neither, or the purges miss. Tags are sent only
when the application names the header its shared cache reads, cacheTags: { header } of the
options: CacheTagHeader.xkey (Varnish), surrogateKey (Fastly), cacheTag (Cloudflare),
cdnTag (Bunny); without it, no answer carries tags. The contract lists them as templates in
x-cache-tags. Tags on a private answer, or reading a parameter the path does not have, are
refused when the route is declared.
The purge is asked by the use case and done after its commit:
// application/event-handlers/calendar-opening-hours-set.event-handler.ts: before-commit, the default phase@DomainEventHandler(CalendarOpeningHoursSet)export class CalendarOpeningHoursSetEventHandler implements DomainEventHandler<CalendarOpeningHoursSet> { constructor(private readonly cacheInvalidation: CacheInvalidation) {}
async handle(event: CalendarOpeningHoursSet): Promise<void> { await this.cacheInvalidation.invalidate([cacheTag('calendar', event.aggregateId)]); }}CacheInvalidation of the kernel publishes a purge request on the publisher of the
application, which kernelModule() binds by default: behind an outbox, the request is a row of
the transaction of the command. A command that rolls back purges nothing; one that commits
purges even when the process stops right after the commit, since the row waits for the next
relay. The relay hands the requests to a CachePurger and every other event to the broker:
import { OutboxRelay, type OutboxStore } from '@ddd-ts-kit/events';import { PurgingPublisher, type Clock, type IntegrationEventPublisher } from '@ddd-ts-kit/kernel';import { VarnishCachePurger } from '@ddd-ts-kit/http/purgers';
declare const store: OutboxStore;declare const broker: IntegrationEventPublisher;declare const clock: Clock;declare const varnishUrl: string;declare const varnishPurgeKey: string;
export const relay = new OutboxRelay({ store, publisher: new PurgingPublisher(new VarnishCachePurger({ url: varnishUrl, key: varnishPurgeKey }), broker), clock,});A purge that fails throws: the relay retries it as it retries a publication, and a request
delivered twice purges twice, which changes nothing. A read that loaded the previous state just
before the commit can still reach the cache after the purge and stay for its s-maxage:
RepeatedCachePurger of the kernel purges once more a little later (new RepeatedCachePurger(varnish, { againAfterMs: 2_000 })), best effort. Without an outbox (events: in-memory) the
request reaches the in-memory bus, where nothing purges: pass cacheInvalidation to
kernelModule to purge otherwise.
@ddd-ts-kit/http/purgers holds the adapters of the CachePurger port; they need no
dependency (the fetch of Node) and are held to cachePurgerContract of
@ddd-ts-kit/testing/contracts, which the in-memory one (InMemoryCachePurger of the kernel)
passes too:
| Adapter | Purges | Tag header | Verified |
|---|---|---|---|
VarnishCachePurger({ url, key?, mode?, soft? }) |
PURGE with xkey-purge (vmod_xkey), or xkey-softpurge with soft; mode: 'ban' sends one x-ban-tag per tag, its pattern characters escaped, which the VCL turns into a ban; key goes in x-purge-key, which the VCL asks of a purge from another machine |
xkey |
the contract suite on Varnish 9.1.0 with the VCL init --http-cache varnish writes, in CI |
FastlyCachePurger({ serviceId, token, soft? }) |
POST /service/<id>/purge, 256 surrogate keys a request |
surrogate-key |
the contract suite on a fake of the documented purge API, and the requests it sends; never run against Fastly |
CloudflareCachePurger({ zoneId, token }) |
POST /zones/<id>/purge_cache with tags, 100 a request, on every plan |
cache-tag |
the contract suite on a fake of the documented purge API, and the requests it sends; never run against Cloudflare |
A purge that the cache does not confirm with a 2xx is a CachePurgeError. Bunny and the other
caches are compared on the HTTP caches page.
Errors
Section titled “Errors”A handler throws a DomainError; its code is mapped to a status in one place and answered as
application/problem+json (RFC 9457):
{ "type": "urn:problem:not-found", "title": "Not Found", "status": 404, "code": "NOT_FOUND", "detail": "Order 7 does not exist.", "requestId": "..." }A refusal built by a factory of a ConceptError carries facts, and they are private: the
document holds, under facts, only the ones its factory exposed by name, and no facts member
when it exposed none.
import { ConceptError } from '@ddd-ts-kit/kernel';
export class AppointmentError extends ConceptError { static slotTaken(calendarId: string, startsAt: Date) { return AppointmentError.refusal('SLOT_TAKEN', 'The slot is already held.', { calendarId, startsAt }).exposing('startsAt'); }}With SLOT_TAKEN mapped to 409 (createErrorMapper({ SLOT_TAKEN: 409 }), below), a handler
that throws AppointmentError.slotTaken('c-1', startsAt) answers:
{ "type": "urn:problem:slot-taken", "title": "Conflict", "status": 409, "code": "SLOT_TAKEN", "detail": "The slot is already held.", "facts": { "startsAt": "2026-03-02T09:00:00.000Z" }, "requestId": "..." }calendarId stays in the process: the factory did not expose it.
facts is an optional object of the Problem schema of the OpenAPI document. A client
branches on code and reads data from facts, never from detail, which is a sentence for a
human and may change. The message is answered as it was written: it says nothing the caller
did not send, and nothing about another person. A refusal mapped to a 5xx answers neither its
message nor its facts.
import type { Problem } from '@ddd-ts-kit/http';
// Instead of: data parsed out of the sentence, which breaks the day the sentence is rewordedexport function startsAtFromDetail(problem: Problem): string | undefined { return /held at (.+)\.$/.exec(problem.detail ?? '')?.[1];}
// Write: branch on the stable code, read the fact the refusal exposedexport function startsAtOf(problem: Problem): string | undefined { if (problem.code !== 'SLOT_TAKEN') { return undefined; }
const startsAt = problem.facts?.['startsAt'];
return typeof startsAt === 'string' ? startsAt : undefined;}Defaults: VALIDATION_FAILED 422 (with issues, paths prefixed by the part:
body.items.0.sku), NOT_FOUND 404, CONFLICT 409, INVARIANT_VIOLATION 422, UNAUTHORIZED
401, FORBIDDEN 403, NOT_ACCEPTABLE 406, PRECONDITION_FAILED 412, UNSUPPORTED_MEDIA_TYPE 415
(a body or a file of a type the route does not take), PRECONDITION_REQUIRED 428, and for files
FILE_NOT_FOUND 404, FILE_TOO_LARGE 413. A domain code that is not mapped answers 422
(fallbackStatus of the second argument changes it).
import { createErrorMapper } from '@ddd-ts-kit/http';
export const errorMapper = createErrorMapper({ ORDER_ALREADY_SHIPPED: 409, QUOTA_EXCEEDED: 429, SLOT_TAKEN: 409 });
errorMapper.statusOf('SLOT_TAKEN'); // 409, the code of the applicationerrorMapper.statusOf('NOT_FOUND'); // 404, a defaulterrorMapper.statusOf('INVALID_EMAIL'); // 422, a code not mappederrorMapper.typeOf('SLOT_TAKEN'); // 'urn:problem:slot-taken', the type of its problem documentAnything that is not a DomainError answers 500 Internal Server Error with no detail (its
code is INTERNAL_ERROR): the message of an unexpected error never leaves the process. Log it
from the onError hook, and report it to the error tracker with
onError: reportHttpErrors(errorReporter): every request answered 5xx goes to the kernel
ErrorReporter, once, with the correlation, actor and tenant ids of its execution context
(context.execution, present once the request got that far); a 4xx never does.
A refusal built by a factory (ConceptError) answers its message as detail unless the message
quotes a fact the factory did not expose: then the client reads a sentence made from the code
(INVALID_EMAIL gives Invalid email.) and the message stays in the logs and the error
reports. A fact is found in the message as a text, a number or an ISO instant; a fact written
another way (a formatted date, a rounded number) is not seen, so expose what a client may
read and keep the rest out of the sentence.
Options shared by the adapters
Section titled “Options shared by the adapters”Every mount takes the same ExecuteOptions, a plain object built where the server starts:
import { auditGuardRefusals } from '@ddd-ts-kit/audit';import { CacheTagHeader, createErrorMapper, type ExecuteOptions } from '@ddd-ts-kit/http';import type { AuditTrail, Clock, IdGenerator } from '@ddd-ts-kit/kernel';import { tracedRoutes } from '@ddd-ts-kit/tracing';
declare function authenticate(raw: unknown): Promise<unknown>;declare const logger: { error(fields: Record<string, unknown>): void };declare const trail: AuditTrail;declare const clock: Clock;declare const ids: IdGenerator;
export const options: ExecuteOptions = { errorMapper: createErrorMapper({ ORDER_ALREADY_SHIPPED: 409 }), resolvePrincipal: (raw) => authenticate(raw), // throw UnauthorizedError to refuse onError: (error, { requestId, correlationId, operationId, problem }) => { if (problem.status >= 500) { logger.error({ requestId, correlationId, operationId, error }); } }, onGuardRefusal: auditGuardRefusals(trail, { clock, ids }), // the refusals at the edge, in the audit trail validateResponses: process.env['NODE_ENV'] === 'test', // checks answers against the declaration around: tracedRoutes(), // of @ddd-ts-kit/tracing: the span of the request, named by its route template // the header the shared cache in front reads the tags from; honoursVary and perTenant: see HTTP caching cacheTags: { header: CacheTagHeader.xkey },};onGuardRefusal(refusal) is told, and awaited, every request a guard refused before its
controller (HasRole, HasScope, TenantMember, a guard of the project, and a tenant the caller
does not belong to): its operation, its problem and its execution context. Those refusals reach
no use case, so the authorization hook of @ddd-ts-kit/auth never sees them;
auditGuardRefusals(trail, { clock, ids }) of @ddd-ts-kit/audit writes each one to the audit
trail as a denied decision (an anonymous caller's excepted). Only a refusal is reported, a
DomainError such as UnauthorizedError or ForbiddenError: a guard that fails with another
error is a fault, answered 500 and not reported. A refusal of a handler is not reported there,
and a hook that fails does not change the answer.
around(route, respond) wraps the whole handling of a request, from authentication to the
answer, refusals included, and is told the route that was matched (method, path as
declared, operationId). It is where a tracer names a request POST /orders/:id/lines instead
of by its URL; this package depends on no tracing library.
The request id is the incoming x-request-id when well formed, else a UUID; it is echoed in
the x-request-id response header and in every problem.
The execution context of a request
Section titled “The execution context of a request”executeRoute builds a kernel ExecutionContext for every request and hands it to the handler
as context.execution:
| Field | Value |
|---|---|
correlationId |
the incoming x-correlation-id when well formed (letters, digits, ._:-, at most 128), else the request id; echoed in the x-correlation-id response header, on problems too, and given to onError |
origin |
http |
actor, session |
from what resolvePrincipal answered, read structurally: a Session gives both, an Actor gives the actor, anything else is the anonymous actor |
tenantId |
the tenant resolveTenant names, once the caller is known to belong to it; the tenant of the actor otherwise |
locale |
the locale of the session, else the preferred language of Accept-Language |
deadline |
arrival plus requestTimeoutMs, when the option is set |
import { principalResolver, type Authenticator } from '@ddd-ts-kit/auth';import type { ExecuteOptions } from '@ddd-ts-kit/http';import { Clock, ExecutionScope } from '@ddd-ts-kit/kernel';
declare const authenticator: Authenticator;declare const app: { resolve<T>(token: abstract new (...args: never) => T): T };
export const options: ExecuteOptions = { resolvePrincipal: principalResolver(authenticator), // of @ddd-ts-kit/auth: answers a Session executionScope: app.resolve(ExecutionScope), // the scope the application runs its use cases in clock: app.resolve(Clock), requestTimeoutMs: 10_000,};With executionScope, every handler runs inside scope.run(context.execution, ...): loggers,
audit columns, tenant scoping and outgoing headers read the context of the request with no
wrapper around the handler, and no route can forget it. withPrincipal stays, for a scope of
your own; it runs inside the execution scope. The pipeline does not interrupt a handler at its
deadline: a timeout wrapper or an outgoing call reads deadline and honours it. clock is the
option that says when a request arrived, the system clock by default.
Use cases still receive the actor explicitly: new PlaceOrder(context.execution.actor, body),
or actorOf(context.principal) of @ddd-ts-kit/auth, which reads the same actor.
The tenant of a request
Section titled “The tenant of a request”A multi-tenant application says where a request names its tenant with resolveTenant:
import { firstTenantOf, tenantFromClaim, tenantFromSubdomain, type ExecuteOptions } from '@ddd-ts-kit/http';
export const options: ExecuteOptions = { resolveTenant: firstTenantOf(tenantFromSubdomain('app.example.com'), tenantFromClaim()), // or tenantFromHeader('x-tenant-id'), tenantFromPath('tenantId')};| Resolver | Names |
|---|---|
tenantFromSubdomain(baseDomain) |
the one label left of the base domain in Host (acme.app.example.com); behind a proxy, have it pass the original host |
tenantFromHeader(name?) |
the value of a header, x-tenant-id by default |
tenantFromPath(parameter?) |
a path parameter, tenantId by default |
tenantFromClaim() |
the tenant of the credential, Actor.tenantId |
firstTenantOf(...resolvers) |
the first that names one |
servedTenantOf(resolver, served) |
what resolver names, when it is among the tenants served; any other is answered 404 (NOT_FOUND), so a credential naming a tenant the deployment does not know (its jobs, its directory) reaches nothing |
The pipeline then checks the membership itself, before any guard or handler: an authenticated
caller that does not belong to the tenant named (its tenantId, or its tenants attribute for an
actor of several tenants) is answered 403, so a header or a subdomain chosen by a client never
opens another tenant's data. An anonymous caller keeps the tenant it addressed, for the public
routes of a tenant. The guard TenantMember puts a group of routes behind a tenant: 401 to the
anonymous caller, 403 to a request in no tenant or of another tenant. Platform staff do not pass
it: they reach tenants through an elevation of the kernel (acrossTenants), behind guards of their
own. A tenant id that no tenant can have (*, a list, a :, outside the grammar of isTenantId)
is answered 404 before anything reads it, whoever calls.
| Caller | Tenant named by the request | Answer |
|---|---|---|
| anonymous | any | goes on, in the tenant it addressed; a TenantMember route answers 401 |
member of acme |
acme |
goes on, execution.tenantId is acme |
member of acme |
globex |
403, before any guard or handler, reported to onGuardRefusal |
member of acme |
none | goes on in acme, the tenant of the actor |
| anybody | * or a,b |
404 |
In the composition root
Section titled “In the composition root”The package binds and resolves nothing: routes are a plain list and ExecuteOptions a plain
object, both built where the server starts. What it takes from the application are ports,
resolved by their class:
import { createApplication, defineContextModule, kernelModule, type ContextModule } from '@ddd-ts-kit/di';import { ErrorMapper, createErrorMapper, type ExecuteOptions } from '@ddd-ts-kit/http';import { Clock, ExecutionScope } from '@ddd-ts-kit/kernel';
declare const orderingModule: ContextModule; // the module of a bounded context
const httpModule = defineContextModule({ name: 'http', register(builder) { builder.bind(ErrorMapper).toValue(createErrorMapper({ ORDER_ALREADY_SHIPPED: 409 })); },});
const app = createApplication({ modules: [kernelModule(), httpModule, orderingModule] });
export const options: ExecuteOptions = { errorMapper: app.resolve(ErrorMapper), executionScope: app.resolve(ExecutionScope), clock: app.resolve(Clock),};| What | Is | Why |
|---|---|---|
ErrorMapper |
a port: an abstract class, the class is the token | one per application, and the server and the contract generator must be given the same one; binding it is optional, createErrorMapper(...) in a constant works as well |
ExecutionScope, Clock |
ports of the kernel | resolved from the application and handed over in the options |
resolvePrincipal, withPrincipal, onError, requestId |
functions of ExecuteOptions |
hooks of one server, written where it starts; the injectable side of authentication is the Authenticator of @ddd-ts-kit/auth, which principalResolver turns into resolvePrincipal |
| the route list | a value, readonly Route[] |
the result of defineRoutes(...), passed to the mount and to generateOpenApi |
Schema |
an interface of @ddd-ts-kit/validation |
one value per request part, declared in the route |
Pagination
Section titled “Pagination”One query vocabulary and one answer for every search, on top of the kernel PageRequest and
Page: limit, offset or cursor, and sort (placedAt,-total: comma-separated fields, a
leading - for descending).
import { z } from 'zod';import type { Page, PageRequest } from '@ddd-ts-kit/kernel';import { defineRoute, pageQuerySchema, pageResponse, pageSchemaOf } from '@ddd-ts-kit/http';import { zodSchema } from '@ddd-ts-kit/validation/zod';
interface Order { readonly id: string; readonly placedAt: Date; readonly total: number;}
declare const orders: { search(page: PageRequest<'placedAt' | 'total'>): Promise<Page<Order>> };
const order = zodSchema(z.object({ id: z.string(), placedAt: z.string(), total: z.number() }), { io: 'output' });const toOrderDto = ({ id, placedAt, total }: Order) => ({ id, placedAt: placedAt.toISOString(), total });
export const searchOrders = defineRoute( { method: 'GET', path: '/orders', operationId: 'searchOrders', summary: 'Search orders', request: { query: pageQuerySchema({ sortable: ['placedAt', 'total'], maxSize: 50 }) }, responses: { 200: { description: 'A page of orders.', body: pageSchemaOf(order) } }, security: ['bearer'], }, async ({ query }) => ({ status: 200, // query is a PageRequest<'placedAt' | 'total'> body: pageResponse(await orders.search(query), toOrderDto), }),);| Helper | Does |
|---|---|
pageRequestFrom(query, options?) |
reads the four parameters, as strings or already coerced, into a PageRequest; throws the ValidationError of the pipeline, so a wrong parameter answers 422 with one issue per parameter (query.limit, query.sort...). For a route whose query schema also has filters: declare the four parameters in that schema and call it in the handler |
pageQuerySchema(options?) |
the same reading as a Schema, for a route whose whole query is the page |
pageResponse(page, map) |
the JSON body { items, total?, nextCursor? }, each item through map |
pageRequestParameters(options?) |
the JSON Schema of the query (one OpenAPI parameter per property); properties merges into a wider schema |
pageSchemaOf(itemSchema) |
the Schema of a page of items: validates the envelope and each item, describes both in the contract; shared as <Item>Page when the item is shared (shared schemas) |
options are the PageLimits of the kernel (defaultSize 20, maxSize 100) plus sortable,
the closed list of fields a client may sort by, and kind, the variant of a query that names
neither offset nor cursor (offset by default). Without sortable, a sort parameter is
refused: a client never chooses a column the read port did not offer. offset and cursor
together are refused. These helpers depend on no validation library; pageQuerySchema and
pageSchemaOf are plain Schemas and sit next to zod or class-validator schemas.
Filters and sorting
Section titled “Filters and sorting”One query grammar for every search route, on top of the kernel Criteria: the read port declares
what it accepts with defineCriteria, and the route reads the query into a criteria that
definition accepts, or answers 422.
import { criteriaQuerySchema, defineRoute, pageResponse, pageSchemaOf } from '@ddd-ts-kit/http';
const searchAppointments = defineRoute( { method: 'GET', path: '/appointments', operationId: 'searchAppointments', summary: 'Search appointments', request: { query: criteriaQuerySchema(appointmentCriteria) }, responses: { 200: { description: 'A page of appointments.', body: pageSchemaOf(appointment) } }, security: ['bearer'], }, async ({ query, context }) => ({ status: 200, // query is a Criteria<AppointmentRow> body: pageResponse(await queries.execute(new SearchAppointments(context.execution.actor, query)), toAppointmentDto), }),);GET /appointments?filter[status]=in:booked,confirmed&filter[startsAt]=gte:2026-10-01T00:00:00Z &filter[or][practitionerId]=eq:p-7&filter[or][remote]=true &sort=-startsAt,id&limit=20&cursor=eyJzdGFydHNBdCI6...| Written | Means |
|---|---|
filter[<field>]=<operator>:<value> |
one condition. The operators are those of the kernel: eq, ne, in, gt, gte, lt, lte, contains, startsWith, isNull, between |
filter[<field>]=<value> |
eq. The text before the first colon is an operator only when it is the name of one: filter[title]=10:30 review is an eq, and a value that starts like an operator is written eq:in:the morning |
several filter[...] |
joined by AND; the same field may be repeated: filter[seats]=gte:2&filter[seats]=lte:4 |
in:<a>,<b>,<c> |
a list, separated by commas. A comma inside a value is written \,, a backslash \\; a colon needs nothing |
between:<from>,<to> |
two values, both bounds included |
isNull:true, isNull:false, isNull |
without a value, or with one; isNull alone means true |
contains:<text>, startsWith:<text> |
the text as it is, commas included; case sensitive |
filter[or][<field>]=... |
the conditions written under or are alternatives; under not, the rows that satisfy them all are refused; and exists for symmetry |
filter[or][0][<field>]=...&filter[or][0][<other>]=...&filter[or][1][<field>]=... |
an index holds several conditions together inside a group: or(and(...), ...). It counts as one more level of depth, which the definition allows or not (maxDepth, 1 by default) |
sort=-startsAt,id |
comma-separated or repeated, a leading - for descending, among the fields declared sortable and the tie-breaker |
limit, offset, cursor |
the page, read by the helpers of "Pagination" with the limits of the definition |
A value is read by the type its field declares: a number in plain digits (no exponent), true
or false, a decimal kept as written, an instant in ISO 8601 with its zone
(2026-10-01T08:00:00Z, 2026-10-01T10:00:00+02:00) or a date alone, taken at midnight UTC.
Percent-encoding is the transport's: + in a zone is sent %2B. and, or, not and
numbers are reserved names: a field called so cannot be filtered. Both ways a framework hands
a query are read: the keys as written ('filter[status]', Express 5 and Fastify by default)
and already nested (filter: { status }, a qs parser).
Anything the definition does not declare is refused with the 422 problem of the pipeline, one issue per mistake, none quoting a value:
path |
code |
|---|---|
query.filter.<field> |
unknown_field, operator_not_allowed, invalid_value, in_list_too_long, invalid_filter (not written filter[<field>]=<operator>:<value>) |
query.filter |
too_deep, empty_group, too_many_filters, invalid_filter |
query.sort |
unsortable_field, invalid_sort |
query.limit, query.offset, query.cursor |
invalid_limit, invalid_offset, invalid_cursor, offset_with_cursor |
| Helper | Does |
|---|---|
criteriaQuerySchema(definition, { kind? }) |
the reading as a Schema, for a route whose whole query is the criteria |
criteriaFrom(query, definition, { kind? }) |
the same reading in a handler, for a route whose query schema holds other parameters too; throws the ValidationError of the pipeline |
criteriaParameters(definition) |
the JSON Schema of the query, one OpenAPI parameter per property: filter[<field>] for each field with an operator, with the operators in its description and in x-filter-operators, its type in x-filter-type; sort as the list of the values it accepts; limit, offset and cursor |
The scope of the actor is not a parameter: the handler of the query joins it with scoped,
and nothing in a query string can remove it.
Representations: JSON, XML, CSV
Section titled “Representations: JSON, XML, CSV”A route keeps answering a typed value its response schema checks; a Representation turns it
into the text of one media type, chosen per request by the Accept header among those the
route declares. The application registers its representations once and gives the same set to
the adapters and to the contract generator:
import { Representations, type ExecuteOptions } from '@ddd-ts-kit/http';
const representations = Representations.standard(); // JSON, XML and CSVconst options: ExecuteOptions = { ...boundary, representations };const document = generateOpenApi({ info, routes, errorMapper, representations });A route says what it answers in besides JSON (produces) and what it reads a body in besides
JSON (consumes), on @Controller or on defineRoute alike:
@Controller({ group: PartnerRoutes, method: 'GET', path: '/:practiceId/appointments', summary: 'Export appointments', params, response: { status: 200, body: streamSchemaOf(line) }, produces: ['text/csv', 'application/xml'],})| Media type | Answers | Reads | Notes |
|---|---|---|---|
application/json |
always, and first: what a request without Accept gets |
always | as before; a big integer is written as a string |
application/problem+json |
a refusal, unless XML was negotiated | RFC 9457, as before | |
application/xml |
XmlRepresentation |
XmlRepresentation, when the route consumes it |
the convention below |
application/problem+xml |
a refusal of a request that negotiated XML | <problem xmlns="urn:ietf:rfc:7807"> |
|
text/csv |
CsvRepresentation, for a page or a list of flat rows |
never | streamed; RFC 4180 |
- Negotiation (RFC 9110): the media type of highest quality among those the route offers,
the most specific range of the header deciding, the order
application/jsonthenproducesbreaking ties. None acceptable:406 NOT_ACCEPTABLEinapplication/problem+json, before the handler runs. A route that offers more than one answers withVary: accept, added to theVaryof its cache policy or the one its handler sets, and documented in the contract. A route that declares no body schema in any response (a file, a redirect, no content) is never refused 406: its answer is its own, and aReadableor bytes go out as the handler wrote them, under its content type. - Bodies: a body sent in a media type the route consumes is read by its representation
into the value the JSON path produces, then validated by the same schema; one sent in a media
type the route does not read is refused with
415 UNSUPPORTED_MEDIA_TYPE.mountExpressinstalls a text parser on each route that consumes another media type,mountFastifya content type parser,mountHonoreads it as text under itsbodyLimit: nothing to add to the application. - Streaming: a handler that answers an
AsyncIterableunder astreamSchemaOf(item)response is written item by item in every representation (one JSON array, one XML root, one CSV line per row), honouring the back pressure of the client and stopping when it leaves. Each item is read inside the execution scope of the request, as the handler was. The first item is read before the status is sent: a cursor that cannot open is answered as a problem. A failure after that cuts the response short and is told toonErrorwith the correlation of the request andinterrupted: true, to count as a server fault. - A streamed list read through the bus: build it with
everyPage((cursor) => this.queries.execute(new SearchQuery(actor, ..., pageAt(cursor)))), one query per page. Each page then goes through the middlewares of the bus: authorization, the unit of work and its transaction-scoped tenant session (the row-level security of@ddd-ts-kit/knexand@ddd-ts-kit/drizzle), timeout, tracing, metrics, error reporting. A query handler that answers a lazyAsyncIterableis read after the bus has returned, outside all of them: its reads have no transaction, no tenant session, no span. The deadline of the request (requestTimeoutMs) counts from its arrival, so a long export needs a route of its own with a longer one, or a job that writes a file. - Mount-time checks: a media type no registered representation provides, a CSV answer
whose rows hold a list or an object, and a consumed media type nothing reads are refused with
a
DeclarationErrorwhen the routes are mounted; so is a route that declaresproducesorconsumeswhen norepresentationsare mounted. A body in a consumed media type that another parser (a plugin, a middleware) already turned into an object is a defect, answered 500. - The contract follows the representations it is given, which must be those the server is
mounted with (
generateOpenApi({ representations })): every media type a route answers in is listed under its response, every media type it reads under its request body, the problem types under each refusal,406on every route whose answer a representation writes and415on every route that reads a body, declared inproducesandconsumesor not. Withoutrepresentations, the contract says JSON only and neither status, as the server answers. - Without
representations, nothing changes: every answer is JSON, whatever the request accepts. With them, an answer in the JSON of the kit stays a value in theRouteResponse(a spec callingexecuteRoutereads it as before); another representation gives bytes inbody, or chunks instream.
The serialization rules, the same in every representation
Section titled “The serialization rules, the same in every representation”| Value | Written as |
|---|---|
an instant (Date) |
ISO 8601 in UTC, 2026-10-13T07:05:00.000Z |
| a decimal | a string, decimalText(value): '12.30', never a binary number a client rounds |
| money | money(amount, currency): { amount: '12.30', currency: 'EUR' }, the ISO 4217 code always next to the amount |
| an identifier | a string; a bigint is written in full |
| a closed vocabulary | its code, the value of the object as const that declares it |
| a list | the page envelope { items, total?, nextCursor? } of pageResponse, or a streamed list |
standard-serialization.spec.ts holds the three representations to this table.
The XML convention
Section titled “The XML convention”One convention, the default of the XML object of OpenAPI, so the contract describes the XML with nothing added:
- the root element is
xml.nameof the response schema, else the operation id of the route (<getAppointment>), and declaresxmlns:xsi; - a property is an element of its name, in the order of the schema;
xml.namerenames it,xml.attribute: truemakes a scalar an attribute; a name that cannot be an element name is written<entry key="two words">; - a list is unwrapped: one element per item, named after the property (or
items.xml.name);xml.wrapped: trueputs them in an element of the property, each nameditems.xml.nameoritem; a list at the root holds<item>elements; nullis an empty element withxsi:nil="true"; a missing value is no element at all;- a scalar is the text JSON would hold: an instant in ISO 8601 UTC,
trueorfalse, a number as JavaScript writes it; a character XML 1.0 cannot carry becomes U+FFFD.
Reading follows the schema: text becomes a number or a boolean where the schema says so, repeated elements a list, an element the schema does not declare stays in the value so the schema refuses it as it refuses an unknown JSON property. The parser is the package's own and reads elements, attributes, CDATA, comments, processing instructions, the predefined entities and character references; it refuses a document type declaration, so no body can declare an entity (no XXE, no expansion bomb), and nests at most 64 elements deep.
One header line with the properties of a row in the order of the schema, then one line per
row, \r\n at the end of each. A cell holding a comma, a quote, a line break or surrounding
spaces is quoted; a text that starts with =, +, -, @, a tab or a carriage return is
prefixed with ', so a spreadsheet does not run it as a formula (a number never is). null
and a missing value are an empty cell. CSV is never read: an import is a route of its own,
with a body schema of its own.
A representation of your own
Section titled “A representation of your own”class YamlRepresentation implements Representation { readonly mediaType = 'application/yaml'; readonly problemMediaType = undefined; // refusals stay application/problem+json
write(body: unknown, shape: BodyShape): RepresentedBody { ... }}
const representations = Representations.of(new XmlRepresentation(), new YamlRepresentation());write gets the value and its shape (the JSON Schema of the response and the operation id);
read, when present, turns a body back into the value; refuses, when present, says at mount
time why a shape cannot be written. One given for application/json replaces the default.
Fields reserved to some callers
Section titled “Fields reserved to some callers”A response or a request schema marks a field as requiring a permission or a policy; the
pipeline does the rest, for every representation, instead of an if (admin) in a handler:
import { fieldDeciderOf, withFieldAccess } from '@ddd-ts-kit/http';
const answer = withFieldAccess(classSchema(AppointmentAnswer), { internalNote: { read: { permission: schedulingPermissions['appointment.note.read'] }, write: { policy: notesOfTheirOwnCalendars }, // a policy of @ddd-ts-kit/auth fits as it is subject: (row) => ({ type: 'calendar', id: row.calendarId }), // what the requirement is asked about },});
const options: ExecuteOptions = { ...boundary, fieldAccess: fieldDeciderOf({ access: app.resolve(Access), clock }) };| Where | A field the actor may not read | A field the actor may read but not set |
|---|---|---|
| an answer (JSON, XML, CSV, a streamed list) | left out; in CSV the column stays, its cells empty | kept |
| a request body | refused with 403 FORBIDDEN, naming the path (body.internalNote) |
refused the same way |
| a form described by the server | absent | read-only |
| the contract | x-field-access: { read: { permission }, write: { policy } } on the property, which is no longer listed in required |
the same |
readabsent: whoever may call the route sees the field;writeabsent: whoever sees it may set it. A field left out of a body is never judged.- A permission is decided by
access.decideOn(question)of@ddd-ts-kit/auth(elseaccess.decide), the policies of the permission then the authorizer, as for a use case; a policy named on the field is asked directly, with the time ofclock.subjectnames the element: left out, the requirement is asked about nothing, which a role grant answers and a relationship does not. - What is recorded: a field set in a body is an act, and its decision goes to the audit
trail (
onDecision) with who asked and the correlation; a field shown or left out of an answer or a form only shapes it, and its decision is not recorded. The use case keeps recording its own decision. - Cost: each requirement is decided once per element and per request, inside the execution scope of the request. A requirement about no element is asked once; a page of a hundred rows of the same calendar asks once. A streamed export whose rule names a subject per row asks one decision per distinct row (one evaluation of the policies and of the authorizer, a lookup of relationships for a relationship model); the judge remembers the last 256 elements per requirement, so its memory stays bounded whatever the length of the export. For a long export, prefer a rule without subject, or a read model that already holds only what the caller may read.
pageSchemaOf(item)andstreamSchemaOf(item)carry the restricted fields of their item. A shared schema carries them in either order:withFieldAccess(sharedSchema('Note', schema), rules),withFieldAccess(classSchema(NoteDto, { shared: true }), rules)andsharedSchema('Note', withFieldAccess(schema, rules))mark the shared definition and filter the answer alike.- A public cache policy: an answer with a field some callers may not read differs per caller.
When the routes are mounted, a
publicpolicy on such a route is refused unless itsvarynamesAuthorizationorCookie, whoever may call the route (see "HTTP caching"). The rules apply to the properties of the schema given towithFieldAccess; a nested object is restricted by wrapping its own schema. - Without
fieldAccessin the options, a restricted field is left out of every answer and refused in every body: nothing is shown by omission. - The rule
authz/fields-by-schemaof the checker and of the ESLint plugin refuses the hand-written alternative, a test on the roles of the actor underapplication/queries/;authz/in-applicationalready refuses one inpresentation/.
Forms described by the server
Section titled “Forms described by the server”A route that declares form is described as a form, from what it already declares: its request
body schema, the restricted fields of that schema, and a few presentation hints. formRoutes
serves one GET /forms/<operationId> per such route, mounted and documented like any route:
// presentation/http/appointments/book-appointment/book-appointment.controller.ts, next to its schemasconst form = defineForm<Infer<typeof body>>({ groups: [{ name: 'when', fields: ['startsAt', 'appointmentTypeId'] }], fields: { appointmentTypeId: { lookup: 'searchAppointmentTypes' }, // options from a GET route reason: { widget: 'textarea', visibleWhen: { field: 'kind', equals: AppointmentKind.consultation } }, },});
@Controller({ ..., body, form })
// the composition rootconst routes = defineRoutes(appRoutes, formRoutes(appRoutes, { fieldAccess }));GET /forms/bookAppointment?calendarId=c-1 answers JSON Forms (the default): schema,
uischema and data, which every renderer of JSON Forms takes as they are.
?format=blocks answers the neutral tree of blocks for a front end with its own engine:
{ "type": "form", "operation": "bookAppointment", "method": "POST", "path": "/appointments", "label": "bookAppointment", "blocks": [ { "type": "group", "name": "when", "label": "bookAppointment.when", "blocks": [ { "type": "field", "name": "startsAt", "label": "bookAppointment.startsAt", "widget": "datetime", "required": true, "constraints": { "format": "date-time" } } ] }, { "type": "field", "name": "internalNote", "label": "bookAppointment.internalNote", "widget": "textarea", "required": false, "readOnly": true } ] }| What | Taken from | In the tree | In JSON Forms |
|---|---|---|---|
fields, their order, required |
the request schema | field blocks |
schema |
| constraints | minLength, maxLength, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, pattern, format, minItems, maxItems |
constraints |
the same keywords in schema |
| options | an enum of the schema (a closed vocabulary), else options of the hint |
options, each with a translation key |
enum |
| a lookup | lookup: '<operationId>' of a GET route |
lookup: { operation, method, path } |
options.lookup of the control |
| widget | the hint, else the schema: select for an enumeration, checkbox, number, datetime, date, time, email, list, text |
widget |
the control, options.multi for textarea, options.format: 'radio' |
| groups | groups of the hints, nested; a nested object of the body is a group of its own |
group blocks |
Group elements |
| a field shown on a condition | visibleWhen: { field, equals } or { field, in } |
visibleWhen |
a SHOW rule |
| a field required on a condition | requiredWhen |
requiredWhen |
allOf of if/then in schema |
| labels | <operationId>, <operationId>.<group>, <operationId>.<field>, <operationId>.<field>.<option>; label of a hint replaces one |
label |
i18n |
| values known in advance | the query: ?calendarId=c-1, typed by the schema |
value |
data |
The description respects the caller. A form names the permission of the use case it
describes, asked as its @Requires asks it, from the values of the query:
const form = defineForm<Infer<typeof body>>({ requires: { permission: schedulingPermissions['appointment.book'], subject: (values) => (values.calendarId === undefined ? undefined : { type: 'calendar', id: values.calendarId }), context: (values, actor) => ({ selfService: (values.patientId ?? actor.id) === actor.id }), },});A caller who may not run the use case is refused with 403 (recorded as a decision) and learns
nothing of the fields; without fieldAccess, nobody is given a form that declares requires.
A form without requires is given to whoever may call the route. Then a field the actor may not
read (withFieldAccess) is absent, one they may read but not set is readOnly (readOnly in
schema, options.readonly on the control), with every field it holds when it is an object,
each asked about the element its subject names from the values of the query; a value of the
query for a field the caller does not see is not given back in data. The form of a route that
is not public refuses the anonymous caller with 401. The server still validates the body when
it is sent, refuses a field the caller may not set with 403, and the use case still enforces
its rules: a form carries shape and presentation, never a business rule.
Hints naming a field the body does not hold, or a lookup that is no GET route, stop
formRoutes with a DeclarationError. formDocumentsOf(routes) gives every form whole, in
both formats, for the documentation: a project generated by the CLI writes them to
openapi/forms/<operationId>.json next to its contract.
Express 5
Section titled “Express 5”import express from 'express';import type { ExecuteOptions, Route } from '@ddd-ts-kit/http';import { mountExpress } from '@ddd-ts-kit/http/express';
declare const routes: readonly Route[]; // defineRoutes(...), controllerRoutes(...)declare const options: ExecuteOptions;
const app = express();app.use(express.json()); // required: the adapter does not parse bodiesmountExpress(app, routes, options); // an express.Router() works tooapp.listen(3000);Fastify 5
Section titled “Fastify 5”import Fastify from 'fastify';import type { ExecuteOptions, Route } from '@ddd-ts-kit/http';import { fastifyRoutes, mountFastify } from '@ddd-ts-kit/http/fastify';
declare const routes: readonly Route[];declare const options: ExecuteOptions;
const app = Fastify();mountFastify(app, routes, options);// or, as a plugin:await app.register(fastifyRoutes(routes, options), { prefix: '/api' });Hono 4
Section titled “Hono 4”import { serve } from '@hono/node-server';import { Hono } from 'hono';import type { ExecuteOptions, Route } from '@ddd-ts-kit/http';import { mountHono } from '@ddd-ts-kit/http/hono';
declare const routes: readonly Route[];declare const options: ExecuteOptions;
const app = new Hono();mountHono(app, routes, { ...options, bodyLimit: 2 * 1024 * 1024 }); // bodyLimit: 1 MiB by defaultserve({ fetch: app.fetch, port: 3000 });The adapter reads the JSON body itself, chunk by chunk and up to bodyLimit bytes (1 MiB by
default, as Fastify; Express stops at 100 kB): a larger body is answered 413 as a problem
(BODY_TOO_LARGE), a body that is not JSON 400 (MALFORMED_BODY), both before the pipeline and
without holding the body whole. A body in a media type the route consumes (consumes) is read
as text under the same limit, for its representation; another media type is not read, and the
pipeline answers 415 when representations are mounted. An upload and a raw body are read from
the request stream, with the limits their route declares, and a stream answer or a streamed
list is streamed. A request carries HonoRaw as
its raw request (its headers as a record, which the authenticators of the kit read, and the
Context of Hono), and a repeated query parameter is a list, as on Express and Fastify. The
adapter imports nothing of Hono at run time and node:stream for the streams: the kit proves
Node with @hono/node-server, not Bun, Deno or an edge runtime.
NestJS
Section titled “NestJS”No decorator per route: a module mounts the routes on the underlying HTTP server at module init, whether Nest runs on Express or on Fastify. Handlers get their dependencies by closure.
import { Module } from '@nestjs/common';import type { ExecuteOptions, Route } from '@ddd-ts-kit/http';import { createRoutesModule } from '@ddd-ts-kit/http/nest';
/** A token of the Nest application, exported by OrdersModule. */abstract class OrderQueries {}
declare class OrdersModule {}declare function orderRoutes(orders: OrderQueries): readonly Route[];declare const options: ExecuteOptions;
@Module({ imports: [ createRoutesModule( { inject: [OrderQueries], useFactory: (orders: OrderQueries) => orderRoutes(orders) }, { ...options, imports: [OrdersModule] }, // the modules exporting the injected tokens ), ],})export class AppModule {}A plain array works as well: createRoutesModule(routes, options). These routes bypass Nest's
pipes, guards, interceptors, exception filters and global prefix: validation and error mapping
are those described above. Use prefixRoutes for a prefix.
OpenAPI 3.1
Section titled “OpenAPI 3.1”import { readFileSync, writeFileSync } from 'node:fs';import type { ErrorMapper, Route } from '@ddd-ts-kit/http';import { checkOpenApiDrift, generateOpenApi, stringifyOpenApi } from '@ddd-ts-kit/http/openapi';
declare const routes: readonly Route[]; // the very list the server mountsdeclare const errorMapper: ErrorMapper;
const document = generateOpenApi({ info: { title: 'Orders', version: '1.0.0' }, servers: [{ url: 'https://api.example.com' }], routes, securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } }, errorMapper, // the one the server runs with});
writeFileSync('openapi/openapi.json', stringifyOpenApi(document));
// In CI: regenerate and fail on a difference.if (checkOpenApiDrift(document, readFileSync('openapi/openapi.json', 'utf8')).drift) { process.exit(1);}stringifyOpenApi writes two-space JSON. A project generated by the CLI commits the contract in
YAML instead, openapi/openapi.yaml, through stringifyContract(document, 'yaml') of
ddd-ts-kit/dist/contracts.js in its scripts/openapi.ts (openapi.format of its config
chooses), and compares the text it writes with the committed one.
What is generated: {param} paths, one parameter per property of the params, query and headers
schemas, the request body, the declared responses, the security of every operation (an empty
list for a public route), one application/problem+json response per
status of the declared errors (codes listed in the description and in x-error-codes), 422
whenever the route validates input, a shared Problem schema. Local $defs of the schemas are
moved to components.schemas. Paths, statuses and component keys are sorted: the output does
not depend on the order of the routes, so the committed file diffs cleanly.
Shared schemas
Section titled “Shared schemas”A schema several routes use is declared once and lands once in components.schemas; each route
points at it with $ref, whether it uses it whole or inside another schema. A schema is shared
when its JSON Schema is a reference to one of its own definitions, which every adapter of
@ddd-ts-kit/validation can produce:
| Library | Shared by |
|---|---|
| zod | .meta({ id: 'Slot' }) on the zod schema, used whole or nested |
| class-validator | classSchema(SlotDto, { shared: true }); a nested class is always a component |
| TypeBox | { $id: 'Slot' } in the options of the schema, used whole or nested |
| Standard Schema | the library's own ids through Standard JSON Schema, or sharedSchema('Slot', schema) |
any Schema |
sharedSchema(name, schema) of @ddd-ts-kit/validation |
A part of a shared schema (slot.pick(...), pickClass(SlotDto, [...])) is a new shape: it is
written where it is used unless it is shared under a name of its own. Two different shapes under
one name get distinct components (Slot, Slot_2): for zod, the input and the output side of a
schema with defaults are two shapes, so a response gets an id of its own.
The platform shares three schemas of its own:
| Schema | Component | For |
|---|---|---|
acceptedSchema |
Accepted, { id } |
the answer of every command route that returns the identifier of what it created or changed |
pageSchemaOf(item) |
<Item>Page when the item is shared |
the envelope of a search answer (items, total, nextCursor) |
| the problem document | Problem |
every refusal, RFC 9457 with the stable code |
A shared request part (params, query, headers) still gives one parameter per property.
Fields reserved by withFieldAccess stay reserved whichever of the two wraps the other.
Serving the contract and its page
Section titled “Serving the contract and its page”import type { Route } from '@ddd-ts-kit/http';import { openApiDocsRoutes } from '@ddd-ts-kit/http/docs';import type { OpenApiDocument } from '@ddd-ts-kit/http/openapi';
declare const orderRoutes: readonly Route[];declare const document: OpenApiDocument; // generateOpenApi(...) of orderRoutes
export const routes = [...orderRoutes, ...openApiDocsRoutes(document, { ui: 'scalar' })];Two public routes (security: false), mounted next to the application routes and absent
from the contract they display:
the JSON document (jsonPath, default /openapi.json) and an interactive page (uiPath,
default /docs) rendered by ui: swagger (default), scalar or redoc. The page loads the
assets of the UI from a public CDN pinned to a major version; assetsUrl points it at
self-hosted files when it must work offline or under a strict content security policy. A
project generated by the CLI mounts them in src/platform/http/with-documentation.ts, with
the UI chosen by --docs-ui, unless documentation is switched off by its configuration.
A contract split into documents
Section titled “A contract split into documents”import type { Route } from '@ddd-ts-kit/http';import { openApiDocumentsRoutes } from '@ddd-ts-kit/http/docs';import { generateOpenApi, routesByDocument } from '@ddd-ts-kit/http/openapi';
declare const routes: readonly Route[];
const documents = routesByDocument(routes, [ { name: 'public', title: 'Booking API', groups: ['AppointmentRoutes'] }, { name: 'back-office', title: 'Back office', tags: ['audit'], operations: ['openPractice'] },]).map(({ name, title, routes: selected }) => ({ name, document: generateOpenApi({ info: { title: title ?? 'API', version: '1.0.0' }, routes: selected }),}));
export const served = [...routes, ...openApiDocumentsRoutes(documents, { ui: 'swagger' })];routesByDocument gives each document the routes of its route groups (a controller route
carries in groups the class names of its group and of every group above it), of its tags and
of its operations; a route may be in several documents, and a route in none is refused with a
DeclarationError naming it. Each document is generated on its own, so it holds every schema
it uses. openApiDocumentsRoutes serves each at /openapi/<name>.json with a page at
/docs/<name>, and on /docs Swagger UI with a selector between them (Scalar and Redoc show
one document at a time: /docs lists their pages); one document is served as
openApiDocsRoutes serves it. A generated project reads the documents from
openapi.documents of ddd-ts-kit.config.json, through src/platform/http/openapi-documents.ts.
Uploads and files
Section titled “Uploads and files”A controller that receives a file declares it, and the declaration is the contract: the field,
the most it may weigh and the media types it may be, which the OpenAPI document states
(multipart/form-data, x-max-bytes, x-media-types, the 413, 415 and 422 refusals).
import { z } from 'zod';import { AuthorizedCommand, FileUpload, type Actor, type CommandBus, type ReceivedFile } from '@ddd-ts-kit/kernel';import { acceptedSchema, Authenticated, Controller, RouteGroup, type Accepted, type HttpRequest, type IncomingFile,} from '@ddd-ts-kit/http';import { zodSchema } from '@ddd-ts-kit/validation/zod';
/** The command carries the file: the bus stages it before the transaction opens. */class AttachDocument extends AuthorizedCommand<string> { readonly document: FileUpload;
constructor( actor: Actor, readonly appointmentId: string, file: ReceivedFile, ) { super(actor); this.document = new FileUpload(file, 'appointment-documents'); }}
@RouteGroup({ prefix: '/appointments', guards: [Authenticated] })export class AppointmentRoutes {}
const params = zodSchema(z.object({ appointmentId: z.string().min(1) }));const body = zodSchema(z.object({ note: z.string().max(200).optional() })); // the text fields, sent before the file
@Controller({ group: AppointmentRoutes, method: 'POST', path: '/:appointmentId/document', summary: 'Attach a document to an appointment', params, body, upload: { field: 'document', maxBytes: 10_000_000, mediaTypes: ['application/pdf', 'image/*'] }, response: { status: 201, body: acceptedSchema },})export class AttachDocumentController { constructor(private readonly commands: CommandBus) {}
async handle({ params, upload, actor, }: HttpRequest<{ params: { appointmentId: string }; upload: IncomingFile }>): Promise<Accepted> { await this.commands.execute(new AttachDocument(actor, params.appointmentId, upload));
return { id: params.appointmentId }; }}The routes are mounted with an upload reader, multipartUploads() of @ddd-ts-kit/http/uploads
(on busboy, an optional peer: npm i busboy):
import type { Express } from 'express';import type { ExecuteOptions, Route } from '@ddd-ts-kit/http';import { mountExpress } from '@ddd-ts-kit/http/express';import { multipartUploads } from '@ddd-ts-kit/http/uploads';
declare const app: Express;declare const routes: readonly Route[];declare const options: ExecuteOptions;
mountExpress(app, routes, { ...options, uploads: multipartUploads() });The file reaches handle while it still streams, never held whole: the command wraps it in a
FileUpload of the kernel, which fileUploadsMiddleware of the command bus stages before the
transaction opens. On its way the content is
counted, and refused with FILE_TOO_LARGE (413) beyond maxBytes; its first bytes must prove
the media type the client declared and that type must be accepted, or it is refused with
UNSUPPORTED_MEDIA_TYPE (415), before a byte reaches the storage. A request announcing a body
far larger than the file allowed is refused before it is read; a form without the file, 422.
Text fields sent after the file are not read. fileName is shown back to people, never used as
a key: the key is generated on the server.
For a large file, the client uploads straight to the storage instead: a command signs an upload
link (FileStaging.uploadUrl) and records the grant it answers on the aggregate, the client
sends the file to the link, and a second command confirms from that grant
(FileStaging.confirm(pending), which checks the size and the media type on what arrived). A
client that drops the connection part-way fails the file stream, so a handler waiting on it
rejects instead of waiting forever.
signedFileRoutes(storage, { prefix: '/files' }) answers the signed links of a storage the
application serves itself, the local directory of development or the memory of the tests:
GET /files?token= downloads (as an attachment, nosniff, no-store), PUT /files?token=
uploads, refused with 413 beyond the size the link was signed for. They are public, the token being the grant, and are not part of the contract. With
Express, mount them before express.json(): that parser reads a JSON body first. An object
store answers its own links and needs no route.
A handler may answer a stream (Readable) as its body: it is piped to the response, with its
own content-type, whatever representations the routes are mounted with: no representation
writes a file, and a route that declares no body schema is never refused 406.
Limits
Section titled “Limits”- On Express, Fastify and NestJS, a body the framework itself refuses (malformed JSON, payload too large) is answered by the framework's own error handler, before the pipeline runs. The Hono adapter reads the body itself and answers both as problems (see "Hono 4").
- Routes are JSON first. A handler may answer a string, bytes or a stream by setting its own
content-typeheader, which no representation touches. A request body streams only as a declared upload or a raw body (rawBody): server-sent events and WebSockets are written natively in the framework. - XML is a faithful rendering of the JSON model, not a design tool for XML vocabularies: no namespaces but the default one of the root, no mixed content, no XML Schema. A partner format with a schema of its own is an anti-corruption layer with its own mapper.
- A streamed list cannot be refused once its first item is written: the connection is cut,
the client sees a truncated body, and
onErroris told. The items of a stream are not checked one by one byvalidateResponses. - CSV is written, never read, and holds flat rows only.
- A form describes scalars, nested objects and lists of a closed vocabulary; a list of objects
is a
listfield with no detail. Layout beyond groups and order is the front end's. A lookup names the route of the options; how the front end calls it and labels its items is its own. - Field access judges the fields a schema declares, not the rows: which rows an actor may read is the access scope of the query. A requirement about an element is one decision per element of the answer, not recorded; there is no batch decision of many elements at once.
- A form is given to who may run its use case only when it declares
requires; the values of its query name the element and the context, which the caller chooses, as they would in the body they send.