Proposal: the read side
This page describes what the kit adds, or would add, for reading data: criteria and filters, representations (JSON, XML and others), forms described by the server, read models that need data of another bounded context, projections and a search engine. Each part ends with what it costs and what it does not do.
Status (2026-10-13)
Section titled “Status (2026-10-13)”Three states: built in the runtime (exported by a package, documented in its README, held
by a contract suite), not yet generated by the CLI (the runtime has it, ddd-ts-kit generate and check do not know it), not built.
| Part | Built in the runtime | Not yet generated by the CLI | Not built |
|---|---|---|---|
| 1. Criteria and filters | Criteria, defineCriteria, validateCriteria, planCriteria, scoped and the in-memory evaluateCriteria in @ddd-ts-kit/kernel; the query-string grammar and its OpenAPI parameters in @ddd-ts-kit/http (criteriaQuerySchema, criteriaFrom, criteriaParameters); criteriaToKnex, criteriaToDrizzle, criteriaToPrisma, criteriaToTypeOrm; criteriaContract in @ddd-ts-kit/testing |
generate query --search writes a read port that takes the access scope and a PageRequest, an in-memory adapter and a TODO(domain) for the criteria: no defineCriteria, no filter[...] parameters |
the search engine translator; RSQL |
| 2. Representations | Representation, Representations.standard() and negotiation on Accept (406) in @ddd-ts-kit/http; JSON, application/problem+json and +xml, XML written and read (415 otherwise), CSV streamed, streamSchemaOf, everyPage to stream a search page by page through the bus; files and bodiless answers left out of negotiation; every media type, 406 and 415 in the contract; withFieldAccess and fieldDeciderOf for fields reserved to a permission or a policy, in every representation and in request bodies; the rule authz/fields-by-schema |
generated since 2026-10-13: stack.representations chosen at init, generate representation, describe lists the media types |
an XML vocabulary of its own (namespaces, mixed content); CSV input |
| 3. Forms | formRoutes at GET /forms/<operationId>, defineForm hints, JSON Forms and the neutral tree of blocks, the form given only to who may run its use case (requires), hidden and read-only fields by caller, formDocumentsOf |
generated since 2026-10-13: stack.forms chosen at init, generate form, the forms written to openapi/forms/ |
other dialects than JSON Forms, which a front end maps from the tree; a lookup renderer |
| 4. Another context's data | hydration: hydrate(...).with(...).orElse(...) in the kernel, with the failure and timeout policy; the gateway port it calls (generate gateway) |
no generated use of hydrate |
the analyzer rule: the compiler refuses a criteria on a hydrated field instead |
| 5. Projections and search | @Projection and @On (@ddd-ts-kit/kernel/declarations), their registration and metrics in @ddd-ts-kit/di; the ReadModelStore port, TableReadModelStore over InMemoryReadModelStore, KnexReadModelStore, DrizzleReadModelStore, PrismaReadModelStore, TypeOrmReadModelStore, held to readModelStoreContract; ProjectionRebuild on PostgreSQL |
generated since 2026-10-12, in the declared style: generate projection <context> <Name> <event-type> writes a projector and its spec, application/projections/ is a role of the canonical tree, the generated barrel exports a projector and registers what its constructor takes, describe and CONTEXT.md list it. Still not generated: the port of the read model with its adapter, which generate port and the ORM package give |
the search index store and the Elasticsearch and OpenSearch package; a generated rebuild job |
The reference for what is built is the README of each package: @ddd-ts-kit/kernel
("Criteria", "Hydration", "Projections"), @ddd-ts-kit/http (the query grammar, the
representations, the fields reserved to some callers, the forms),
@ddd-ts-kit/di ("Projections"), the four ORM packages ("Criteria", "Read models"). Where
this page and a README differ on a built part, the README is right. Criteria and read models
were run on PostgreSQL only.
Summary
Section titled “Summary”The write side of the kit is strict on purpose: aggregates, invariants, one transaction. The
read side has other needs: lists with filters and sorting, several output formats, screens
that mix data of several contexts, search. Treating those with the write model is what makes
aggregates grow and repositories sprout findByXAndYOrderByZ methods. The design keeps
the two apart, CQRS as the kit already uses it, and gives the read side its own small,
standard vocabulary so every project filters, pages, serializes and projects the same way.
Read the drawing from the two ends. At the top, the write side does not change: a command, an aggregate, one transaction, and the events leave through the outbox. A projector turns those events into a read model shaped for the screens that read it. At the bottom, a search never touches an aggregate: the route reads the query string into a criteria, the handler joins it to what the actor may see, the read port answers a page, and data owned by another context is added by hydration. Two boxes, the route and the read model, say which of their parts are built and which are proposed.
1. Criteria and filters
Section titled “1. Criteria and filters”Built in the runtime; not yet generated by the CLI; no search engine translator.
Problem. Every search route reinvents its query parameters, and every repository grows finder methods.
What exists. One standard, typed description of a read, in the kernel next to Page and
PageRequest. Compare the two ways of serving three screens that list appointments:
// Instead of: one finder per screen on the repository of the aggregateinterface AppointmentRepository { findByPractitionerAndStatusOrderByStartsAt(practitionerId: string, statuses: string[]): Promise<Appointment[]>; findByTenantAndStartsAtBetween(tenantId: string, from: Date, to: Date): Promise<Appointment[]>; findCancelledByPractitionerPaged(practitionerId: string, page: number): Promise<Appointment[]>; // ... and one more for the next screen, each with its own paging and its own rights check}write one read port over a read model, which declares once what a caller may filter and sort, and describe each read as a criteria:
import { Criteria, ReadModelStore, defineCriteria, offsetPage, scoped, type Page, type ReadScope } from '@ddd-ts-kit/kernel';
// application/ports/appointment-list.port.ts: the rows of the read model, and what a caller may ask of themexport interface AppointmentRow { readonly id: string; readonly status: 'booked' | 'confirmed' | 'cancelled'; readonly startsAt: Date; readonly practitionerId: string; readonly tenantId: string;}
export const appointmentCriteria = defineCriteria<AppointmentRow>({ fields: { id: { type: 'string' }, status: { type: 'string', filter: ['eq', 'in'], sortable: true }, startsAt: { type: 'instant', filter: ['gte', 'lt', 'between'], sortable: true }, practitionerId: { type: 'string', filter: ['eq'] }, tenantId: { type: 'string' }, }, defaultSort: [{ field: 'startsAt', direction: 'asc' }], tieBreaker: 'id', limits: { maxPageSize: 50 },});
export abstract class AppointmentList extends ReadModelStore<AppointmentRow> {}
// application/queries/search-appointments: the body of the handler, one read described as dataexport function upcomingOf( appointments: AppointmentList, scope: ReadScope, practitionerId: string, from: Date,): Promise<Page<AppointmentRow>> { const criteria = Criteria.of<AppointmentRow>() .where('status', 'in', ['booked', 'confirmed']) .where('startsAt', 'gte', from) .where('practitionerId', 'eq', practitionerId) .orderBy('startsAt', 'asc') .page(offsetPage({ limit: 20 }));
// The scope the authorizer computed is joined by AND: no filter of the caller can widen it. return appointments.search(scoped(criteria, scope, { ids: 'id', filter: { tenantId: 'tenantId' } }));}The compiler already holds most mistakes: where('status', 'eq', 'pending') (a value outside
the union), where('startsAt', 'contains', '2026') (an operator for strings on an instant),
where('practitionerId', 'isNull', true) (a field that is never null) and orderBy('colour')
(a field the row does not have) do not compile. What the compiler cannot
see, a field the definition does not open to callers, is refused when the criteria is planned.
- A read port declares with
defineCriteriawhich fields may be filtered and sorted, and with which operators (eq,ne,in,gt,gte,lt,lte,contains,startsWith,isNull,between). Anything else is refused at the boundary with a 422, one issue per mistake, naming the field and never quoting a value. - One query-string grammar for every route, parsed and validated by the HTTP package:
?filter[status]=in:booked,confirmed&filter[startsAt]=gte:2026-10-01&sort=-startsAt. The OpenAPI parameters come from the same definition (criteriaParameters). - Translators turn a criteria plan into the store's query: Knex, Drizzle, Prisma, TypeORM,
and an in-memory evaluator for fakes. One contract suite (
criteriaContract) holds them to the same table of criteria and answers. A search engine translator (part 5) is not built. The fake of a read port is a class of a few lines, and answers with the reference semantics:class FakeAppointmentList extends InMemoryReadModelStore<AppointmentRow> implements AppointmentList, its constructor callingsuper(appointmentCriteria). - The access scope computed by the authorizer is joined to the criteria with
scoped, with AND, and nothing in a query string can remove it: a filter never widens what the actor may see. - Groups (
and,or,not) are supported one level deep by default (maxDepth); deeper trees are an option of the definition, because they are where query performance is lost.
What generate query --search writes today is older than this: a read port taking the access
scope and a PageRequest, with the criteria left as a TODO(domain). Moving a generated
list to defineCriteria is done by hand, following the README of @ddd-ts-kit/http.
Not to confuse with the specification pattern. A specification is a business predicate on an aggregate ("is this slot bookable"), lives in the domain and is used by the write side. Criteria describe a read and know only field names.
Limits. No joins: a field of another context is not filterable unless a projection copied it (part 4). No aggregation language; counts and facets belong to the search adapter.
2. Representations and serialization
Section titled “2. Representations and serialization”Built in the runtime and generated by the CLI. The how-to is in the README of
@ddd-ts-kit/http ("Representations: JSON, XML, CSV" and "Fields reserved to some callers"),
the ideas in concepts/representations-and-forms.md,
the libraries in tools/representations-and-forms.md.
Problem. A response is a presentation concern, but the same data is wanted as JSON by the web app, XML by a partner, CSV by an export.
What exists. A route keeps returning a typed value checked against its response schema; a
Representation turns it into text for a media type chosen by content negotiation (Accept),
from the registry the platform gives the adapters and the contract generator
(Representations.standard()):
| Media type | Provided | Notes |
|---|---|---|
application/json |
default | as before; first in every negotiation |
application/problem+json / +xml |
errors | RFC 9457; +xml when XML was negotiated |
application/xml |
XmlRepresentation, no dependency |
names and attributes from the schema and its XML object, the default convention of OpenAPI for lists, xsi:nil for null; read back by the same convention; no document type declaration accepted |
text/csv |
CsvRepresentation |
for a page or a list of flat rows; streamed; never read |
- A route declares
producesandconsumes; the contract lists every media type of each route, the problem types of its refusals,406 NOT_ACCEPTABLEon every route a representation writes and415 UNSUPPORTED_MEDIA_TYPEon every route that reads a body. A route that answers a file declares no body schema and is never refused 406. - A handler that answers an
AsyncIterableunderstreamSchemaOf(item)is written item by item in every representation, inside the execution scope of its request; a cursor that cannot open is still answered as a problem, one that fails later is reported toonError.everyPagebuilds that list from one query per page sent through the bus, so every page runs under the middlewares of the bus (transaction, tenant session, timeout, tracing). - The serialization rules are standard and held once (
standard-serialization.spec.ts): an instant in ISO 8601 UTC, decimals and money as strings with a currency (decimalText,money), identifiers as strings, the page envelope, a closed vocabulary by its code. - Field visibility:
withFieldAccess(schema, { field: { read, write, subject } })reserves a field to a permission (decided byAccess) or a policy; the pipeline leaves it out of every representation, refuses it in a request body, and the contract marks it withx-field-access. A field shown or hidden is decided without being recorded; a field set is recorded. The ruleauthz/fields-by-schemarefuses the hand-written alternative in a query. - A generated project chooses
stack.representationsatinit(negotiatedby default with an HTTP stack);generate representation <context> <UseCase> xml,csvdeclares the media types of a controller.
Limits. XML here is a faithful rendering of the JSON model, not a design tool for XML vocabularies with namespaces and mixed content; a partner format with its own schema is an anti-corruption layer with its own mapper. Aggregates are never serialized: only read models and presentation types are. A streamed list cannot be refused once its first item is written. Field access judges the fields of a schema, never the rows.
3. Forms described by the server
Section titled “3. Forms described by the server”Built in the runtime and generated by the CLI.
Problem. The front end rebuilds by hand, for every command, a form that the back end already describes: fields, types, constraints, closed vocabularies.
What exists. A route that declares form (hints of defineForm, next to its schemas) is
served by formRoutes at GET /forms/<operationId>, in two formats from one tree:
- JSON Forms (schema, UI schema and data), the default, with maintained renderers for React, Angular and Vue.
- A neutral tree of blocks (
?format=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 } ] }, { "type": "field", "name": "internalNote", "label": "bookAppointment.internalNote", "widget": "textarea", "required": false, "readOnly": true, "constraints": { "maxLength": 2000 } } ] }- Blocks nest; a field has a widget, a translation key, the constraints of its schema, options
from a closed vocabulary or a lookup route, and simple conditions (
visibleWhen,requiredWhen). - The description respects the caller: the form is given only to who may run its use case
(
requires, the permission asked as@Requiresasks it), a field they may not see is absent, one they may not set is read-only (an object with every field it holds), decided by the same field access as the answers, about the element the query names (?calendarId=). - Validation stays on the server: the form mirrors shape constraints, the route validates again and refuses a field the caller may not set, and the command enforces the business rules.
- A generated project chooses
stack.formsatinit;generate form <context> <UseCase>adds the hints to a controller;npm run docs:generatewrites each form whole toopenapi/forms/<operationId>.json.
Limits. A form description carries shape and presentation, never business rules: "an appointment cannot overlap another" is answered by the command, not by the form. Layout beyond groups and order is the front end's job, and so is the behaviour of a lookup.
4. Read models that need another context's data
Section titled “4. Read models that need another context's data”Hydration is built in the runtime; the CLI generates no use of it.
Problem. The appointment list must show the practitioner's name, which belongs to the identity context. The rule stands: no join, no shared table, no reaching into another context's internals.
Three strategies, chosen per read.
| Strategy | How | Use when | Cost |
|---|---|---|---|
| Projection (default) | the consuming context keeps the few foreign fields it needs, updated by integration events | lists, filters or sorting on the foreign field, high read volume, the data may be slightly stale | eventual consistency, a projector to write, a rebuild path |
| Hydration at query time | the query handler reads its own rows, then completes them through a gateway port to the other context's published read contract, by batch of identifiers | detail views and small pages, data that must be fresh, low volume | latency and availability now depend on the other context; no filtering or sorting on hydrated fields |
| Composition above the contexts | a presentation-level route (or a front-end gateway) calls two queries and merges | screens that are truly a mix of two contexts' views | orchestration lives outside any context |
For hydration the kernel provides a small, declarative helper (hydrate) so it is never
written as a loop of calls:
// Instead of: one call to the other context per row, written by handfor (const row of page.items) { row.practitioner = await this.practitioners.byId(row.practitionerId); // 20 rows, 20 calls}import { hydrate, type Page } from '@ddd-ts-kit/kernel';
interface AppointmentRow { readonly id: string; readonly practitionerId: string; readonly locationId: string;}
interface Practitioner { readonly displayName: string;}
interface Location { readonly city: string;}
// application/ports: gateway ports to what identity and sites publish, one call for many idsexport abstract class PractitionerGateway { abstract byIds(ids: readonly string[]): Promise<ReadonlyMap<string, Practitioner>>;}
export abstract class LocationGateway { abstract byIds(ids: readonly string[]): Promise<ReadonlyMap<string, Location>>;}
const unknownPractitioner: Practitioner = { displayName: 'Unknown practitioner' };
export class AppointmentDetails { constructor( private readonly practitioners: PractitionerGateway, private readonly locations: LocationGateway, ) {}
async complete(page: Page<AppointmentRow>) { return hydrate(page) // a lookup is called without its object: wrap the method of a class in an arrow .with('practitioner', (row) => row.practitionerId, (ids) => this.practitioners.byIds(ids)) .with('location', (row) => row.locationId, (ids) => this.locations.byIds(ids)) .orElse('practitioner', unknownPractitioner, { onFailure: 'fallback', timeoutMs: 300 }); }}A page of 20 rows costs two calls, one per relation, made at the same time. Every row has a
practitioner: the fallback when identity does not know the id, and also when its lookup
fails or takes more than 300 ms, which warnings then reports (lookup_failed,
lookup_timed_out). location is optional in the type of the rows, and a location lookup
that fails fails the whole read, because that relation has no fallback.
- One call per relation per page, never one per row; identifiers are deduplicated; the
relations are looked up at the same time; the gateway can be cached with the existing
cachedaspect. - A declared policy when the other side fails or times out, per hydrated field: fail the
query (the default), or answer with the fallback and a warning (
lookup_failed,lookup_timed_out). - A criteria cannot name a hydrated field: the type of a criteria definition keeps the scalar fields of the read model, so the compiler refuses it. The rule proposed for both analyzers, with the fix "project it", is not built.
5. Projections and a search engine
Section titled “5. Projections and a search engine”Projections on SQL are built in the runtime, and generate projection writes a projector; the
CLI does not write the port of the read model with it, nor a rebuild job; the search engine is
not built.
Problem. Without a standard, read models are written by hand in integration event handlers, each with its own answer to idempotence, rebuild and lag; and some reads (full-text, facets, large filtered lists) are a poor fit for the transactional database.
What exists.
- A projector role.
@Projection('<context>.<name>')on a class ofapplication/projections/, in a*.projection.tsfile, with@On(definition)on one method per integration event it applies and@On(EventClass, { name })for a domain event of its own context. It is subscribed as a consumer under its name. It writes its read model through a port and does nothing else: it sends no command and reads no other context. Its writes are idempotent and ordered per key by construction: every row carries the stamp of the event that last changed it (stampOf), andupsertIfNewerwrites only for a newer one. - A read model store port,
ReadModelStore<Row>, with one implementation (TableReadModelStore) over a table an adapter gives: in memory for the fakes, Knex, Drizzle, Prisma and TypeORM for a SQL table in the context's schema, written in the unit of work that holds the processed-event mark. - Rebuild. A read model fed by events no store keeps is rebuilt from the source of
truth (a read model of event-sourced aggregates is rebuilt from the event store instead,
EventStoreProjectionRebuild): the owning context exposes a paged export of stamped rows through its published language,ProjectionRebuildcopies it into a new table while events keep being applied, then swaps the two in one transaction. PostgreSQL only. - Observability.
projection_events_totalby result (applied,stale,ignored,failed) andprojection_last_event_age_ms, the lag, on theMetricsport; oneprocess <projection>span per delivery; an event buried after its retries reaches the error reporter as for any consumer.
A projector of the read model of practitioner names, fed by an event identity publishes, its port, its fake, and what it does with a late or repeated event:
import { InMemoryReadModelStore, ReadModelStore, defineCriteria, stampOf, type IntegrationEvent, type ReadModelWrite } from '@ddd-ts-kit/kernel';import { On, Projection, type EventContract } from '@ddd-ts-kit/kernel/declarations';
// identity/published-language: declared by identity with defineIntegrationEvent, imported hereinterface PractitionerRenamedPayload { readonly practitionerId: string; readonly displayName: string;}declare const practitionerRenamedV1: EventContract<PractitionerRenamedPayload>;
// scheduling/application/ports/practitioner-names.port.ts: the read model and its portexport interface PractitionerName { readonly practitionerId: string; readonly displayName: string;}
export const practitionerNameCriteria = defineCriteria<PractitionerName>({ fields: { practitionerId: { type: 'string', filter: ['eq', 'in'] }, displayName: { type: 'string', filter: ['startsWith'], sortable: true }, }, tieBreaker: 'practitionerId',});
export abstract class PractitionerNames extends ReadModelStore<PractitionerName> {}
// scheduling/application/projections/practitioner-names.projection.ts@Projection('scheduling.practitioner-names')export class PractitionerNamesProjection { constructor(private readonly names: PractitionerNames) {}
@On(practitionerRenamedV1) renamed(event: IntegrationEvent<PractitionerRenamedPayload>): Promise<ReadModelWrite> { const { practitionerId, displayName } = event.payload;
return this.names.upsertIfNewer(practitionerId, stampOf(event), () => ({ practitionerId, displayName })); }}
// scheduling/test/fakes/practitioner-names.fake.tsexport class FakePractitionerNames extends InMemoryReadModelStore<PractitionerName> implements PractitionerNames { constructor() { super(practitionerNameCriteria); }}
// What a redelivery and a late event do: the row ends on the newest event, whatever the order.const renamed = (id: string, occurredAt: string, displayName: string): IntegrationEvent<PractitionerRenamedPayload> => ({ id, type: 'identity.practitioner.renamed', version: 1, occurredAt, payload: { practitionerId: 'p-7', displayName },});
export async function deliveredOutOfOrder(): Promise<PractitionerName | undefined> { const names = new FakePractitionerNames(); const projection = new PractitionerNamesProjection(names); const older = renamed('evt-1', '2026-10-01T10:00:00.000Z', 'Ada'); const newer = renamed('evt-2', '2026-10-01T10:05:00.000Z', 'Ada Lovelace');
await projection.renamed(newer); // 'applied' await projection.renamed(older); // 'stale': older than the event the row holds await projection.renamed(newer); // 'stale': the same event, delivered again
return names.get('p-7'); // { practitionerId: 'p-7', displayName: 'Ada Lovelace' }}What is still proposed.
- In the CLI. What is generated since 2026-10-12 is in the status table:
generate projection <context> <Name> <event-type>writes the projector of one integration event and its spec, and the barrel registers it. This holds in the declared style only: in the explicit style the generator refuses, and the projector is a class written by hand, registered withbuilder.include(...). Still proposed: one projector for several events (--on <event>...), a--store sql|searchthat writes the read model's port with its adapter and its fake (todaygenerate portand the ORM package, by hand), and the rebuild job. - A search index as a second implementation of the read model store.
- Search engine adapter. A package for Elasticsearch and OpenSearch (same protocol for what is needed): index per read model, mapping derived from the read model's schema, criteria translated to the query language, bulk application of events, alias switch for rebuilds without downtime, full-text and facets as extensions of criteria.
Limits. An older event skipped as stale is not applied later: an event should carry the
state of the fields it sets, or come in order per key. With an updatedAt alone as the stamp
of the source, a rebuild is exact only for events that set state, not for events that count
(the kernel README states both). A search index would be a copy, never the source of truth,
and eventually consistent: a write followed at once by a search may not see itself; the kit
would document the ways to handle it (read your own write from the SQL model, or wait for
refresh on that one request) rather than hide it. Running a search cluster is a real
operational cost; a PostgreSQL table with the right indexes, and its full-text search, covers
many projects.
What is asked of the project owner
Section titled “What is asked of the project owner”- Filter grammar: settled by what was built, the bracket form above. RSQL
(
status=in=(booked,confirmed)) is not provided. - Forms: settled by what was built: both, the block tree as the kit's own model and JSON Forms as its first mapper.
- Search engine: Elasticsearch, OpenSearch, or both behind one adapter. Recommended: one adapter tested on OpenSearch (open licence) and on Elasticsearch.
- Order of work. Criteria, hydration, projections on SQL, representations and forms are
in the runtime, representations and forms in the CLI too, and the projector is generated.
What remains, in the recommended order: criteria in the generator and the checker
(
defineCriteriaand thefilter[...]parameters fromgenerate query --search) and the rest of the projection generator (the port of the read model, the rebuild job), then the search adapter.