@ddd-ts-kit/kernel
v0.22.0
Published
Framework-agnostic Domain-Driven Design (DDD) building blocks for TypeScript: aggregates, entities, value objects, domain events, CQRS command and query buses, unit of work and application ports, with an in-memory event bus.
Maintainers
Readme
English · Français
@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
| 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
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 value
new 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 lines
events.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
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
import { AggregateRoot, ConceptError, defineStateMachine } from '@ddd-ts-kit/kernel';
// domain/vocabularies/parcel-status.vocabulary.ts
export const ParcelStatus = { Registered: 'registered', Shipped: 'shipped', Delivered: 'delivered' } as const;
export type ParcelStatus = (typeof ParcelStatus)[keyof typeof ParcelStatus];
// domain/errors/parcel.error.ts
export 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.ts
export 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 hand
export 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 refused
Parcel.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
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
| 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
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
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.
// contexts/ordering/domain/repositories/order.repository.ts
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[]>;
}// contexts/ordering/application/commands/place-order/place-order.handler.ts
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> { /* ... */ }
}// contexts/ordering/infrastructure/persistence/knex-order.repository.ts
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
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 |
// contexts/ordering/application/ports/payment-gateway.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 only
export 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 structural
export 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:
// contexts/ordering/application/ports/database.port.ts
export abstract class Database {
abstract readonly client: Knex;
}
// contexts/ordering/application/ports/payment-settings.port.ts
export abstract class PaymentSettings {
abstract readonly apiKey: string;
}
// composition root
builder.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, generated
injected(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
| 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
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
// contexts/billing/application/scheduling/send-reminders.job.ts
@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
// contexts/billing/application/background/compile-statements.background-job.ts
@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
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
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
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
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, job
scope.run(executionContext({ correlationId, actor, session, origin: 'http' }), () => handle(request));// infrastructure: a repository adapter
const 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 request
await 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
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
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
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.
Cache
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
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.
Lock
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
// The command wraps what the controller received: the bus stages it before the transaction opens.
this.document 