@ddd-ts-kit/kernel
The building blocks every context depends on, and nothing else: no framework, no ORM, no
logger, no dependency at all. It is the only package of the kit domain/ may import;
application/ imports it too, and @ddd-ts-kit/auth next to it for permissions and policies.
npm i @ddd-ts-kit/kernelTwo entries: @ddd-ts-kit/kernel (the building blocks, the ports, the standard interfaces and
their doubles) and @ddd-ts-kit/kernel/declarations (the decorators with which a class says
what it is, see "Declarations").
The kernel says what a context needs (a Clock, a UnitOfWork, a Logger) and never
how: every port is an abstract class with abstract members only. Its implementations live
elsewhere: the doubles and in-process versions of the kernel itself for tests and a single
process, the adapter packages of the kit (@ddd-ts-kit/pino, @ddd-ts-kit/redis,
@ddd-ts-kit/s3, the ORM packages, ...) for production, and the infrastructure/ of each
context for its own repositories and gateways. The platform chooses one per port when it
starts, so a business rule never changes with a library.
What is inside
Section titled “What is inside”| Area | Exports |
|---|---|
| Domain | Entity, AggregateRoot (version, recorded events), ValueObject, DomainEvent, DomainError with a stable code, ConceptError (the errors of one concept, each built by a static factory: see "Domain errors"), isRefusal, CodeOf, RefusalOf, Refusal, RefusalFacts, the generic InvariantViolation, NotFoundError, ConflictError, ConcurrencyError, PreconditionFailedError, Result with ok, err, unwrap |
| Ports | Clock, IdGenerator, UnitOfWork, Repository (abstract classes: each one is its own injection token), UseCase, DependencyKind (the roles a binding declares, read by di, tracing and aspects: repository, gateway, external, use-case, publisher, consumer, domain-event-handler, job) |
| Buses | Command, Query, CommandBus, QueryBus, BusMiddleware, the in-process InMemoryCommandBus and InMemoryQueryBus (register(type, handler, { context }) names the bounded context in MessageInfo), unitOfWorkMiddleware |
| Events | IntegrationEvent, IntegrationEventPublisher, IntegrationEventSubscriber, EventBus (both at once), EventSubscribers (the subscriber of each bounded context), ProcessedEventStore, idempotent, InMemoryEventBus, DomainEventDispatcher, IntegrationEventCallback and DomainEventCallback (the functions a subscriber and a dispatcher are given), executionContextOfEvent, inEventScope, scopedPublisher, EventDelivery (what a provider says of one delivery: attempt, last attempt, transport, source, consumer), messagingSystemOf |
| Domain events and the unit of work | dispatchingOnSave (the wrapper that makes a repository hand over what the aggregates it is given recorded), announceDomainEvents, withinUnitOfWork (what every UnitOfWork.run is built on: queue, dispatch before the commit, after-commit phase), unitOfWorkStage and UnitOfWorkStage, DomainEventPhase, DomainEventSubscription, SaveAfterCommitError, AfterCommitFailure, reportAfterCommitFailures, InMemoryDomainEventDispatcher |
| State machines | defineStateMachine, StateMachine, TransitionError, InvalidStateMachineError, mermaidOfStateMachine (see "State machines") |
| Actor | Actor, AuthorizedCommand, AuthorizedQuery, ActorContext, systemActor |
| Standard interfaces | Session, ExecutionContext and ExecutionScope, Logger, Metrics, ErrorReporter, Cache, CacheInvalidation and CachePurger (with cacheTag, tenantCacheTags, CachePurgeRequest, PublishedCacheInvalidation, PurgingPublisher, RepeatedCachePurger: see "Purging the HTTP caches"), Lock, RateLimiter, Page and PageRequest, AuditStamp, TenantScoped, AuditTrail, AuditReader and AuditEntry |
| Scheduled jobs | JobScheduler, ScheduledJob, JobExecution, JobSchedule, JobOptions, JobDefinition, JobRun, JobDescription, parseJobSchedule, describeJobSchedule, jobPolicy, executionContextOfJob |
| Background jobs | JobQueue, BackgroundJob, BackgroundJobExecution, EnqueueOptions, QueuedJob, QueuedJobStatus, backgroundJobPolicy, retryDelayMs, executionContextOfBackgroundJob, whenCommitted (see "JobQueue") |
| Process managers | ProcessManager, ProcessDecision, ProcessRunner, ProcessStore, InMemoryProcessStore, UnreadableProcessStateError (see "ProcessRunner and ProcessStore") |
| Files | FileStorage, StoredFile, FileScanner, FileKeys, TenantFileKeys, FileStaging, FileUpload, fileUploadsMiddleware, FileInspection, FileError, InMemoryFileStorage, LocalFileStorage, PurgeStagedFiles (see "FileStorage, StoredFile, FileScanner") |
| Tenancy | tenantScopeOf, tenantReachOf, acrossTenants, tenantKey, tenantCacheKey, isTenantId, TenantDirectory, InMemoryTenantDirectory (see "Tenancy") |
| Event sourcing | EventSourcedAggregate, EventStore, defineStoredEvent, EventSerializer, EventSourcedRepository, SnapshotPolicy, personalData, PersonalDataCipher, CatchUpSubscription, EventStoreProjectionRebuild, InMemoryEventStore, PostgresEventStore (see "Event sourcing") |
| Read side | Criteria, defineCriteria, validateCriteria, scoped, planCriteria, evaluateCriteria, pageOfPlan, hydrate, ReadModelStore, stampOf, TableReadModelStore, ProjectionRebuild, InMemoryReadModelStore (see "Criteria", "Hydration" and "Projections") |
| Persistence helpers | optimisticUpdate, expectVersion (the version a caller read, If-Match over HTTP, still the current one, else PreconditionFailedError: 412), schemaNameOf, stampCreation, stampUpdate, tenantScopeOf, encodeCursor, decodeCursor |
| Error reporting | ErrorReporter, ErrorReport, errorReportOf, reportOnce, isUnexpectedError, errorReportingMiddleware, DeadLetterReports, reportProcessFailures, NoopErrorReporter |
| Doubles | FixedClock, SequentialIdGenerator, InMemoryUnitOfWork, FixedActorContext, FixedExecutionScope, InMemoryLogger, InMemoryMetrics, InMemoryErrorReporter, InMemoryCache, InMemoryCacheInvalidation, InMemoryCachePurger, InMemoryLock, InMemoryRateLimiter |
Declarations (@ddd-ts-kit/kernel/declarations) |
Requires, Public, CommandHandler, QueryHandler, IntegrationEventHandler, DomainEventHandler, Scheduled, Background, Projection, On, applyingProjection, Repository, Gateway, Adapter, Policy, Edge (for the decorators of a transport package, such as @Controller), injected, injectedDependencies, declarationOf, listDeclarations, DeclarationError, Token, describeToken, tokenIdentity, portDefect |
The rules these blocks serve are in docs/ARCHITECTURE.md.
A first aggregate
Section titled “A first aggregate”The four building blocks of a model, in one file for the reading (a project gives each its own
file, and ddd-ts-kit generate writes them):
import { AggregateRoot, ConceptError, Entity, ValueObject, type DomainEvent } from '@ddd-ts-kit/kernel';
// The refusals of the concept, each a static factory (see "Domain errors").export class OrderError extends ConceptError { static invalidQuantity(units: number) { return OrderError.refusal('INVALID_QUANTITY', `${units} is not a positive whole quantity.`, { units }); }
static alreadyPlaced(orderId: string) { return OrderError.refusal('ORDER_ALREADY_PLACED', `Order ${orderId} is already placed.`, { orderId }); }
static empty(orderId: string) { return OrderError.refusal('ORDER_EMPTY', `Order ${orderId} has no line to place.`, { orderId }); }}
// A value object: immutable, compared by value, valid once built.export class Quantity extends ValueObject<{ readonly units: number }> { private constructor(props: { readonly units: number }) { super(props); }
static create(units: number): Quantity { if (!Number.isInteger(units) || units <= 0) { throw OrderError.invalidQuantity(units); }
return new Quantity({ units }); }
get units(): number { return this.props.units; }}
// An entity: known by its identity, reached only through its aggregate root.export class OrderLine extends Entity { constructor( productId: string, readonly quantity: Quantity, ) { super(productId); }}
// A domain event: a fact of the past, named <context>.<aggregate>.<fact>.export class OrderPlaced implements DomainEvent { static readonly eventName = 'ordering.order.placed';
readonly name = OrderPlaced.eventName;
constructor( readonly aggregateId: string, readonly occurredAt: Date, readonly lineCount: number, ) {}}
// The aggregate root: the only way in, every rule checked by an intention method.export class Order extends AggregateRoot { private readonly lines: OrderLine[] = []; private placed = false;
private constructor(id: string) { super(id); }
static open(id: string): Order { return new Order(id); }
add(productId: string, quantity: Quantity): void { if (this.placed) { throw OrderError.alreadyPlaced(this.id); }
this.lines.push(new OrderLine(productId, quantity)); }
place(now: Date): void { if (this.placed) { throw OrderError.alreadyPlaced(this.id); }
if (this.lines.length === 0) { throw OrderError.empty(this.id); }
this.placed = true; this.record(new OrderPlaced(this.id, now, this.lines.length)); }}
// What a spec of the aggregate checks:const order = Order.open('o-1');order.add('p-7', Quantity.create(2));order.place(new Date('2026-01-05T09:00:00Z'));
order.pullEvents(); // [OrderPlaced { aggregateId: 'o-1', lineCount: 1, ... }]Quantity.create(2).equals(Quantity.create(2)); // true: same valuenew OrderLine('p-7', Quantity.create(1)).equals(new OrderLine('p-7', Quantity.create(5))); // true: same identity| Block | What the kernel gives it |
|---|---|
Entity<Id> |
id and equals: two entities are equal when their class and their id are |
ValueObject<Props> |
frozen props, equals by value (dates by their time, lists and records deeply), toJSON |
AggregateRoot<Id> |
an entity with version, the optimistic concurrency token its repository adapter manages, record(event) for its methods and pullEvents(). The recorded events are not enumerable: spreading, comparing or serialising an aggregate never sees them |
DomainEvent |
an interface: name, occurredAt, aggregateId |
Instead of changing the state from outside, where every caller has to remember the rule:
order.status = 'placed'; // nothing checks that the order has linesevents.push({ name: 'ordering.order.placed' }); // and nothing ties the fact to the changewrite one intention method on the aggregate, which checks the rule and records the fact in the same move:
order.place(clock.now());Result, ok, err and unwrap are for a project that has value objects answer their
refusal instead of throwing it ("stack.valueObjects": "result"). Why each block exists:
Aggregates and
Entities and value objects.
Domain errors
Section titled “Domain errors”A business refusal is a DomainError: an answer the code planned for, known everywhere by its
code. A context has one error class per concept of its language, and each refusal is a static
factory of that class, named in the words of the business:
import { ConceptError } from '@ddd-ts-kit/kernel';
declare const calendar: { readonly id: string };declare const slot: { readonly startsAt: Date };
export class AppointmentError extends ConceptError { static slotTaken(calendarId: string, startsAt: Date) { return AppointmentError.refusal( 'SLOT_TAKEN', `The slot starting at ${startsAt.toISOString()} on calendar ${calendarId} is already held.`, { calendarId, startsAt }, ).exposing('startsAt'); }
static notFound(appointmentId: string) { return AppointmentError.refusal('APPOINTMENT_NOT_FOUND', `Appointment ${appointmentId} does not exist.`, { appointmentId, }); }}
throw AppointmentError.slotTaken(calendar.id, slot.startsAt);| Member | What it does |
|---|---|
protected static refusal(code, message, facts?) |
builds the refusal of a factory: an instance of the class it is called on, typed with that code and those facts. The constructor is protected and a subclass declares none, so outside its class a refusal exists only through a factory |
protected exposing(...names) |
called on the refusal by its factory: these facts may leave the process. None does by default |
protected causedBy(cause) |
called by its factory: the error this refusal translates, as cause |
code, message, name, cause |
as on any DomainError; name is the class, code the identity of the refusal |
facts |
every fact the factory was given, as plain data (RefusalFacts: text, numbers, booleans, dates, null, lists of them), for the code that catches the error and for the tests |
exposedFacts |
the facts a client may read: what @ddd-ts-kit/http writes under facts in the problem document |
toJSON() |
{ name, code, message }, plus facts when some are exposed: never the stack, the cause or a private fact |
static is(error, code?) |
narrows what was caught to a refusal of this class, of this code when one is given |
Instead of a bare Error, which nothing downstream can tell from a bug:
throw new Error(`Slot ${startsAt.toISOString()} is taken`); // reported as unexpected, answered as a 500write the refusal of the concept, which every layer knows by its code:
throw AppointmentError.slotTaken(calendar.id, slot.startsAt); // a planned answer: a 4xx with code SLOT_TAKENThe HTTP status of each code is chosen in the presentation layer, in one place; the error
logs, the spans and the audit trail record the code, and the error reporter leaves a
DomainError alone (the two exceptions are under "ErrorReporter").
The codes of a class are a closed vocabulary the compiler derives from its factories, so a
switch is exhaustive and each case knows its facts:
import { isRefusal, type CodeOf } from '@ddd-ts-kit/kernel';
type AppointmentErrorCode = CodeOf<typeof AppointmentError>; // 'SLOT_TAKEN' | 'APPOINTMENT_NOT_FOUND'
if (AppointmentError.is(error)) { switch (error.code) { case 'SLOT_TAKEN': return retryAfter(error.facts.startsAt); // a Date case 'APPOINTMENT_NOT_FOUND': return forget(error.facts.appointmentId); }}
if (AppointmentError.is(error, 'SLOT_TAKEN')) { error.facts.calendarId; // a string}
isRefusal(error, 'SLOT_TAKEN'); // whatever the class: the code is what everything downstream keys onerror instanceof AppointmentError && error.code === 'SLOT_TAKEN' works too, without the facts
of that code. A factory lets its return type be inferred: an annotation : AppointmentError
would hide its code from CodeOf.
Facts are private. A message is written for a human and answered to the client as detail;
the facts are data, and stay in the process unless the factory names the ones that may leave.
Never expose what identifies a person. The audit trail and the spans record the code alone.
One class per error is the form of 0.1 and 0.2, and keeps working: DomainError is still
abstract over code, its constructor still public, and InvariantViolation, NotFoundError,
ConflictError and ConcurrencyError, each one refusal under a fixed code, are still built
with new.
export class SlotTakenError extends DomainError { readonly code = 'SLOT_TAKEN';}A project says which form it writes with "stack.refusals" in ddd-ts-kit.config.json
(factories, what init writes, or classes, what a config without the key means); the
errors/* rules hold the first, and ddd-ts-kit error-factories moves a project from the
second. Both forms can live in one project.
State machines
Section titled “State machines”import { AggregateRoot, ConceptError, defineStateMachine } from '@ddd-ts-kit/kernel';
export const ParcelStatus = { Registered: 'registered', Shipped: 'shipped', Delivered: 'delivered' } as const;export type ParcelStatus = (typeof ParcelStatus)[keyof typeof ParcelStatus];
// domain/errors/parcel.error.tsexport class ParcelError extends ConceptError { static unpaid(parcelId: string) { return ParcelError.refusal('PARCEL_UNPAID', `Parcel ${parcelId} is not paid yet.`, { parcelId }); }
static cannot(parcelId: string, from: ParcelStatus, transition: string) { return ParcelError.refusal('PARCEL_TRANSITION_REFUSED', `Parcel ${parcelId} cannot ${transition} once ${from}.`, { parcelId, from, transition, }); }}
// What the transitions are judged with: the aggregate hands it to `next`.interface ParcelMoment { readonly parcelId: string; readonly paid: boolean;}
// domain/state-machines/parcel.state-machine.tsexport const parcelLifecycle = defineStateMachine({ states: Object.values(ParcelStatus), initial: [ParcelStatus.Registered], transitions: { ship: { from: [ParcelStatus.Registered], to: ParcelStatus.Shipped, guard: ({ parcelId, paid }: ParcelMoment) => (paid ? undefined : ParcelError.unpaid(parcelId)), }, reroute: { from: [ParcelStatus.Shipped] }, deliver: { from: [ParcelStatus.Shipped], to: ParcelStatus.Delivered }, }, refused: ({ from, transition }, { parcelId }: ParcelMoment) => ParcelError.cannot(parcelId, from, transition),});
// domain/aggregates/parcel.aggregate.ts: the aggregate asks the machine, never tests its status by handexport class Parcel extends AggregateRoot { private status: ParcelStatus = parcelLifecycle.start(ParcelStatus.Registered);
private constructor( id: string, private readonly paid: boolean, ) { super(id); }
static register(id: string, paid: boolean): Parcel { return new Parcel(id, paid); }
ship(): void { this.status = parcelLifecycle.next(this.status, 'ship', this.moment()); }
deliver(): void { this.status = parcelLifecycle.next(this.status, 'deliver', this.moment()); }
private moment(): ParcelMoment { return { parcelId: this.id, paid: this.paid }; }}
Parcel.register('p-1', false).ship(); // throws PARCEL_UNPAID: the guard refusedParcel.register('p-2', true).deliver(); // throws PARCEL_TRANSITION_REFUSED: registered cannot deliver
parcelLifecycle.allowedFrom(ParcelStatus.Shipped); // ['reroute', 'deliver']parcelLifecycle.isFinal(ParcelStatus.Delivered); // trueThe lifecycle of an aggregate, in domain/state-machines/<aggregate>.state-machine.ts.
next(from, transition, context) answers the next status or throws: the refusal refused builds
from a static factory for a transition the state does not allow, or the refusal of a guard.
start(state) checks an initial state, can(...) answers without throwing, allowedFrom(state)
and isFinal(state) read the graph, describe() gives it as data and
mermaidOfStateMachine(description) as a Mermaid diagram. A transition without to keeps the
state. A declaration naming an unknown state throws InvalidStateMachineError when the file
loads. Why, and the rule that holds the aggregate to it:
State machines.
Domain events and the unit of work
Section titled “Domain events and the unit of work”An effect of a domain event exists only if the state change that recorded it is committed.
An aggregate records its events; nothing in a use case hands them over. A repository wrapped
by dispatchingOnSave(repository, () => dispatcher) pulls them itself, once a method that was
handed the aggregate has resolved:
const orders = dispatchingOnSave(new KnexOrderRepository(db), () => dispatcher);
await unitOfWork.run(async () => { await orders.save(order); // persisted; the events `order` recorded are queued await orders.saveAll([first, second]); await orders.remove(order); // a "deleted" event is queued with the removal}); // dispatched here, in recording order, before the commitkernelModule() of @ddd-ts-kit/di applies it to every binding of kind repository, so an
application never writes this line; the table of what it guarantees (ordering, re-entrancy,
failures, wrapper order) is in the README of that package. In short:
- Inside a unit of work a save only queues, in recording order across all the saves. Once the work has returned, still inside the transaction, the queue is dispatched: every handler sees every save. Events recorded by handlers that save wait behind the others, in the same order, without recursion. Then the transaction commits.
- The events are pulled only when the call succeeded. The first
before-commithandler that fails rejects the unit of work; so does the work, a later save or the commit. The queued events are then dropped and never put back on the aggregates: a retry reloads them. - Outside any unit of work the save dispatches before it resolves,
before-commithandlers thenafter-commitones.
The whole timeline, with the doubles of the kernel and nothing else, as a spec would set it up:
import { AggregateRoot, InMemoryDomainEventDispatcher, InMemoryUnitOfWork, dispatchingOnSave, type DomainEvent,} from '@ddd-ts-kit/kernel';
class CartOpened implements DomainEvent { static readonly eventName = 'shop.cart.opened';
readonly name = CartOpened.eventName;
constructor( readonly aggregateId: string, readonly occurredAt: Date, ) {}}
class Cart extends AggregateRoot { static open(id: string, now: Date): Cart { const cart = new Cart(id);
cart.record(new CartOpened(id, now));
return cart; }}
class InMemoryCarts { readonly saved: string[] = [];
async save(cart: Cart): Promise<void> { this.saved.push(cart.id); }}
const journal: string[] = [];const dispatcher = new InMemoryDomainEventDispatcher();const carts = dispatchingOnSave(new InMemoryCarts(), () => dispatcher);const unitOfWork = new InMemoryUnitOfWork();
dispatcher.on(CartOpened.eventName, async (event) => { journal.push(`before-commit ${event.aggregateId} sees ${carts.saved.join(' and ')}`);});dispatcher.on(CartOpened.eventName, async (event) => void journal.push(`after-commit ${event.aggregateId}`), { phase: 'after-commit',});
const now = new Date('2026-01-05T09:00:00Z');
await unitOfWork.run(async () => { await carts.save(Cart.open('c1', now)); journal.push('saved c1'); await carts.save(Cart.open('c2', now)); journal.push('saved c2');});
// journal: saved c1, saved c2,// before-commit c1 sees c1 and c2, before-commit c2 sees c1 and c2,// after-commit c1, after-commit c2pullEvents() stays public for this wrapper and for the specs of an aggregate; a use case and
a repository adapter never call it. An aggregate is recognised by what it does (it has
pullEvents), so a context compiled against another copy of the kernel is followed too.
Two phases
Section titled “Two phases”| Phase | Declared | Runs | A failure |
|---|---|---|---|
before-commit (default) |
dispatcher.on(name, callback) |
at the end of the unit of work, inside its transaction | fails the command; everything rolls back |
after-commit |
dispatcher.on(name, callback, { phase: 'after-commit' }) |
once the transaction has committed, never when it rolled back, in dispatch order, awaited before the command answers | never fails the command: reported, and the other handlers still run |
With declarations the phase is an option of the decorator:
@DomainEventHandler(OrderPaid, { phase: 'after-commit' }).
The after-commit phase is at most once: a crash between the commit and the run loses it. What
must not be lost goes through the outbox as an integration event. A before-commit handler
may only have effects the transaction covers, because the commit can still fail after it ran
(domain-events/transactional-before-commit).
DomainEventDispatcher.dispatch(events, phase?) runs the handlers of one phase,
before-commit by default. InMemoryDomainEventDispatcher(failed?) is told each failure of an
after-commit handler and keeps the last hundred in afterCommitFailures, for specs.
reportAfterCommitFailures({ reporter, logger, scope? }) is the failed to give it wherever a
logger and an error reporter exist: one Logger.error entry ("A domain event handler failed
after the commit", with domainEvent, errorType, errorCode) and the error handed to the
ErrorReporter under the operation after-commit <event name>.
An after-commit handler that hands an aggregate to a wrapped repository (save, saveAll,
remove(aggregate)) is refused before the write with SaveAfterCommitError: the unit of work
is over. It sends a command through the CommandBus, which opens a unit of work of its own.
unitOfWorkStage() answers 'outside', 'open', 'before-commit' or 'after-commit'.
A unit of work of your own
Section titled “A unit of work of your own”The queue lives in an AsyncLocalStorage scope opened by withinUnitOfWork(work, transaction).
InMemoryUnitOfWork and the units of work of the ORM packages are built on it, so a run
called directly (a consumer, a script) has the timing of a command. unitOfWorkMiddleware
calls it too: a command is covered even with a UnitOfWork that does not. A nested run, or a
command sent from inside a unit of work, joins the scope in progress: one dispatch, at the end
of the outermost, and the after-commit phase after the outermost commit. Two concurrent
commands never share a scope.
A UnitOfWork written in a project builds run on it. transaction begins, calls what it is
given, commits when it resolved and rolls back when it rejected:
run<T>(work: () => Promise<T>): Promise<T> { return withinUnitOfWork(work, (scoped) => { if (this.current.getStore() !== undefined) { // nested: join the transaction return scoped(); }
return this.database.transaction((transaction) => this.current.run(transaction, scoped)); });}unitOfWorkContract of @ddd-ts-kit/testing/contracts fails an adapter that forgot.
Declarations
Section titled “Declarations”A class says what it is, and the composition root is assembled from what the classes say.
@ddd-ts-kit/kernel/declarations holds the decorators that record it. They are pure metadata:
a declaration instantiates nothing, resolves nothing, reads no configuration and knows no
container. That is why they live here, where the application layer may import them. Turning
declarations into bindings is the job of @ddd-ts-kit/di (contextModule,
discoverContext), and of nothing else.
import { Repository } from '@ddd-ts-kit/kernel';import type { Order } from '@/contexts/ordering/domain/aggregates/order.aggregate';
export abstract class OrderRepository extends Repository<Order> { abstract findOpenOf(customerId: string): Promise<readonly Order[]>;}import { Clock } from '@ddd-ts-kit/kernel';import { CommandHandler } from '@ddd-ts-kit/kernel/declarations';import { OrderRepository } from '@/contexts/ordering/domain/repositories/order.repository';import { PlaceOrder } from '@/contexts/ordering/application/commands/place-order/place-order.command';
@CommandHandler(PlaceOrder)export class PlaceOrderHandler implements CommandHandler<PlaceOrder> { constructor( private readonly orders: OrderRepository, private readonly clock: Clock, ) {}
async execute(command: PlaceOrder): Promise<string> { /* ... */ }}import { Clock, ExecutionScope } from '@ddd-ts-kit/kernel';import { Repository } from '@ddd-ts-kit/kernel/declarations';import { Database } from '@/contexts/ordering/application/ports/database.port';import { OrderRepository } from '@/contexts/ordering/domain/repositories/order.repository';
@Repository(OrderRepository)export class KnexOrderRepository implements OrderRepository { constructor( private readonly database: Database, private readonly scope: ExecutionScope, private readonly clock: Clock, ) {}
/* findById, save, findOpenOf */}Repository is two things under one name, each in its entry: the abstract class a repository
port extends (main entry, imported by the port) and the decorator of its adapter (this entry,
imported by the adapter). No file needs both.
Ports and tokens
Section titled “Ports and tokens”A port is an abstract class, and the class is the injection token. The class names the
dependency and carries its type: a constructor parameter typed by it names what is injected, and
app.resolve(Clock) answers a Clock. Token<T> is that type: abstract new (...args: never) => T.
There is no token constant and no token file. A port has one place, one abstract class per file:
| Port | File |
|---|---|
| a repository of the domain | domain/repositories/*.repository.ts |
| any other port, and a value class | application/ports/*.port.ts |
export abstract class PaymentGateway { abstract charge(orderId: string, amount: Money): Promise<PaymentReceipt>;}The port rule. A port has abstract members only: no body, no constructor parameter, no
private or protected member. A port may extend another port. An adapter implements its port
and never extends it. The type stays structural, so a fake, an in-memory class or a plain
object literal stands for a port without ceremony.
import { Clock } from '@ddd-ts-kit/kernel';import { Adapter } from '@ddd-ts-kit/kernel/declarations';
interface Money { readonly amount: string; readonly currency: string;}
interface PaymentReceipt { readonly reference: string; readonly paidAt: Date;}
// application/ports/payment-gateway.port.ts: abstract members onlyexport abstract class PaymentGateway { abstract charge(orderId: string, amount: Money): Promise<PaymentReceipt>;}
// infrastructure/external/http-payment-gateway.adapter.ts: implements its port, never extends it@Adapter(PaymentGateway, { kind: 'external' })export class HttpPaymentGateway implements PaymentGateway { constructor(private readonly clock: Clock) {}
async charge(orderId: string, amount: Money): Promise<PaymentReceipt> { // the call to the payment provider goes here return { reference: `${orderId}:${amount.amount}:${amount.currency}`, paidAt: this.clock.now() }; }}
// test/fakes: a plain object is a PaymentGateway, since the type is structuralexport const acceptingGateway: PaymentGateway = { charge: async (orderId) => ({ reference: `fake-${orderId}`, paidAt: new Date('2026-01-05T09:00:00Z') }),};Repository<A, Id> is the contract every repository shares (findById, save), as an
abstract class. The repository port of an aggregate extends it and adds its own finders, as
OrderRepository above; the adapter implements that port.
A value that is no port (a configuration object, a database handle, a list, a primitive)
is typed by an abstract class too, with abstract readonly properties, and a plain object is
bound to it with toValue of @ddd-ts-kit/di:
export abstract class Database { abstract readonly client: Knex;}
// contexts/ordering/application/ports/payment-settings.port.tsexport abstract class PaymentSettings { abstract readonly apiKey: string;}
// composition rootbuilder.bind(Database).toValue({ client: knex });builder.bind(PaymentSettings).toValue({ apiKey: configuration.paymentApiKey });That is the one way: no string token, no symbol token, no helper. Instead of a token next to the type, two names to keep in step:
export const PAYMENT_GATEWAY = Symbol('PaymentGateway');
constructor(@Inject(PAYMENT_GATEWAY) private readonly payments: PaymentGateway) {}write the type alone, which is the token:
constructor(private readonly payments: PaymentGateway) {}Identity across copies. A class is its own identity, and two copies of a package loaded in
one process (a duplicated install, a bundle next to node_modules) hold two classes. A port
that declares static readonly tokenKey = '<package>.<Port>' is known by that key instead,
the same in every copy: tokenIdentity(port) answers what a container keys by, and
describeToken(port) its name. Every injectable port of the kernel carries one (kernel.Clock,
kernel.CommandBus, ...); Repository<A, Id> is a base to extend, never a token, and carries
none.
export abstract class PaymentGateway { static readonly tokenKey = 'ordering.PaymentGateway';
abstract charge(orderId: string, amount: Money): Promise<PaymentReceipt>;}The limit, plainly: a port of your own that declares no tokenKey and is loaded twice is
two tokens. What is bound under one copy is not found under the other. An application
compiled as one program never meets it; a port published in a package that may be installed
twice, or bundled next to node_modules, declares a tokenKey. The key is the identity: two
ports that declare the same key are the same token, so prefix it with the package or the
context. It is declared, never inherited: a port that extends a keyed port declares its own.
When to declare one: only for a port that one process may load from two copies of its package. A port of an application, compiled once into one program, never declares a key: the class is identity enough, and a key would only be a name to keep unique by hand.
A port that extends a port of the kernel declares its key as any other does, without
override: the kernel ports carry theirs as an own property that is not a declared member
(kernel.<ClassName>, set in a static block), so nothing is overridden.
export abstract class OrderRepository extends Repository<Order> { static readonly tokenKey = 'ordering.OrderRepository';}
export abstract class BusinessClock extends Clock { static readonly tokenKey = 'ordering.BusinessClock';}What a class depends on is said by its constructor. A decorator lists nothing: each
constructor parameter is typed by one class, its token, and ddd-ts-kit docs reads the
signatures from the sources into the generated file of the context, which registers them with
injected:
import { AggregateRoot, Clock, Command, Repository } from '@ddd-ts-kit/kernel';import { CommandHandler, injected, injectedDependencies } from '@ddd-ts-kit/kernel/declarations';
declare class Order extends AggregateRoot { static place(id: string, customerId: string, now: Date): Order;}
export abstract class OrderRepository extends Repository<Order> {}
export class PlaceOrder extends Command<string> { constructor( readonly orderId: string, readonly customerId: string, ) { super(); }}
@CommandHandler(PlaceOrder)export class PlaceOrderHandler implements CommandHandler<PlaceOrder> { constructor( private readonly orders: OrderRepository, private readonly clock: Clock, ) {}
async execute(command: PlaceOrder): Promise<string> { const order = Order.place(command.orderId, command.customerId, this.clock.now());
await this.orders.save(order);
return order.id; }}
// contexts/ordering/ordering.declarations.ts, generatedinjected(PlaceOrderHandler, [OrderRepository, Clock]);
injectedDependencies(PlaceOrderHandler); // [OrderRepository, Clock]: what a container builds the handler withinjected(Class, [Tokens]) records one token per constructor parameter, in order, in a
registry shared by every copy of the kernel a process loads; injectedDependencies(Class)
reads it back, and a container (@ddd-ts-kit/di) is what uses it. Nothing here reads
design:paramtypes: that metadata exists only with legacy decorators compiled by tsc or
swc, not with standard decorators nor under tsx or esbuild. A decorator still accepts a list
as its last argument, @CommandHandler(PlaceOrder, [OrderRepository, Clock]): the form 0.1
projects wrote. A declaration then carries it in dependencies, which is undefined when
the constructor is the statement, and a container gives it precedence.
What the compiler refuses, in both decorator modes:
- a list, of
injectedor of a decorator, that does not name one port per constructor parameter, each of the type of its parameter, in order: a missing or extra entry, a wrong type, a wrong order;injected(Class)without a list for a constructor that takes something; - a class that does not implement the port it is declared the adapter of, and an abstract class;
- a concrete class named where its port is expected:
[InMemoryOrderRepository, Clock]for a parameter typedOrderRepository. A list names ports, never what implements them.
What changed with the list becoming optional: a decorator without a list accepts any constructor, so the compiler no longer tells, at the decorator, a class whose list was forgotten. It does not have to: the list is derived from the constructor, checked where it is generated, and a class left without one is refused by name when the application is composed.
What is refused when the file loads, with a DeclarationError from @Repository,
@Gateway and @Adapter, because the compiler cannot see it:
- a port with a body:
TalkativePharmacy implements greet: a port is an abstract class with abstract members only. A member inherited from a parent port counts; - an adapter that extends its port:
CornerPharmacy extends Pharmacy: an adapter implements its port, it never extends it.
portDefect(port, implementation) is that check, answering the reason or undefined; a
container applies it to a binding made by hand. A class bound to itself is no port and is
left alone.
The decorators
Section titled “The decorators”| Decorator | On | Example |
|---|---|---|
Requires(permission, options?) |
a command or query that carries its actor | @Requires(permissions['visit.schedule'], { resource: (c: ScheduleVisit) => ({ type: 'patient', id: c.patientId }), audited: ['startsAt'] }) |
Public(options?) |
a command or query anybody may send | @Public() |
CommandHandler(Message) |
the one handler of a command | @CommandHandler(PlaceOrder) |
QueryHandler(Message) |
the one handler of a query | @QueryHandler(GetOrder) |
IntegrationEventHandler(definition, { consumer, actorOf? }) |
the handler of an integration event of another context | @IntegrationEventHandler(OrderPlaced, { consumer: 'billing.invoice-on-order-placed' }) |
DomainEventHandler(Event) |
a handler of a domain event of its own context | @DomainEventHandler(OrderPaid), attached under the name the event class declares once in static readonly eventName |
Scheduled(name, schedule and options) |
a job run by time, which triggers use cases | @Scheduled('billing.send-reminders', { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' }) |
Projection(name) with On(definition) on its methods |
a projector: the class that keeps one read model up to date | @Projection('scheduling.appointment-list'), @On(PaymentCaptured) |
Repository(Port, options?) |
the adapter of a repository of the domain | @Repository(OrderRepository) |
Gateway(Port, options?) |
the adapter of a port that calls another context | @Gateway(StockGateway) |
Adapter(Port, options?) |
the adapter of any other port | @Adapter(PaymentGateway, { kind: 'external' }) |
Policy(permission, { name }) |
a rule that needs the loaded element | @Policy(permissions['invoice.void'], { name: 'billing.only-issuer-voids' }) |
- Access is said on the message. A command extends
CommandorAuthorizedCommand, a queryQueryorAuthorizedQuery: the base class already says which of the two it is, so the decorator only says who may send it.@Requiresis refused by the compiler on a message without an actor.auditedlists the fields an audit trail may record (a name, or{ field, masked: true }), as plain data; the compiler checks that the message has them. - Dependencies are said by the constructor, as above. A list written as the last argument of a decorator, the form of 0.1, names one port per parameter and is checked against the constructor. There is no parameter decorator and no reflection.
- The role is checked too: a handler of another message or of another result, an event
handler of another payload, a job without
runare refused by the compiler. - Options of a repository, a gateway and an adapter:
transient: true(one instance per resolution instead of one per application),replaces: true(takes the place of a binding made earlier, such as a default of the kernel module),aspects: [...](wrappers, the first outermost); an adapter also takeskind: 'external' | 'publisher'. - One name for the decorator and for the shape of its class:
CommandHandler,QueryHandler,IntegrationEventHandlerandDomainEventHandlerare each a decorator and a type, so one import serves@IntegrationEventHandler(OrderPlaced, ...)andimplements IntegrationEventHandler<OrderPlacedPayload>. A job implementsScheduledJob. - Names are written, not derived: the consumer name, the policy name, the job name and the
name of the domain event survive the renaming of a class, and the empty string is refused.
The name of a domain event is written once, in
static readonly eventNameon its class: its handlers and projections read it there (NamedDomainEventType), and a class without it is refused by the compiler. - A declared class is its own token:
@ddd-ts-kit/dibinds a handler, a job or a policy under its class.
declarationOf(Class) answers what a class declared, as frozen plain data, or undefined;
listDeclarations() lists the declared classes the process still holds, for diagnostics. A
class plays one role: a second declaration on the same class throws DeclarationError when
the file loads, and so does @Requires or @Public on what is neither a Command nor a
Query, and @Scheduled with a name or a schedule that cannot be read. A declaration is not
inherited by a subclass.
The names of the main entry differ on purpose: there, IntegrationEventCallback and
DomainEventCallback are the plain functions given to subscribe and to on, and
CommandHandler and QueryHandler are the interfaces alone.
Where it is stored. In one registry per process, reached through Symbol.for, with the
classes held weakly. A context compiled against one copy of the kernel is therefore read by a
container holding another (a duplicated install, a bundle next to node_modules), and a module
reloaded by a watcher leaves no trace of its former classes. Symbol.metadata is not used:
Node 24 does not define it, and the legacy decorator mode has no context to carry it.
Not in the domain. domain/ carries no decorator: aggregates, entities, value objects,
domain events, domain services and repository ports declare nothing. A domain event is
named by the handler that reacts to it, not by itself. @UseCase does not exist: the
canonical tree has one handler per command and no application service to bind.
Integration events and their subscribers
Section titled “Integration events and their subscribers”IntegrationEventPublisher is what use cases publish through, IntegrationEventSubscriber
what handlers are subscribed on, and EventBus both at once. EventSubscribers gives each
bounded context its subscriber: forContext(name) answers an IntegrationEventSubscriber.
One bus serves every context by default; a broker gives each consuming context its own, so
that a context extracted into its own process keeps its queues. @ddd-ts-kit/di subscribes
the integration event handlers of a context on EventSubscribers.forContext(<context name>).
Scheduled
Section titled “Scheduled”@Scheduled('billing.send-reminders', { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' })export class SendRemindersJob implements ScheduledJob { constructor(private readonly commands: CommandBus) {}
async run(execution: JobExecution): Promise<void> { await this.commands.execute(new SendReminders(systemActor('billing.send-reminders'), execution.scheduledAt)); }}A job lives in application/scheduling/, in a *.job.ts file, and only triggers use cases:
it receives buses and ports by its constructor and typically sends one command. The second
argument is the schedule and, next to it, the options:
| Key | Default | |
|---|---|---|
every |
an interval, as milliseconds or '30s', '15m', '2h', '1d', aligned on the Unix epoch |
|
cron, timeZone |
timeZone: 'UTC' |
the five standard fields, on the clocks of an IANA zone |
overlap |
'skip' |
an occurrence that comes while the previous run is in progress is skipped, or 'queue'd behind it |
missed |
'skip' |
occurrences that passed without being reached are dropped, or the latest is 'run-once' |
timeoutMs |
none | the signal of a longer run is aborted |
jitterMs |
0 |
a random delay before each run |
enabled |
true |
meant to be overridden by configuration where the job is wired |
singleInstance |
true |
the scheduler takes the Lock of each occurrence |
Exactly one of every and cron is written, and the compiler refuses the rest. The name is
<context>.<job-name> in lower case, unique in the application. JobExecution is what run
receives: scheduledAt, startedAt, attempt and an abort signal. What the schedules mean
across a change of clocks, what one instance among several guarantees and how a job is tested
are in the README of @ddd-ts-kit/scheduling.
Background
Section titled “Background”@Background({ maxAttempts: 3, timeout: '10m' })export class CompileStatementsBackgroundJob implements BackgroundJob<CompileStatementsPayload> { static readonly jobName = 'billing.compile-statements';
constructor(private readonly commands: CommandBus) {}
async run(payload: CompileStatementsPayload, execution: BackgroundJobExecution): Promise<void> { await this.commands.execute(new CompileStatements(execution.jobId, payload.month)); }}A background job is named by its static jobName, which is how a use case enqueues it
(jobs.enqueue(CompileStatementsBackgroundJob, { month }), the payload typed by run). The decorator
takes the options, all optional: maxAttempts (5), backoff ({ baseMs: 1000, factor: 2, maxMs: 300000 }), timeout and lease (30 s), as milliseconds or '10m'. A name that is not
<context>.<job-name> and options it cannot read throw a DeclarationError when the file
loads. BackgroundJobExecution is what run receives: jobId, attempt, enqueuedAt,
startedAt, the checkpoint an earlier attempt saved, an abort signal, saveCheckpoint(value)
and reportProgress({ done, total }).
Decorator modes
Section titled “Decorator modes”Every decorator of this entry works under both decorator modes of TypeScript, and the same
source line type-checks under both, checks of the role and of a dependency list included.
Neither needs emitDecoratorMetadata, and what a constructor takes is known in both the same
way: read from the source, registered by injected.
| Stack | tsconfig.json |
|---|---|
| class-validator, NestJS, TypeORM entities, any library with legacy decorators | "experimentalDecorators": true |
| anything else | leave experimentalDecorators unset: standard (TC39) decorators need no flag |
One program has one mode, which is why the kit follows the project instead of choosing. A
class decorator receives the class first in both modes; the standard call adds a context,
which these decorators do not read. Nothing else differs: no emitDecoratorMetadata, no
reflect-metadata, no parameter decorator in either mode. Both are compiled by TypeScript 5,
6 and the native TypeScript 7. Under Vitest, legacy decorators need nothing; standard ones
need decorators() of @ddd-ts-kit/testing/vitest.
Standard interfaces
Section titled “Standard interfaces”Standard means that every project built with the kit has the same shape under the same name, so an adapter, a middleware or a dashboard written once fits them all. Extensible means that a project adds its own fields without forking the type (see "How to extend").
Session
Section titled “Session”The authenticated session of an actor, as plain data.
| Field | |
|---|---|
id, actor, issuedAt, expiresAt? |
who, since when, until when |
authenticationMethod |
none, password, sso, token, api-key, service, or a string of the project |
authenticationStrength? |
{ mfa?, assuranceLevel? } |
impersonatedBy? |
id of the support actor acting as actor; logFieldsOf carries it to every log line |
locale?, timeZone?, client? |
BCP 47 tag, IANA zone, { ip?, userAgent? } as opaque strings |
scopes? |
what the credential allows whoever presents it (the OAuth 2.0 scope): a limit on the client, never what the actor may do |
extensions |
the fields of the project |
hasScope(session, scope) reads the scopes; a project names its scope vocabulary once by
declaration merging of SessionScopes (interface SessionScopes { readonly 'invoices:read': true }),
and SessionScope, the type a guard takes, is then that vocabulary instead of any string.
isExpired(session, clock.now()), isImpersonated(session), anonymousSession(),
systemSession(name, issuedAt?), and isSession(value), the structural guard a boundary uses on
what it received as a principal.
ExecutionContext and ExecutionScope
Section titled “ExecutionContext and ExecutionScope”What every unit of work carries, whatever started it: correlationId, causationId?, actor,
session?, tenantId?, elevation? (platform staff reading over tenants, see "Tenancy"),
locale?, deadline?, scheduledAt? (for a scheduled job, the instant it was due), origin
(http, event, schedule, queue for a background job, cli, or a string of the project)
and extensions. executionContext(init) builds one and takes the tenant of the actor;
withActor(context, actor) is the same work seen as another actor.
ExecutionScope (current(), require(), run(context, work)) gives the context of the work
in progress to code that is not handed it. The boundary sets it once:
// presentation or platform: one run per request, consumed event, jobscope.run(executionContext({ correlationId, actor, session, origin: 'http' }), () => handle(request));// infrastructure: a repository adapterconst row = { ...toRow(order), ...stampCreation(scope, clock), ...tenantScopeOf(scope) };Both ends, with the implementation that follows the asynchronous call chain:
import { AsyncLocalStorageExecutionScope, FixedClock, executionContext, stampCreation, tenantScopeOf, type Actor,} from '@ddd-ts-kit/kernel';
const scope = new AsyncLocalStorageExecutionScope();const clock = new FixedClock(new Date('2026-01-05T09:00:00Z'));const actor: Actor = { id: 'u-42', type: 'user', tenantId: 'acme', roles: ['agent'], attributes: {} };
// the boundary: set once for the requestawait scope.run(executionContext({ correlationId: 'req-7f3a', actor, origin: 'http' }), async () => { // ...the command goes through the bus to its handler, which saves; in the repository adapter: const row = { id: 'o-1', ...stampCreation(scope, clock), ...tenantScopeOf(scope) }; // { id: 'o-1', createdAt, createdBy: 'u-42', updatedAt, updatedBy: 'u-42', tenantId: 'acme' } return row;});Use cases and the domain never read the scope: they receive the actor, and anything else they
need, in the command or query (authz/actor-explicit). The scope is for loggers, audit columns,
tenant scoping and outgoing headers. Instead of a handler that asks the scope who acts, a
dependency its signature hides:
const actor = this.scope.require().actor;write a command that carries its actor, the first argument of an AuthorizedCommand, and read
command.actor in the handler:
export class CancelOrder extends AuthorizedCommand { constructor(actor: Actor, readonly orderId: string) { super(actor); }}A consumed event opens its own context: executionContextOfEvent(event, { consumer }) has
origin event, the correlation of the event (its id when it names none), the event as
causationId, systemActor(consumer) as actor and the tenantId of the event, so the
consumer reads and writes in that tenant only; inEventScope(scope, { consumer }, handler)
wraps a handler with it. The standard envelope carries no actor, so that no personal data
travels by default: a project whose envelopes name one passes actorOf. On the way out,
scopedPublisher(publisher, scope) stamps the correlation and the tenant of the work in
progress on every event published without them, so everything one stimulus causes is found
again across processes and stays in its tenant.
ActorContext stays, unchanged. ScopedActorContext implements it on top of a scope: the
current actor is the actor of the current execution context, so the two cannot disagree.
AsyncLocalStorageExecutionScope follows the asynchronous call chain; FixedExecutionScope is
the test double, and follows a FixedActorContext when given one.
Logger
Section titled “Logger”debug, info, warn, error(message, fields?), child(fields), isLevelEnabled(level) and
flush(). LogFields spells the well-known keys once: correlationId, causationId,
context, actorId, tenantId, errorType, errorCode, durationMs; any other key is
accepted. A call never throws, whatever the fields: when they cannot be read or serialized the
entry is written with UNWRITABLE_FIELDS (logError: "unserializable fields") in their place.
The kernel never writes to the console: NoopLogger is the default, InMemoryLogger records
entries for a test, a JSON lines adapter is in @ddd-ts-kit/aspects, and @ddd-ts-kit/pino
and @ddd-ts-kit/winston are the adapters of those libraries. The domain and use cases do not
log.
Errors are log values. LogValue admits an Error, so
logger.error('payment failed', { error }) compiles as it is, and a project may name the key
once (interface LogFields { error?: Error }, see "How to extend"). An Error directly under a
key of the fields is written by every logger as describeError(error) describes it, an
ErrorDescription:
| Key | |
|---|---|
type |
the class name |
code |
the code of a DomainError; absent for any other error |
message |
the message |
stack |
the stack of this error alone |
cause |
the cause, described the same way when it is an Error: eight errors deep at most, so a chain that loops ends |
Deeper in the fields (inside a nested object or a list) an error is left to the serializer of
the adapter: pass it under a key. describeErrorFields(fields) is the copy of the fields a
logger writes; an adapter of your own calls it and is held to the result by loggerContract.
Messages of unexpected errors can be dropped. Every logger of the kit takes
scrubMessages: true (DescribeErrorOptions): the message of every error that is not a
DomainError becomes SCRUBBED_MESSAGE ([message removed]) and its stack loses its first
line, which repeats it. Messages of DomainErrors are written by the project and stay. It is
the option of the same name of @ddd-ts-kit/sentry, with the same rule and the same marker.
The default keeps the messages: a log that says [message removed] costs every investigation.
What the kit logs on its own never holds a message. The logged aspect,
loggingMiddleware and the job scheduler never hand an error to a logger: they write
errorType and errorCode, as identifyError(error) gives them, and the error itself goes to
the ErrorReporter. That holds whatever scrubMessages says. The full description is for the
error a project decides to log, in an adapter or a route; scrubbing is its opt-in for logs that
leave its hands.
isLevelEnabled(level) says whether an entry of that level would be written, for the
logger and its children. Ask it before building fields that cost; the kit does, in logged,
loggingMiddleware and the scheduler. NoopLogger answers false.
flush() resolves once every entry logged so far, through the logger or a child, was
handed to its destination, and never rejects. It is called once, when the process stops, after
the last entry: the application of @ddd-ts-kit/di calls it at the end of stop(). What is
logged after a flush is unspecified, and never throws: pino and StdoutLogger still write it,
winston, whose flush ends the logger, drops it. A second call resolves too.
InMemoryLogger takes { level?, fields?, base?, scrubMessages? } like a real adapter: a
threshold (default debug, every entry), fields read at each entry, fields added to every
entry. Its entries hold what a logger writes: an error passed as a field is recorded as its
description. CREDENTIAL_KEYS and REDACTED are the field names and the marker the pino and
winston adapters redact with; StdoutLogger and InMemoryLogger redact nothing.
Metrics
Section titled “Metrics”counter(name).increment(labels?, by?), histogram(name).record(value, labels?),
gauge(name).set(value, labels?). Label values come from a small closed set, never an
identifier. NoopMetrics is the default; InMemoryMetrics records samples and answers
counterValue, histogramValues and gaugeValue for a subset of labels.
ErrorReporter
Section titled “ErrorReporter”report(error, report) and flush(timeoutMs): where unexpected failures go to be looked at,
an error tracker. report is synchronous: it queues and returns, never throws and never waits
for the tracker; flush waits for the queue before the process stops and never rejects.
ErrorReport is plain data, identifiers only: origin, operation, context,
correlationId, traceId, actorId, tenantId, errorCode, severity (fatal, error,
warning) and the project's extensions. NoopErrorReporter is the default,
InMemoryErrorReporter records reports for a test, and @ddd-ts-kit/sentry is the adapter
for Sentry and GlitchTip.
No handler, aggregate or repository calls it: they throw. What stands around them reports, each attached from outside:
| Capture point | Is | Reports |
|---|---|---|
| buses | errorReportingMiddleware(reporter, scope), first on the bus |
a command or query failing with anything but a DomainError; silent while an event is consumed |
| HTTP | reportHttpErrors(reporter) of @ddd-ts-kit/http, as onError |
every request answered 5xx |
| jobs | reportFailedRuns(reporter) of @ddd-ts-kit/scheduling, as onRun |
a run that failed with anything but a DomainError |
| events | new DeadLetterReports(reporter): onFailure on the hook of the provider, observing(subscriber) around its subscriber |
an event its provider gives up, never a retried delivery |
| process | reportProcessFailures(reporter) |
uncaughtException and unhandledRejection, then exit code 1 after a flush |
A DomainError is a business refusal and is never reported, unless HTTP answers it as a 5xx or
it buries an event (a warning). All of them go through reportOnce: the first capture point
that sees an error object reports it, the boundaries it crosses afterwards recognise it, so a
command failing inside a request or a job is one report, named after the command.
errorReportOf(error, source, executionContext?) builds the report: origin, correlation, actor
and tenant ids come from the execution context, never threaded by hand.
import { FixedClock, InMemoryCache, cacheKey } from '@ddd-ts-kit/kernel';
interface Quote { readonly pair: string; readonly rate: string;}
const quoteKey = (pair: string) => cacheKey<Quote>('quote', pair);
const clock = new FixedClock(new Date('2026-01-05T09:00:00Z'));const cache = new InMemoryCache(clock, { maxEntries: 500 });
await cache.set(quoteKey('EUR-USD'), { pair: 'EUR-USD', rate: '1.0842' }, { ttlMs: 60_000 });const hit: Quote | undefined = await cache.get(quoteKey('EUR-USD')); // the quote, typed by its key
clock.advance(60_000);await cache.get(quoteKey('EUR-USD')); // undefined: expired, by the clockThe key carries the type of its value. InMemoryCache(clock, { maxEntries? }) expires by the
Clock and is bounded. tenantCacheKey<Quote>(scope, 'quote', pair) is the same key confined to
the tenant of the work in progress: what one tenant cached is never served to another.
Purging the HTTP caches
Section titled “Purging the HTTP caches”Two ports, so that a use case can never purge before its commit:
| Port | Called by | Does |
|---|---|---|
CacheInvalidation.invalidate(tags) |
a before-commit handler of the domain event that changed what the answers show |
asks for the tags to be purged once the work commits; domain-events/transactional-before-commit allows it |
CachePurger.purge(tags) |
the relay of the outbox, never a use case (refused before the commit) | purges a shared cache now: InMemoryCachePurger here, Varnish and the CDNs in @ddd-ts-kit/http/purgers |
cacheTag('calendar', id) names a resource (calendar:c-1, the identifier percent-encoded),
cacheTag('calendar') every resource of a kind; the routes of @ddd-ts-kit/http tag their
answers with the same function. PublishedCacheInvalidation({ publisher, ids, clock, scope, perTenant? }),
what kernelModule() of @ddd-ts-kit/di binds, publishes one CachePurgeRequest on the
publisher, the tags under the tenant of the work (tenantCacheTags) when perTenant is set, as
a multi-tenant application whose answers are tagged per tenant sets it (work that names no tenant
is then refused with NoCurrentTenantError: its purge would meet no answer): behind an outbox it
is a row of the transaction, gone with a rollback, kept across a crash after the commit.
new PurgingPublisher(purger, broker) is what the relay publishes through: the purge requests
go to the purger, the other events to the broker, and a failed purge throws so that the relay
retries it. new RepeatedCachePurger(purger, { againAfterMs, onFailure? }) purges, then the same
tags once more againAfterMs later, for a read that loaded the previous state before the commit
and reached the cache after the first purge; the second purge is best effort, reported to
onFailure. InMemoryCacheInvalidation remembers the tags asked for (invalidated), for specs.
import type { Lock } from '@ddd-ts-kit/kernel';
declare function exportEverything(): Promise<void>;
export async function nightlyExport(lock: Lock): Promise<void> { const handle = await lock.acquire('nightly-export', { ttlMs: 60_000, waitMs: 0 });
if (handle === undefined) { // somebody else holds the key return; }
try { await exportEverything(); } finally { await handle.release(); }}A mutex between processes, to avoid doing the same work twice. acquire answers undefined
when the key is held, at once or after waitMs; the lock frees itself after ttlMs; the
fencingToken of a LockHandle grows with each acquisition of a key, so a resource that
remembers the highest one refuses a holder whose lock expired. It is an efficiency tool, never
the guard of an invariant: what must not happen twice is refused by a database constraint.
InMemoryLock(clock, { retryDelayMs?, sleep? }) excludes inside one process and expires by the
Clock: the fake, and enough for a single instance. The adapter between processes is in
@ddd-ts-kit/redis. acquiring(attempt, waitMs, waiting) is the retry loop an adapter shares.
FileStorage, StoredFile, FileScanner
Section titled “FileStorage, StoredFile, FileScanner”// The command wraps what the controller received: the bus stages it before the transaction opens.this.document = new FileUpload(received, 'appointment-documents');
// The handler, inside the unit of work:const { file, fileName } = command.document; // a StoredFile, staged and scannedappointment.attachDocument(unwrap(AttachedDocument.create(file, fileName, now)), now);await appointments.save(appointment);await staging.promoteOnCommit(file.key); // moved to its key with the commitFileStorage holds the content of files, never the database: put (a stream in, measured and
hashed, refused with FILE_TOO_LARGE beyond maxBytes, leaving nothing), get (a stream out),
stat, delete, copy, move, list(prefix), and short signed links, signedDownloadUrl and
signedUploadUrl (at most MAX_SIGNED_URL_SECONDS, one hour). No method takes or returns a
whole file. A key is checked by isFileKey (segments of letters, digits, ., - and _, none
starting with a dot) before anything is touched, and FileKeys generates it on the server:
TenantFileKeys(ids, clock, scope) writes tenants/<tenant>/<area>/<year>/<month>/<id>, the
tenant segment only when the work in progress names one (the tenant id as it is: the kernel
refuses any other than letters, digits, - and _, so two tenants never share a prefix). The links of
signedUploadUrl carry a maxBytes, which the storages the application serves enforce.
StoredFile is what an aggregate keeps: key, size, mediaType, checksum (sha256:<hex>),
never the provider. FileScanner is the hook of an antivirus or a content check, called on the
staged file before it is accepted; AcceptEveryFileScanner is the one of a project that has
none yet. FileError carries the refusals: FILE_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE,
FILE_NOT_FOUND, INVALID_FILE_KEY, FILE_REJECTED, INVALID_FILE_URL_EXPIRY.
A write to the storage is not rolled back with a transaction. FileStaging(storage, scanner, reporter) gives the order that keeps both consistent, each step once:
stage(key, content, options)writes understaging/<key>and scans; a refused file is removed (FILE_REJECTED). A file from a client travels in its command as aFileUpload(theReceivedFileand the area of its key), staged byfileUploadsMiddleware(desk), a bus middleware placed after the permission and before the unit of work, so no transaction waits on an upload.upload.fileis the stagedStoredFile.promoteOnCommit(key)moves the file to its key as the last step of the unit of work (atCommit, aCommitStepofapplyandundo): a promotion that fails rolls the command back, and a transaction that rolls back after it returns the file tostaging/. A committed row never points intostaging/.deleteAfterCommit(key)deletes, once committed, a file the aggregate no longer references (afterCommit, at most once: a failure or a crash leaves an unreferenced file, reported).purge(olderThan)removes the staged files older than the instant, all orphans; the jobPurgeStagedFilesruns it (the platform registers it asfiles.purge-staged, every hour, for files staged a day ago or more).
For a large file the client uploads straight to the storage: uploadUrl(key, { expiresInSeconds, mediaType, accepted, maxBytes }) answers the link for the client and the pending grant
(PendingUpload: key, size cap, media types) that the command records on the aggregate;
confirm(pending), called by a command that reads the grant back from the aggregate, never
from the client, checks what arrived against the cap and on its bytes (contentMatches), scans
it, and answers the StoredFile. No final key may start with staging/ (assertFinalKey).
FileInspection is the pass-through every connector streams through: it counts, hashes, and
with an expectation holds back the first bytes until they prove the declared media type
(acceptsMediaType, contentMatches: a PDF starts with %PDF-, office documents are zip
archives, text is UTF-8 without control characters, SVG and unknown binary formats never
match).
Connectors: InMemoryFileStorage for the tests (keys() lists what it holds) and
LocalFileStorage({ directory, baseUrl, secret }) for development, both ServedFileStorage:
their signed links point at the application, which answers them with signedFileRoutes of
@ddd-ts-kit/http. The object store connector is @ddd-ts-kit/s3. Each passes
fileStorageContract of @ddd-ts-kit/testing/contracts. Background import and export (row by
row validation, an error report, resumption) is a design built on these ports and the job queue
(see "JobQueue"), not a feature of this version:
File import and export.
JobScheduler
Section titled “JobScheduler”abstract class JobScheduler { abstract register(job: JobDefinition): void; // { name, schedule, run, ...options } abstract start(): Promise<void>; abstract stop(): Promise<void>; // waits for the runs in progress, then aborts their signal abstract runNow(name: string): Promise<JobRun>; // for an operation or a test abstract describe(): readonly JobDescription[]; // name, schedule, enabled, running, nextRunAt, lastRun}What runs the declared jobs when they are due. It is not the Scheduler of
@ddd-ts-kit/aspects, which is a timer for retry and timeout: this one knows jobs, schedules
and instances. A JobRun is one occurrence and its outcome (succeeded, failed with the
code of the error and never its message, skipped-overlap, skipped-locked, missed). A job
that fails stops neither the scheduler nor the other jobs, and runNow answers failed
instead of rejecting: only an unknown name rejects (UnknownJobError), and a name registered
twice throws (DuplicateJobError).
A run happens inside ExecutionScope, in the context executionContextOfJob(name, { correlationId, scheduledAt, deadline?, tenantId? }) builds: origin schedule, systemActor(name), a correlation of its
own, and scheduledAt, the instant the run was due, which ExecutionContext carries for this
origin. A job is global by default and runs in no tenant; tenancy: 'per-tenant' makes the
scheduler run it once per tenant of a TenantDirectory, each run in that tenant.
parseJobSchedule(schedule) validates a schedule and answers the form occurrences are
computed from (InvalidJobScheduleError, code INVALID_JOB_SCHEDULE, otherwise);
describeJobSchedule(schedule) is its text; jobPolicy(options) validates the options and
fills in the defaults. The kernel holds no scheduler: the in-process one, the computation of
occurrences and the manual one for tests are @ddd-ts-kit/scheduling, and the contract of the
port is jobSchedulerContract of @ddd-ts-kit/testing/contracts.
JobQueue
Section titled “JobQueue”abstract class JobQueue { abstract enqueue<P>(job: BackgroundJobType<P>, payload: P, options?: EnqueueOptions): Promise<EnqueuedJob>; abstract cancel(id: string): Promise<boolean>; abstract find(id: string): Promise<QueuedJob | undefined>; // status, attempts, progress, last error code}Where a use case hands work to a worker. EnqueueOptions are delay or runAt, an
idempotencyKey (a job of the same name and key, enqueued for the same tenant and already kept,
is answered, duplicate: true) and a subject (the id of what the job works on, which find
answers next to the tenant: a query checks the job belongs to the element it was asked about).
QueuedJobStatus is the closed vocabulary of the statuses (waiting, running, succeeded,
cancelled, dead), to publish in a contract. Work done for a tenant finds and cancels the
jobs of that tenant only (an elevation, those of the tenants it names). Called inside a unit of
work, a job is added or cancelled with the business change: the stores of the ORM packages
write with the transaction, a store that cannot (Redis, memory) waits for the commit through
whenCommitted(write, failed?), the helper any such adapter uses. A run happens inside
ExecutionScope, in the context executionContextOfBackgroundJob({ jobId, enqueuedBy, deadline? })
builds: origin queue, the correlation, actor and tenant of the work that enqueued it
(EnqueuedBy), the job as the cause. backgroundJobPolicy(options) fills in the defaults and
retryDelayMs(policy, attempt) is the backoff. The queue, its worker and its stores are
@ddd-ts-kit/scheduling and the ORM packages; why and how:
Background jobs.
ProcessRunner and ProcessStore
Section titled “ProcessRunner and ProcessStore”abstract class ProcessRunner { abstract run<State>(process: ProcessType<State>, id: string, step: (current: State | undefined) => ProcessDecision<State> | undefined): Promise<void>; abstract timeout<State>(process: ProcessType<State>, due: ProcessTimeoutDue & { jobId: string }, step: (current: State) => ProcessDecision<State> | undefined): Promise<void>;}
abstract class ProcessStore { abstract load(name: string, id: string): Promise<StoredProcess | undefined>; abstract save(process: StoredProcess): Promise<void>; // ConcurrencyError unless version is the stored one plus 1}Process managers: a class of application/processes/ (ProcessManager<State>) with a static
processName, an optional static timeoutJob and a static isState guard; its steps answer a
ProcessDecision (state, commands, timeouts as { name, at }, cancel, ended). A state
the guard does not recognise throws UnreadableProcessStateError. InMemoryProcessStore keeps
the instances as JSON, for tests and one process. The runner is StoredProcessRunner of
@ddd-ts-kit/scheduling; why and how:
Sagas and process managers.
RateLimiter
Section titled “RateLimiter”import { ConceptError, type RateLimiter } from '@ddd-ts-kit/kernel';
export class PasswordResetError extends ConceptError { static tooManyAttempts(retryAfterMs: number) { return PasswordResetError.refusal('TOO_MANY_RESET_ATTEMPTS', 'Too many password resets: try again later.', { retryAfterMs, }).exposing('retryAfterMs'); }}
export async function allowReset(limiter: RateLimiter, accountId: string): Promise<void> { const decision = await limiter.consume(`password-reset:${accountId}`, { limit: 3, windowMs: 3_600_000 });
if (!decision.allowed) { throw PasswordResetError.tooManyAttempts(decision.retryAfterMs); }}A sliding window: at any instant, at most limit allowed calls in the windowMs before it. A
RateLimitDecision is { allowed, remaining, retryAfterMs }; a refused call is not counted.
The key is whatever is limited: a client, a tenant, a route and an address together. In a
multi-tenant project, tenantKey(scope, 'password-reset', accountId) confines it to the tenant of
the work in progress (tenant:<id>:password-reset:<account>) and refuses work that names none.
InMemoryRateLimiter(clock) counts inside one process; the adapter shared by several instances
is in @ddd-ts-kit/redis. rateLimitDecisionOf(counted, limit) turns what an adapter counted
into the decision.
Tenancy
Section titled “Tenancy”The tenant of the work is ExecutionContext.tenantId, set at the boundary and read by
infrastructure only, like the rest of the context. The kernel holds what every strategy shares;
the isolation itself (tenant column, row-level security, schema or database per tenant) is in the
persistence packages, and the whole picture in
Multitenancy.
| Concept | What it does |
|---|---|
isTenantId(value), assertTenantId(id), InvalidTenantIdError |
the one grammar of a tenant id (letters, digits, _, -, at most 63): executionContext, executionContextOfEvent, acrossTenants, InMemoryTenantDirectory, tenantScopeOf, tenantReachOf and the row-level security settings refuse any other, so that no tenant id can be read as a wildcard, a list or a key separator |
tenantScopeOf(scope) |
the one tenant a write names; NoCurrentTenantError for work that names none |
tenantReachOf(scope) |
what a read may see: { kind: 'tenant', tenantId }, or { kind: 'elevated', tenants } under an elevation |
tenantsOf(actor), isTenantMember(actor, tenantId) |
the tenants an actor belongs to: its tenantId and its tenants attribute |
acrossTenants({ scope, trail, clock, ids }, { reason, tenants }, work) |
platform staff reading over tenants: refused without a reason or a tenant, recorded in the AuditTrail (action tenancy:elevate) before the work runs; writes still name one tenant |
tenantKey(scope, ...segments), tenantCacheKey(scope, ...segments) |
keys of rate limits, locks and caches confined to the tenant: tenant:<id>:<segments>, each segment percent-encoded, so that two lists of segments never give one key |
TenantDirectory, InMemoryTenantDirectory |
the tenants that exist, for per-tenant jobs, provisioning and migrations |
TENANT_SETTINGS, tenantSettingValues(context), TENANT_ISOLATION_POLICIES, tenantPolicyExpressions(column) |
what the row-level security of the persistence packages shares: the three settings of a transaction (ddd_ts_kit.tenant_id, and for an elevation only ddd_ts_kit.reach and ddd_ts_kit.every_tenant), their values for the work in progress, the names of the four policies (one per command) and their read and write expressions |
An elevation is never implied by a role: the platform decides the permission to elevate (a use
case or a guard), then calls acrossTenants. A trail that fails to record it refuses it.
Page and PageRequest
Section titled “Page and PageRequest”One shape for every search, in two variants:
import { cursorPage, offsetPage } from '@ddd-ts-kit/kernel';
declare const cursor: string; // the nextCursor of the previous page
offsetPage({ offset: 40, limit: 20, sort: [{ field: 'placedAt', direction: 'desc' }] });cursorPage({ cursor, limit: 50 });// both build a PageRequest; the search answers a Page<T>: { items, total?, nextCursor? }A cursor is opaque to the client and plain to the reader: encodeCursor(position) writes the
sort values of the last item of a page, decodeCursor(cursor) reads them back and refuses what
it did not write. It is not signed: the read port checks the types of what it decodes before
they reach a statement. A cursor needs a total order: append a unique key (the primary key)
to the sort, in the same direction, put it in the position, and compare the whole tuple. Rows
that are equal on the sort field otherwise have no defined order, and a page could repeat or
skip one of them. The adapter packages show the query for each ORM.
offsetPage and cursorPage validate and raise InvalidPageRequestError
(INVALID_PAGE_REQUEST) beyond PageLimits (default size 20, maximum 100). pageOf(items, more)
and mapPage(page, map) build and translate the answer. The sortable fields are a type
parameter, PageRequest<'placedAt' | 'total'>: they differ for each read port, which a global
augmentation could not express.
AuditStamp and TenantScoped
Section titled “AuditStamp and TenantScoped”AuditStamp (createdAt, createdBy, updatedAt, updatedBy) and TenantScoped (tenantId)
are columns of a stored record, not properties of an aggregate. stampCreation(scope, clock),
stampUpdate(scope, clock) and tenantScopeOf(scope) fill them in a repository adapter:
// insert: who creates is also who last updatedconst row = { ...toRow(order), ...tenantScopeOf(scope), ...stampCreation(scope, clock) };// update: the two columns to set, the creation is left alone; the tenant is part of every conditionawait update({ ...toRow(order), ...stampUpdate(scope, clock) }, { id: order.id, ...tenantScopeOf(scope) });stampUpdate(stamp, scope, clock) is the form for an adapter that holds the stored stamp (a
document store rewriting the whole record): it answers it whole, creation untouched.
tenantScopeOf raises NoCurrentTenantError for work that names no tenant, so a statement can
never run unscoped by omission. Every adapter package names the columns the same way:
created_at, created_by, updated_at, updated_by, tenant_id.
AuditTrail, AuditReader and AuditEntry
Section titled “AuditTrail, AuditReader and AuditEntry”abstract class AuditTrail { abstract record(record: AuditRecord): Promise<void>;}
abstract class AuditReader { abstract search(filter: AuditFilter, page: PageRequest<AuditSortField>): Promise<Page<AuditEntry>>;}The two ports of an audit trail and the shape of one entry (AuditEntry, AuditRecord for
what a source hands over, AuditFilter, and the types they name), so that code which records
or lists depends on the kernel alone. AuditTrail is append only and is fed by infrastructure
and cross-cutting code (a bus middleware, the authorization hook, an event subscription), never
by a use case or an aggregate. AuditReader is what a query handler depends on to list
entries. A project adds fields to every entry through AuditEntryExtensions.
The kernel holds no implementation: the hash chain, the sources, redaction, verification and
the stores, the in-memory one included, are @ddd-ts-kit/audit.
Not in the kernel
Section titled “Not in the kernel”Feature flags have no port here. Their evaluation model (boolean or variant, targeting, percentage, who the subject is) differs with each provider, and OpenFeature already is the vendor-neutral contract: a project that needs flags declares a port in the context that reads them, named after the decision it takes.
Criteria
Section titled “Criteria”A read of a read model, described once for every search: filters, a sort, a page. A criteria knows field names and nothing of a store. It is not a specification: a specification is a business predicate on an aggregate and lives in the domain; a criteria describes a read.
import { ReadModelStore, defineCriteria } from '@ddd-ts-kit/kernel';
export interface AppointmentRow { readonly id: string; readonly status: 'booked' | 'confirmed' | 'cancelled'; readonly startsAt: Date; readonly practitionerId: string; readonly cancelledAt: Date | null; 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'] }, cancelledAt: { type: 'instant', filter: ['isNull'], nullable: true }, tenantId: { type: 'string' }, }, defaultSort: [{ field: 'startsAt', direction: 'asc' }], tieBreaker: 'id', limits: { maxPageSize: 50 },});
export abstract class AppointmentList extends ReadModelStore<AppointmentRow> {}const criteria = Criteria.of<AppointmentRow>() .where('status', 'in', ['booked', 'confirmed']) .where('startsAt', 'gte', query.from) .or((any) => any.where('practitionerId', 'eq', query.practitionerId).where('cancelledAt', 'isNull', false)) .orderBy('startsAt', 'asc') .page(query.page);
const scope = await this.access.scope(query.actor, permissions['appointment.read'], 'appointment');
return this.appointments.search(scoped(criteria, scope, { ids: 'id', filter: { tenantId: 'tenantId' } }));From the handler to the store, a criteria is checked twice, and the scope of the actor travels with it:
Checked by the compiler. where takes a field of the row, an operator that applies to
what the field holds, and a value of its type: where('seats', 'contains', '2'), an order on
a boolean, isNull on a field that is never null, a value outside a union of literals and a
field hydrated at query time are all refused. The same holds for a declaration: the type of
each field, its operators and nullable are checked against the row.
Checked by the read port. validateCriteria(definition, criteria) answers the violations,
never throws, and never quotes a value. planCriteria(definition, criteria) validates first and
throws InvalidCriteriaError (INVALID_CRITERIA): a criteria its definition refuses never
reaches a statement.
| Code | Path | Refused |
|---|---|---|
unknown_field |
filter.<field> |
a field the definition does not declare |
operator_not_allowed |
filter.<field> |
an operator the field does not declare |
invalid_value |
filter.<field> |
a value that is not of the type of the field, an empty list, a between without two values, an empty text to search |
in_list_too_long |
filter.<field> |
a list above maxInList (100) |
too_deep |
filter |
groups nested deeper than maxDepth (1: or(a, b); 2: or(and(a, b), c)) |
empty_group |
filter |
a group without a filter |
too_many_filters |
filter |
more conditions than maxFilters (20) |
unsortable_field |
sort |
a sort on a field not declared sortable (the tie-breaker always is) |
page_too_large |
page.limit |
a page above maxPageSize (100; the default size is 20) |
invalid_cursor |
page.cursor |
a cursor that is not the nextCursor of a page of the same sort |
What a criteria means
Section titled “What a criteria means”The reference is evaluateCriteria(definition, rows, criteria), which runs a criteria over
rows held in memory and is what a fake of a read port answers with. Every store translator is
held to the same table by criteriaContract of @ddd-ts-kit/testing/contracts.
| Rule | Meaning |
|---|---|
| Types | string; number; boolean; instant, a Date compared by its time, to the millisecond; decimal, a string of digits (-12.50) compared as a number without the rounding of a float |
eq, ne, in |
by value: two instants of the same time are equal whatever the zone they were written in, 10.5 is 10.50 |
gt, gte, lt, lte |
numbers, decimals and instants by value; strings by Unicode code point, so upper case comes before lower case ('B' < 'a'); not on a boolean |
between |
both bounds included |
contains, startsWith |
on a string only, case sensitive, the text taken literally: %, _ and \ are characters. A search that ignores case reads a lower-cased column the projection writes |
isNull |
true keeps the rows without a value, false those with one; only on a field declared nullable |
| A null | never satisfies a comparison, ne included; only isNull sees it |
| Conditions | joined by AND |
and, or, not |
not holds on the rows that do not satisfy all its filters, nulls included: logic has two values, so not(room eq 'B') keeps the rows without a room, where SQL alone would drop them |
| Sort | the orderBy calls, then the sort of the page request for the fields not ordered yet; without any, the defaultSort of the definition |
| Total order | the tie-breaker of the definition ends every sort, in the direction of the last field, unless the sort names it |
| Null in a sort | the greatest value: last ascending, first descending (the default of PostgreSQL) |
false and true |
false first ascending |
| Offset page | the slice and total, the count of the filtered rows |
| Cursor page | no total; nextCursor when another page follows. The cursor holds the sort values of the last row, is checked against the declared types when it comes back, and belongs to one sort |
| No page | the first page of the default size |
Two things follow from where a store keeps its data, and are written in each adapter README.
Strings are ordered by the collation of their column: the reference is the "C" collation
of PostgreSQL, so a text column that is sorted or compared by range is declared COLLATE "C".
A decimal reads back as its column formats it (10.5, or 10.5000 for a fixed scale):
compare decimals as numbers.
The scope of the actor
Section titled “The scope of the actor”scoped(criteria, accessScope, mapping) answers a ScopedCriteria, which is what a read port
asks for: a read cannot reach it without a scope. The scope is kept apart from the filters and
joined to them by AND, so nothing a caller writes, an or or a not included, widens what the
actor may read; refine changes the criteria and keeps the restriction.
| Scope | Becomes |
|---|---|
{ kind: 'all' } |
nothing: the criteria as it is |
{ kind: 'none' }, or ids without any id |
an empty page, without a query |
{ kind: 'ids', ids } |
in on the field mapping.ids names; the list is the authorizer's and is not bounded by maxInList |
{ kind: 'filter', filter } |
one eq per entry, on the field mapping.filter names for its key |
A scope the mapping cannot express throws UnmappedReadScopeError instead of reading without
the restriction, and so does a mapped field the definition does not declare. The fields of a
scope need no operator in the definition: the read port chose them, not the caller. The scope
is the shape AccessScope of @ddd-ts-kit/auth, named ReadScope here so that the kernel
depends on no authorization model.
What that means on four rows, run in memory with the reference semantics:
import { Criteria, defineCriteria, evaluateCriteria, scoped } from '@ddd-ts-kit/kernel';
interface BookingRow { readonly id: string; readonly room: string | null; readonly tenantId: string;}
const bookings = defineCriteria<BookingRow>({ fields: { id: { type: 'string' }, room: { type: 'string', filter: ['eq'], nullable: true }, tenantId: { type: 'string' }, }, defaultSort: [{ field: 'id', direction: 'asc' }], tieBreaker: 'id',});
const rows: BookingRow[] = [ { id: 'a-1', room: 'A', tenantId: 'acme' }, { id: 'a-2', room: 'B', tenantId: 'acme' }, { id: 'a-3', room: null, tenantId: 'acme' }, { id: 'g-1', room: 'A', tenantId: 'globex' },];
// `not` keeps the row without a room, where SQL alone would drop itevaluateCriteria(bookings, rows, Criteria.of<BookingRow>().not((all) => all.where('room', 'eq', 'B')));// items: a-1, a-3, g-1
// an `or` the caller writes never widens the scope: the scope is joined to it by ANDconst anyRoom = Criteria.of<BookingRow>().or((any) => any.where('room', 'eq', 'A').where('room', 'eq', 'B'));const acmeOnly = { kind: 'filter', filter: { tenantId: 'acme' } } as const;evaluateCriteria(bookings, rows, scoped(anyRoom, acmeOnly, { filter: { tenantId: 'tenantId' } }));// items: a-1, a-2; g-1 belongs to another tenant
// the caller cannot filter on tenantId itself: the field declares no operatorevaluateCriteria(bookings, rows, Criteria.of<BookingRow>().where('tenantId', 'eq', 'globex'));// throws InvalidCriteriaError: filter.tenantId (operator_not_allowed)For a store
Section titled “For a store”planCriteria answers a CriteriaPlan, plain data a translator renders and nothing else: the
predicate of the filters and the scope, the predicate of the keyset for a cursor page, the
total order, the rows to fetch. Negations are already pushed down to the comparisons, so a
translator renders and, or and tests; the keyset is a predicate like another, so paging by
cursor is written once. pageOfPlan(plan, rows, total?) turns the rows a query answered into
the Page. The adapter packages hold the translators: criteriaToKnex, criteriaToDrizzle,
criteriaToPrisma, criteriaToTypeOrm.
Limits. No join: a field of another context is filtered once a projection copied it. No
aggregation, no full-text search. A long ids scope is a long IN list: beyond a few
thousand identifiers, project the right the scope stands for into the read model.
Hydration
Section titled “Hydration”Completing the rows of a read with data of another bounded context, at query time, through a gateway port: for detail views and small pages whose foreign data must be fresh. A list that filters or sorts by the foreign field needs a projection instead.
const page = await this.appointments.search(criteria);
return hydrate(page) .with('practitioner', (row) => row.practitionerId, this.practitioners.byIds) .with('location', (row) => row.locationId, this.locations.byIds) .orElse('practitioner', unknownPractitioner, { onFailure: 'fallback', timeoutMs: 300 });lookup is (ids: readonly string[]) => Promise<ReadonlyMap<string, T>>, a method of the
gateway port; the cached aspect of @ddd-ts-kit/aspects goes around the gateway as around
any other.
| Rule | |
|---|---|
| Calls | one per relation for the whole page, with each key once; none when no row has a key. Never one per row |
| Concurrency | the relations are looked up at the same time |
| Result | the row and the hydrated properties, typed; a Page stays a Page with the same total and nextCursor, a list of rows becomes a page of them; warnings is added |
| A row without a key, or unknown on the other side | left without the property, which is typed optional |
orElse(name, fallback) |
every row has the property, typed required: the fallback where nothing was found |
| A lookup that rejects | fails the whole hydration (policy fail, the default), fallback or not |
orElse(name, fallback, { onFailure: 'fallback' }) |
the rows take the fallback and warnings holds { relation, code: 'lookup_failed' }: never a silent success, never the error message |
timeoutMs |
the lookup is given up after that long (HydrationTimeoutError, warning code lookup_timed_out), then follows onFailure |
A hydrated property cannot be named by a criteria definition: FieldOf<Row> keeps the fields
that hold a scalar, and the compiler refuses the rest. The traces show one gateway <Port>.byIds span per relation, as for any call to a gateway: hydration opens no span itself.
Projections
Section titled “Projections”A projector keeps one read model up to date from events: integration events of upstream contexts, domain events of its own. It writes its read model through a port and does nothing else: it sends no command and reads no other context.
import { stampOf, type IntegrationEvent } from '@ddd-ts-kit/kernel';import { On, Projection } from '@ddd-ts-kit/kernel/declarations';
@Projection('scheduling.appointment-list')export class AppointmentListProjection { constructor(private readonly appointments: AppointmentList) {}
@On(AppointmentBooked) booked(event: AppointmentBooked) { return this.appointments.upsertIfNewer(event.aggregateId, stampOf(event), () => rowOf(event)); }
@On(PaymentCaptured) paid(event: IntegrationEvent<PaymentCapturedPayload>) { return this.appointments.upsertIfNewer(event.payload.appointmentId, stampOf(event), (row) => row === undefined ? undefined : { ...row, paid: true }, ); }
@On(AppointmentCancelled) cancelled(event: AppointmentCancelled) { return this.appointments.delete(event.aggregateId, stampOf(event)); }}@Projection('<context>.<name>') is a declaration like the others: both decorator
modes, its dependencies stated by its constructor, one class per file, in
application/projections/, in a *.projection.ts file. @On(definition) on a method says
"this method applies that integration event"; @On(EventClass) says it for a domain event of the
projection's own context, under the name its class declares once, static readonly eventName.
A method that takes another event is refused by the compiler, a projection without any @On and an @On on a class without @Projection throw
when the file loads. A method answers what its store answered, which is how the application
counts what was applied and what was stale.
A role of the CLI. application/projections/ is a directory of the canonical tree,
ddd-ts-kit generate projection <context> <Name> <event-type> writes a projector with its
spec, the generated barrel <context>.declarations.ts exports it and registers what its
constructor takes, and ddd-ts-kit describe and the reference of CONTEXT.md list it with
the events it applies.
Idempotent and ordered by construction
Section titled “Idempotent and ordered by construction”ReadModelStore<Row> is the port of a read model: get, upsertIfNewer, delete and
search(scopedCriteria). Every row carries the stamp of the event that last changed it, and a
write happens only if its event is newer:
| Rule | |
|---|---|
| Stamp | stampOf(event): the id and the instant of an integration event; the name, the aggregate and the instant of a domain event; plus stampOf(event, version) when the event carries a per-key version |
| Newer | a greater version when both stamps carry one; else a later instant; at the same instant, the greater event id. The same event is never newer than itself |
upsertIfNewer(key, stamp, next) |
writes next(current); stale when the row holds this event or a later one; ignored when next answers undefined (a patch of a row that does not exist) |
delete(key, stamp) |
the row is gone for get and search, and its stamp stays: an older event delivered late does not bring it back, a later one does |
| Concurrency | a write is conditional on the stamp it read and is read again when another got there first; five attempts, then ConcurrencyError and the redelivery of the event |
| Transaction | the store writes on the executor of the unit of work, so the row and the processed-event mark of the event commit or roll back together |
So a redelivery, a duplicate and two events of one key delivered out of order all end on the same row. What this does not give: an older event skipped as stale is not applied later, so an event should carry the state of the fields it sets, or come in order per key (the outbox orders per key); a deletion that reaches a row never seen leaves no trace; with instants and no version, the order of two events of one key is the order of the clocks that stamped them; a version is compared with the version the row holds, so pass one only when every event of a row comes from the same stream. One write is one row: an event that concerns many rows lists them with a criteria and writes each.
TableReadModelStore is the one implementation of the port, over a ReadModelTable an
adapter gives (three statements and the query of a plan): InMemoryReadModelStore here for
the fakes, KnexReadModelStore, DrizzleReadModelStore, PrismaReadModelStore and
TypeOrmReadModelStore in the adapter packages, all held to readModelStoreContract.
Rebuild
Section titled “Rebuild”A read model fed by events that no event store keeps is rebuilt from the source of truth, the read contract the owning context publishes, behind a gateway port that answers stamped rows. A read model of event-sourced aggregates is rebuilt from the store instead: Fed by the event store.
await new ProjectionRebuild({ table: appointments.table, source, unitOfWork }).run();// source.export(cursor): a page of { key, row, stamp } and its nextCursor// source.lookup(keys): the rows of these keys as they are now| Step | What happens |
|---|---|
| 1 | an empty copy of the table is opened, with its indexes and constraints and the traces of the deleted rows; from then on every write of the projection records its key before it writes |
| 2 | the export is copied, page by page, into the copy. Events keep being applied to the table, which keeps answering reads |
| 3 | the recorded keys are read again from the source, in rounds, until few remain |
| 4 | in one transaction: the writers are held, the last recorded keys are read again, the copy takes the name of the table, the old table is dropped |
The copy is filled from the source alone, never from the table being replaced, so a rebuild
repairs rows the projection got wrong. No event is lost: an event is delivered after its
source committed it, so either the export read its row afterwards, or its key was recorded and
read again, or it is applied after the swap to a row whose stamp is older. No event is
applied twice: an event the source already reflected is stale against the stamp its row
came with. Both rest on what the source says of each row: the stamp of the last event its
state reflects. With the id and instant of that event, or a per-key version, the guarantee is
exact; with an updatedAt alone, an event of the very same instant is applied once more over
the state that already holds it, which is harmless for an event that sets state and wrong for
one that counts.
The window. Reads are never interrupted until step 4. Writers wait during step 4, for the
time it takes to read the last keys again (at most settledBelow, 50 by default, unless the
table changes faster than maxRounds rounds can follow) and to rename two tables. A write that
had started just before fails and is redelivered. A rebuild that fails drops its copy and
leaves the table as it was. On PostgreSQL the copy is made LIKE ... INCLUDING ALL:
privileges granted on the table by name, row security policies and triggers are not copied
and are restored by the migration that follows, and the indexes of the copy keep the names
PostgreSQL derived for them.
Fed by the event store
Section titled “Fed by the event store”A projection of event-sourced aggregates can be fed by the store rather than where the domain
events are dispatched: @Projection('scheduling.attendance-history', { feed: 'event-store' }).
The dispatcher then never calls it (@ddd-ts-kit/di registers no handler for it); a catch-up
subscription does, under the name of the projection, and the same projection rebuilds its read
model from position zero.
const apply = applyingProjection(projection, serializer); // the @On methods, by event name
const subscription = new CatchUpSubscription({ name: 'scheduling.attendance-history', store, checkpoints, unitOfWork, apply });
await subscription.catchUp(); // or poll() from a job: one batch, one unit of work
await new EventStoreProjectionRebuild({ table: history.table, store, unitOfWork, applierOn: (copy) => applyingProjection(new AttendanceHistoryProjection(copy), serializer),}).run();| Piece | What it does |
|---|---|
CatchUpSubscription |
Reads the store after the checkpoint of its subscriber (CheckpointStore), applies a batch and moves the checkpoint in one unit of work. A failure applies none of the batch; two runs of one subscriber never both move it (ConcurrencyError). A new projection starts at zero, so it catches up on the whole history |
applyingProjection(projection, serializer) |
(@ddd-ts-kit/kernel/declarations) hands each stored event an @On method applies, read back by the serializer of its aggregate, to that method; passes over the rest of the store |
storedStampOf(event) |
The stamp of an event read from the store: its id, instant and version in its stream, so the rows of one stream are ordered by the stream, not by clocks. undefined for an event that did not come from a store |
EventStoreProjectionRebuild |
Fills an empty copy of the table by replaying the store from position zero through the projection built over the copy (ShadowReadModelStore); then, in one transaction, holds the writers, replays what was appended meanwhile and puts the copy in place of the table. The subscription goes on from its checkpoint; what it applies again is stale against the copy. A failed rebuild leaves the table as it was |
A projection fed by the store applies the domain events of its context only: an integration
event reaches a projection where it is consumed, and @On(definition) on such a projection
throws when the file loads.
Seen from outside
Section titled “Seen from outside”Through the ports that exist. @ddd-ts-kit/di counts projection_events_total by context,
projection, event and result (applied, stale, ignored, failed) on Metrics, and
sets projection_last_event_age_ms, the age of the last event applied when it was: the lag.
A projection is subscribed as a consumer under its name, so each delivery is one process <projection> span, with the calls to its read model below it, and an event buried after its
retries reaches the error reporter as for any consumer.
Event sourcing
Section titled “Event sourcing”Chosen per aggregate, never per project: the other aggregates of the context keep their state in tables. When to choose it and what it costs: docs/concepts/event-sourcing.md.
type AttendanceEvent = AttendanceRecordOpened | NoShowNoted;
export class AttendanceRecord extends EventSourcedAggregate<AttendanceEvent, AttendanceState> { private state: AttendanceState = { noShows: 0 };
private constructor(id: string) { super(id); }
static open(id: string, now: Date): AttendanceRecord { const record = new AttendanceRecord(id);
record.record(new AttendanceRecordOpened(id, now));
return record; }
static rehydrate(id: string, history: AggregateHistory<AttendanceEvent, AttendanceState>): AttendanceRecord { const record = new AttendanceRecord(id);
return record.replay(history, (state) => record.restore(state)); }
noteNoShow(appointmentId: string, now: Date): void { if (this.state.noShows >= 3) { throw AttendanceRecordError.alreadyBarred(this.id); }
this.record(new NoShowNoted(this.id, now, appointmentId)); }
protected appliers(): Appliers<AttendanceEvent> { return { [AttendanceRecordOpened.eventName]: () => undefined, [NoShowNoted.eventName]: (event) => this.applyNoShowNoted(event), }; }
private restore(state: AttendanceState): void { this.state = state; }
private applyNoShowNoted(event: NoShowNoted): void { this.state = { noShows: this.state.noShows + 1 }; }}| Piece | What it does |
|---|---|
EventSourcedAggregate<E, S> |
A command checks its invariants on the state, then record(event): the event is applied through the method appliers() names for it, and kept for the save. Only apply methods (and restore, for a snapshot) change the state (event-sourcing/state-in-apply). appliers() must name every event of the union, or it does not compile |
replay(history, restore?) |
Rebuilds from { snapshot?, events }, through the same apply methods, recording nothing. version is the number of events of the stream the aggregate reflects |
appendChanges(aggregate, append) |
What a repository adapter calls: the events recorded since the load and the version the stream must still have; on success the aggregate is at the new version |
EventStore |
The port: append(stream, expectedVersion, events) (a ConcurrencyError when somebody appended first, nothing kept of it), readStream(stream, after?), readAll(after, limit) in the order the appends committed, head(). InMemoryEventStore here; PostgreSQL through every ORM adapter. Held to eventStoreContract |
defineStoredEvent(Event, spec) |
How one event is stored: version of its shape, encode(event): NamedData (plain data, dates kept as ISO text), decode(data, envelope) reading each field by type (data.text('x'), data.instant('at'), data.record('amount')), and upcasters from each older version |
EventSerializer<E> |
Every stored event of one aggregate (StoredEventTypes<E>: one entry per event, or it does not compile). A stored event is never rewritten: it is upcast when read. An event of a later shape than the release knows, or a version without upcaster, is an UnreadableEventError |
EventSourcedRepository |
What the repository adapter delegates to: findById replays the stream <category>-<id> (from the last snapshot), save appends in the transaction of the unit of work; the events then leave with the save like any other |
SnapshotPolicy |
Per aggregate: every so many events, a version of the shape (another one is ignored, the stream replayed), encode and decode. expectSnapshotEquivalence of @ddd-ts-kit/testing proves a snapshot rebuilds the state a full replay does |
personalData(subject, value) |
Marks a personal value in encode. PersonalDataCipher over a SubjectKeyStore seals it with the key of its data subject (AES-256-GCM); SubjectKeyStore.erase(subject) destroys the key, and a replay then reads ERASED_PERSONAL_DATA |
@Repository(AttendanceRecordRepository)export class EventStoreAttendanceRecordRepository implements AttendanceRecordRepository { private readonly streams: EventSourcedRepository<AttendanceRecord, AttendanceEvent, AttendanceState>;
constructor(store: EventStore, snapshots: SnapshotStore, cipher: PersonalDataCipher) { this.streams = new EventSourcedRepository({ store, category: 'scheduling.attendance-record', serializer: new EventSerializer<AttendanceEvent>(attendanceRecordEvents, { cipher }), rehydrate: (id, history) => AttendanceRecord.rehydrate(id, history), snapshots: { store: snapshots, every: 50, version: 1, encode: (record) => record.toState(), decode: stateOf }, }); }
findById(id: string): Promise<AttendanceRecord | undefined> { return this.streams.findById(id); }
save(record: AttendanceRecord): Promise<void> { return this.streams.save(record); }}No double write. The store joins the transaction of the unit of work, and the publisher of
the integration events is a handler of the domain events that runs before the commit and
writes the outbox: the stream and the outbox records commit together or not at all.
eventStoreUnitOfWorkContract of @ddd-ts-kit/testing proves it for each transactional store:
nothing is published for an append rolled back or refused by a concurrency conflict.
How to extend
Section titled “How to extend”Session, ExecutionContext, Actor, AuditEntry and ErrorReport each have an empty interface that a project fills by
declaration merging, once, in a declaration file of its platform:
import '@ddd-ts-kit/kernel';
declare module '@ddd-ts-kit/kernel' { interface SessionExtensions { readonly plan: 'free' | 'team'; } interface ExecutionContextExtensions { readonly region: string; } interface ActorExtensions { readonly department: string; } interface AuditEntryExtensions { readonly region: 'eu' | 'us'; } interface ErrorReportExtensions { readonly deployment: 'blue' | 'green'; } interface LogFields { orderId?: string; error?: Error; } interface MetricLabels { region?: 'eu' | 'us' | undefined; }}session.extensions.plan; // 'free' | 'team' | undefinedcontext.extensions.region; // string | undefinedactor.attributes.department; // string | undefinedentry.extensions.region; // 'eu' | 'us' | undefinedlogger.info('shipped', { orderId: 42 }); // compile error: orderId is a stringextensions, not top-level fields. A project field lives underextensions, so a standard field added toSessionorExecutionContextin a later version can never collide with it.- Optional when read.
anonymousSession(),systemSession()andexecutionContext()carry no extension, and they must stay truthful in every project: an extension reads asT | undefined. Actorkeeps its bag. Authorizers already readactor.attributesby name, and actors travel in messages:ActorExtensionsgives known attributes a name and a type inside that bag instead of adding a second one. An extension that is not anActorAttribute(a string, a number, a boolean or a list of strings) reads asnever.AuditEntryExtensionsholds JSON. An entry is hashed and stored as it is: an extension that is not plain JSON (aDate, a class) reads asnever.LogFieldsandMetricLabelstake new well-known keys the same way; unknown keys stay allowed.- Nothing to register. The augmentation is types only. Adapters of the kit keep compiling: they never read a project extension.