@ddd-ts-kit/kernel
Les blocs de base dont dépend chaque contexte, et rien d'autre : aucun framework, aucun ORM,
aucun logger, aucune dépendance. C'est le seul package du kit que domain/ peut importer ;
application/ l'importe aussi, avec @ddd-ts-kit/auth à côté pour les permissions et les
politiques.
npm i @ddd-ts-kit/kernelDeux points d'entrée : @ddd-ts-kit/kernel (les blocs de base, les ports, les interfaces
standard et leurs doubles) et @ddd-ts-kit/kernel/declarations (les décorateurs par lesquels une
classe dit ce qu'elle est, voir « Déclarations »).
Le noyau dit ce dont un contexte a besoin (un Clock, un UnitOfWork, un Logger) et
jamais comment : chaque port est une classe abstraite aux seuls membres abstraits. Ses
implémentations vivent ailleurs : les doubles et les versions en processus du noyau lui-même
pour les tests et un processus unique, les packages d'adaptateurs du kit (@ddd-ts-kit/pino,
@ddd-ts-kit/redis, @ddd-ts-kit/s3, les packages ORM, ...) pour la production, et
l'infrastructure/ de chaque contexte pour ses propres repositories et gateways. La
plateforme en choisit une par port au démarrage, si bien qu'une règle métier ne change jamais
avec une bibliothèque.
| Thème | Exports |
|---|---|
| Domaine | Entity, AggregateRoot (version, événements enregistrés), ValueObject, DomainEvent, DomainError avec un code stable, ConceptError (les erreurs d'un concept, chacune construite par une fabrique statique : voir « Erreurs du domaine »), isRefusal, CodeOf, RefusalOf, Refusal, RefusalFacts, le générique InvariantViolation, NotFoundError, ConflictError, ConcurrencyError, PreconditionFailedError, Result avec ok, err, unwrap |
| Ports | Clock, IdGenerator, UnitOfWork, Repository (classes abstraites : chacune est son propre jeton d'injection), UseCase, DependencyKind (les rôles que déclare une liaison, lus par di, le traçage et les aspects : repository, gateway, external, use-case, publisher, consumer, domain-event-handler, job) |
| Bus | Command, Query, CommandBus, QueryBus, BusMiddleware, les InMemoryCommandBus et InMemoryQueryBus en processus (register(type, handler, { context }) nomme le contexte délimité dans MessageInfo), unitOfWorkMiddleware |
| Événements | IntegrationEvent, IntegrationEventPublisher, IntegrationEventSubscriber, EventBus (les deux à la fois), EventSubscribers (l'abonné de chaque contexte délimité), ProcessedEventStore, idempotent, InMemoryEventBus, DomainEventDispatcher, IntegrationEventCallback et DomainEventCallback (les fonctions que reçoivent un abonné et un distributeur), executionContextOfEvent, inEventScope, scopedPublisher, EventDelivery (ce qu'un fournisseur dit d'une livraison : tentative, dernière tentative, transport, source, consommateur), messagingSystemOf |
| Événements du domaine et unité de travail | dispatchingOnSave (l'enveloppe qui fait remettre par un repository ce qu'ont enregistré les agrégats qu'on lui confie), announceDomainEvents, withinUnitOfWork (ce sur quoi repose chaque UnitOfWork.run : file, distribution avant la validation, phase after-commit), unitOfWorkStage et UnitOfWorkStage, DomainEventPhase, DomainEventSubscription, SaveAfterCommitError, AfterCommitFailure, reportAfterCommitFailures, InMemoryDomainEventDispatcher |
| Machines à états | defineStateMachine, StateMachine, TransitionError, InvalidStateMachineError, mermaidOfStateMachine (voir « Machines à états ») |
| Acteur | Actor, AuthorizedCommand, AuthorizedQuery, ActorContext, systemActor |
| Interfaces standard | Session, ExecutionContext et ExecutionScope, Logger, Metrics, ErrorReporter, Cache, CacheInvalidation et CachePurger (avec cacheTag, tenantCacheTags, CachePurgeRequest, PublishedCacheInvalidation, PurgingPublisher, RepeatedCachePurger : voir « Purger les caches HTTP »), Lock, RateLimiter, Page et PageRequest, AuditStamp, TenantScoped, AuditTrail, AuditReader et AuditEntry |
| Jobs planifiés | JobScheduler, ScheduledJob, JobExecution, JobSchedule, JobOptions, JobDefinition, JobRun, JobDescription, parseJobSchedule, describeJobSchedule, jobPolicy, executionContextOfJob |
| Jobs en arrière-plan | JobQueue, BackgroundJob, BackgroundJobExecution, EnqueueOptions, QueuedJob, QueuedJobStatus, backgroundJobPolicy, retryDelayMs, executionContextOfBackgroundJob, whenCommitted (voir « JobQueue ») |
| Gestionnaires de processus | ProcessManager, ProcessDecision, ProcessRunner, ProcessStore, InMemoryProcessStore, UnreadableProcessStateError (voir « ProcessRunner et ProcessStore ») |
| Fichiers | FileStorage, StoredFile, FileScanner, FileKeys, TenantFileKeys, FileStaging, FileUpload, fileUploadsMiddleware, FileInspection, FileError, InMemoryFileStorage, LocalFileStorage, PurgeStagedFiles (voir « FileStorage, StoredFile, FileScanner ») |
| Multitenance | tenantScopeOf, tenantReachOf, acrossTenants, tenantKey, tenantCacheKey, isTenantId, TenantDirectory, InMemoryTenantDirectory (voir « Multitenance ») |
| Event sourcing | EventSourcedAggregate, EventStore, defineStoredEvent, EventSerializer, EventSourcedRepository, SnapshotPolicy, personalData, PersonalDataCipher, CatchUpSubscription, EventStoreProjectionRebuild, InMemoryEventStore, PostgresEventStore (voir « Event sourcing ») |
| Côté lecture | Criteria, defineCriteria, validateCriteria, scoped, planCriteria, evaluateCriteria, pageOfPlan, hydrate, ReadModelStore, stampOf, TableReadModelStore, ProjectionRebuild, InMemoryReadModelStore (voir « Critères », « Hydratation » et « Projections ») |
| Aides à la persistance | optimisticUpdate, expectVersion (la version qu'un appelant a lue, If-Match en HTTP, est toujours la version courante, sinon PreconditionFailedError : 412), schemaNameOf, stampCreation, stampUpdate, tenantScopeOf, encodeCursor, decodeCursor |
| Remontée des erreurs | ErrorReporter, ErrorReport, errorReportOf, reportOnce, isUnexpectedError, errorReportingMiddleware, DeadLetterReports, reportProcessFailures, NoopErrorReporter |
| Doubles | FixedClock, SequentialIdGenerator, InMemoryUnitOfWork, FixedActorContext, FixedExecutionScope, InMemoryLogger, InMemoryMetrics, InMemoryErrorReporter, InMemoryCache, InMemoryCacheInvalidation, InMemoryCachePurger, InMemoryLock, InMemoryRateLimiter |
Déclarations (@ddd-ts-kit/kernel/declarations) |
Requires, Public, CommandHandler, QueryHandler, IntegrationEventHandler, DomainEventHandler, Scheduled, Background, Projection, On, applyingProjection, Repository, Gateway, Adapter, Policy, Edge (pour les décorateurs d'un package de transport, comme @Controller), injected, injectedDependencies, declarationOf, listDeclarations, DeclarationError, Token, describeToken, tokenIdentity, portDefect |
Les règles que servent ces blocs sont dans docs/ARCHITECTURE.md.
Un premier agrégat
Section intitulée « Un premier agrégat »Les quatre blocs de base d'un modèle, dans un seul fichier pour la lecture (un projet donne à
chacun son propre fichier, et ddd-ts-kit generate les écrit) :
import { AggregateRoot, ConceptError, Entity, ValueObject, type DomainEvent } from '@ddd-ts-kit/kernel';
// Les refus du concept, chacun une fabrique statique (voir « Erreurs du domaine »).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 }); }}
// Un objet-valeur : immuable, comparé par valeur, valide dès sa construction.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; }}
// Une entité : connue par son identité, atteinte seulement par sa racine d'agrégat.export class OrderLine extends Entity { constructor( productId: string, readonly quantity: Quantity, ) { super(productId); }}
// Un événement du domaine : un fait passé, nommé <contexte>.<agrégat>.<fait>.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, ) {}}
// La racine d'agrégat : la seule entrée, chaque règle vérifiée par une méthode d'intention.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)); }}
// Ce que vérifie une spec de l'agrégat :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 : même valeurnew OrderLine('p-7', Quantity.create(1)).equals(new OrderLine('p-7', Quantity.create(5))); // true : même identité| Bloc | Ce que le noyau lui donne |
|---|---|
Entity<Id> |
id et equals : deux entités sont égales quand leur classe et leur id le sont |
ValueObject<Props> |
des props gelées, equals par valeur (les dates par leur instant, les listes et les enregistrements en profondeur), toJSON |
AggregateRoot<Id> |
une entité avec version, le jeton de concurrence optimiste que gère l'adaptateur de son repository, record(event) pour ses méthodes et pullEvents(). Les événements enregistrés ne sont pas énumérables : décomposer, comparer ou sérialiser un agrégat ne les voit jamais |
DomainEvent |
une interface : name, occurredAt, aggregateId |
Plutôt que de changer l'état depuis l'extérieur, où chaque appelant doit se souvenir de la règle :
order.status = 'placed'; // rien ne vérifie que la commande a des lignesevents.push({ name: 'ordering.order.placed' }); // et rien ne lie le fait au changementécrivez une méthode d'intention sur l'agrégat, qui vérifie la règle et enregistre le fait du même geste :
order.place(clock.now());Result, ok, err et unwrap servent au projet dont les objets-valeurs renvoient leur refus
au lieu de le lever ("stack.valueObjects": "result"). Pourquoi chaque bloc existe :
Agrégats et
Entités et objets-valeurs.
Erreurs du domaine
Section intitulée « Erreurs du domaine »Un refus métier est une DomainError : une réponse que le code a prévue, connue partout par son
code. Un contexte a une classe d'erreur par concept de son langage, et chaque refus est une
fabrique statique de cette classe, nommée avec les mots du métier :
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);| Membre | Ce qu'il fait |
|---|---|
protected static refusal(code, message, facts?) |
construit le refus d'une fabrique : une instance de la classe sur laquelle il est appelé, typée avec ce code et ces faits. Le constructeur est protégé et une sous-classe n'en déclare aucun, si bien qu'en dehors de sa classe un refus n'existe que par une fabrique |
protected exposing(...names) |
appelé sur le refus par sa fabrique : ces faits peuvent sortir du processus. Aucun ne le peut par défaut |
protected causedBy(cause) |
appelé par sa fabrique : l'erreur que ce refus traduit, comme cause |
code, message, name, cause |
comme sur toute DomainError ; name est la classe, code l'identité du refus |
facts |
chaque fait donné à la fabrique, en données simples (RefusalFacts : textes, nombres, booléens, dates, null, listes de ceux-ci), pour le code qui attrape l'erreur et pour les tests |
exposedFacts |
les faits qu'un client peut lire : ce que @ddd-ts-kit/http écrit sous facts dans le document problem |
toJSON() |
{ name, code, message }, plus facts quand certains sont exposés : jamais la pile, la cause ni un fait privé |
static is(error, code?) |
restreint ce qui a été attrapé à un refus de cette classe, de ce code quand il est donné |
Plutôt qu'une Error nue, que rien en aval ne distingue d'un bogue :
throw new Error(`Slot ${startsAt.toISOString()} is taken`); // signalée comme inattendue, renvoyée en 500écrivez le refus du concept, que chaque couche connaît par son code :
throw AppointmentError.slotTaken(calendar.id, slot.startsAt); // une réponse prévue : un 4xx de code SLOT_TAKENLe statut HTTP de chaque code se choisit dans la couche de présentation, à un seul endroit ; les
journaux d'erreurs, les spans et le journal d'audit enregistrent le code, et le rapporteur
d'erreurs laisse une DomainError tranquille (les deux exceptions sont sous « ErrorReporter »).
Les codes d'une classe forment un vocabulaire fermé que le compilateur déduit de ses fabriques,
si bien qu'un switch est exhaustif et que chaque cas connaît ses faits :
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); // une Date case 'APPOINTMENT_NOT_FOUND': return forget(error.facts.appointmentId); }}
if (AppointmentError.is(error, 'SLOT_TAKEN')) { error.facts.calendarId; // une string}
isRefusal(error, 'SLOT_TAKEN'); // quelle que soit la classe : le code est ce sur quoi tout l'aval s'appuieerror instanceof AppointmentError && error.code === 'SLOT_TAKEN' fonctionne aussi, sans les
faits de ce code. Une fabrique laisse son type de retour être inféré : une annotation
: AppointmentError cacherait son code à CodeOf.
Les faits sont privés. Un message est écrit pour un humain et renvoyé au client comme
detail ; les faits sont des données, et restent dans le processus sauf si la fabrique nomme
ceux qui peuvent sortir. N'exposez jamais ce qui identifie une personne. Le journal d'audit et
les spans n'enregistrent que le code.
Une classe par erreur est la forme de 0.1 et 0.2, et fonctionne toujours : DomainError est
toujours abstraite sur code, son constructeur toujours public, et InvariantViolation,
NotFoundError, ConflictError et ConcurrencyError, chacune un refus sous un code fixe, se
construisent toujours avec new.
export class SlotTakenError extends DomainError { readonly code = 'SLOT_TAKEN';}Un projet dit quelle forme il écrit avec "stack.refusals" dans ddd-ts-kit.config.json
(factories, ce qu'écrit init, ou classes, ce que signifie une configuration sans la clé) ;
les règles errors/* tiennent la première, et ddd-ts-kit error-factories fait passer un projet
de la seconde à la première. Les deux formes peuvent cohabiter dans un projet.
Machines à états
Section intitulée « Machines à états »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, }); }}
// Ce sur quoi les transitions sont jugées : l'agrégat le passe à `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 : l'agrégat interroge la machine, il ne teste jamais son statut à la mainexport 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(); // lève PARCEL_UNPAID : la garde a refuséParcel.register('p-2', true).deliver(); // lève PARCEL_TRANSITION_REFUSED : registered ne peut pas deliver
parcelLifecycle.allowedFrom(ParcelStatus.Shipped); // ['reroute', 'deliver']parcelLifecycle.isFinal(ParcelStatus.Delivered); // trueLe cycle de vie d'un agrégat, dans domain/state-machines/<aggregate>.state-machine.ts.
next(from, transition, context) renvoie le statut suivant ou lève : le refus que refused
construit à partir d'une fabrique statique pour une transition que l'état ne permet pas, ou le
refus d'une garde. start(state) vérifie un état initial, can(...) répond sans lever,
allowedFrom(state) et isFinal(state) lisent le graphe, describe() le donne sous forme de
données et mermaidOfStateMachine(description) sous forme de diagramme Mermaid. Une transition
sans to garde l'état. Une déclaration qui nomme un état inconnu lève InvalidStateMachineError
au chargement du fichier. Pourquoi, et la règle qui y tient l'agrégat :
Machines à états.
Événements du domaine et unité de travail
Section intitulée « Événements du domaine et unité de travail »Un effet d'un événement du domaine n'existe que si le changement d'état qui l'a enregistré est validé.
Un agrégat enregistre ses événements ; rien dans un cas d'usage ne les remet. Un repository
enveloppé par dispatchingOnSave(repository, () => dispatcher) les tire lui-même, une fois
résolue une méthode à laquelle on a confié l'agrégat :
const orders = dispatchingOnSave(new KnexOrderRepository(db), () => dispatcher);
await unitOfWork.run(async () => { await orders.save(order); // persisté ; les événements qu'a enregistrés `order` sont mis en file await orders.saveAll([first, second]); await orders.remove(order); // un événement « deleted » est mis en file avec la suppression}); // distribués ici, dans l'ordre d'enregistrement, avant la validationkernelModule() de @ddd-ts-kit/di l'applique à chaque liaison de type repository, si bien
qu'une application n'écrit jamais cette ligne ; le tableau de ce qu'il garantit (ordre,
réentrance, échecs, ordre des enveloppes) est dans le README de ce package. En bref :
- Dans une unité de travail, un save ne fait que mettre en file, dans l'ordre d'enregistrement sur l'ensemble des saves. Une fois le travail terminé, toujours dans la transaction, la file est distribuée : chaque gestionnaire voit chaque save. Les événements enregistrés par des gestionnaires qui enregistrent attendent derrière les autres, dans le même ordre, sans récursion. Puis la transaction est validée.
- Les événements ne sont tirés que si l'appel a réussi. Le premier gestionnaire
before-commitqui échoue rejette l'unité de travail ; le travail, un save ultérieur ou la validation aussi. Les événements en file sont alors abandonnés et jamais remis sur les agrégats : une nouvelle tentative les recharge. - Hors de toute unité de travail, le save distribue avant de se résoudre, les gestionnaires
before-commitpuis lesafter-commit.
Toute la chronologie, avec les doubles du noyau et rien d'autre, telle qu'une spec la mettrait en place :
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() reste public pour cette enveloppe et pour les specs d'un agrégat ; un cas d'usage
et un adaptateur de repository ne l'appellent jamais. Un agrégat se reconnaît à ce qu'il fait
(il a pullEvents), si bien qu'un contexte compilé contre une autre copie du noyau est suivi lui
aussi.
Deux phases
Section intitulée « Deux phases »| Phase | Déclarée | S'exécute | Un échec |
|---|---|---|---|
before-commit (par défaut) |
dispatcher.on(name, callback) |
à la fin de l'unité de travail, dans sa transaction | fait échouer la commande ; tout est annulé |
after-commit |
dispatcher.on(name, callback, { phase: 'after-commit' }) |
une fois la transaction validée, jamais quand elle a été annulée, dans l'ordre de distribution, attendue avant que la commande réponde | ne fait jamais échouer la commande : remonté, et les autres gestionnaires s'exécutent quand même |
Avec les déclarations, la phase est une option du décorateur :
@DomainEventHandler(OrderPaid, { phase: 'after-commit' }).
La phase after-commit vaut au plus une fois : un crash entre la validation et l'exécution la
perd. Ce qui ne doit pas se perdre passe par l'outbox sous forme d'événement d'intégration. Un
gestionnaire before-commit ne peut avoir que des effets que la transaction couvre, car la
validation peut encore échouer après son exécution
(domain-events/transactional-before-commit).
DomainEventDispatcher.dispatch(events, phase?) exécute les gestionnaires d'une phase,
before-commit par défaut. InMemoryDomainEventDispatcher(failed?) est informé de chaque échec
d'un gestionnaire after-commit et garde les cent derniers dans afterCommitFailures, pour les
specs. reportAfterCommitFailures({ reporter, logger, scope? }) est le failed à lui donner
partout où existent un logger et un rapporteur d'erreurs : une entrée Logger.error (« A
domain event handler failed after the commit », avec domainEvent, errorType, errorCode) et
l'erreur remise à l'ErrorReporter sous l'opération after-commit <event name>.
Un gestionnaire after-commit qui confie un agrégat à un repository enveloppé (save,
saveAll, remove(aggregate)) est refusé avant l'écriture avec SaveAfterCommitError :
l'unité de travail est terminée. Il envoie une commande par le CommandBus, qui ouvre sa propre
unité de travail. unitOfWorkStage() répond 'outside', 'open', 'before-commit' ou
'after-commit'.
Une unité de travail à vous
Section intitulée « Une unité de travail à vous »La file vit dans une portée AsyncLocalStorage ouverte par withinUnitOfWork(work, transaction).
InMemoryUnitOfWork et les unités de travail des packages ORM reposent dessus, si bien qu'un
run appelé directement (un consommateur, un script) a le déroulement d'une commande.
unitOfWorkMiddleware l'appelle aussi : une commande est couverte même avec un UnitOfWork qui
ne le fait pas. Un run imbriqué, ou une commande envoyée depuis une unité de travail, rejoint
la portée en cours : une seule distribution, à la fin de la plus externe, et la phase
after-commit après la validation la plus externe. Deux commandes concurrentes ne partagent
jamais une portée.
Un UnitOfWork écrit dans un projet construit run dessus. transaction commence, appelle ce
qu'on lui donne, valide quand cela s'est résolu et annule quand cela a été rejeté :
run<T>(work: () => Promise<T>): Promise<T> { return withinUnitOfWork(work, (scoped) => { if (this.current.getStore() !== undefined) { // imbriqué : rejoindre la transaction return scoped(); }
return this.database.transaction((transaction) => this.current.run(transaction, scoped)); });}unitOfWorkContract de @ddd-ts-kit/testing/contracts fait échouer un adaptateur qui l'a
oublié.
Déclarations
Section intitulée « Déclarations »Une classe dit ce qu'elle est, et la racine de composition s'assemble à partir de ce que disent
les classes. @ddd-ts-kit/kernel/declarations contient les décorateurs qui l'enregistrent. Ce ne
sont que des métadonnées : une déclaration n'instancie rien, ne résout rien, ne lit aucune
configuration et ne connaît aucun conteneur. C'est pourquoi ils vivent ici, où la couche
application peut les importer. Transformer les déclarations en liaisons est le travail de
@ddd-ts-kit/di (contextModule, discoverContext), et de rien d'autre.
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 désigne deux choses sous un même nom, chacune dans son point d'entrée : la classe
abstraite qu'étend un port de repository (point d'entrée principal, importé par le port) et le
décorateur de son adaptateur (ce point d'entrée-ci, importé par l'adaptateur). Aucun fichier n'a
besoin des deux.
Ports et jetons
Section intitulée « Ports et jetons »Un port est une classe abstraite, et la classe est le jeton d'injection. La classe nomme la
dépendance et porte son type : un paramètre de constructeur typé par elle nomme ce qui est
injecté, et app.resolve(Clock) renvoie un Clock. Token<T> est ce type :
abstract new (...args: never) => T. Il n'y a ni constante de jeton ni fichier de jetons. Un port
a une place, une classe abstraite par fichier :
| Port | Fichier |
|---|---|
| un repository du domaine | domain/repositories/*.repository.ts |
| tout autre port, et une classe de valeur | application/ports/*.port.ts |
export abstract class PaymentGateway { abstract charge(orderId: string, amount: Money): Promise<PaymentReceipt>;}La règle du port. Un port n'a que des membres abstraits : aucun corps, aucun paramètre de
constructeur, aucun membre privé ou protégé. Un port peut étendre un autre port. Un adaptateur
implements son port et ne l'extends jamais. Le type reste structurel, si bien qu'un fake,
une classe en mémoire ou un simple littéral objet tient lieu de port sans cérémonie.
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 : uniquement des membres abstraitsexport abstract class PaymentGateway { abstract charge(orderId: string, amount: Money): Promise<PaymentReceipt>;}
// infrastructure/external/http-payment-gateway.adapter.ts : implémente son port, ne l'étend jamais@Adapter(PaymentGateway, { kind: 'external' })export class HttpPaymentGateway implements PaymentGateway { constructor(private readonly clock: Clock) {}
async charge(orderId: string, amount: Money): Promise<PaymentReceipt> { // l'appel au prestataire de paiement se place ici return { reference: `${orderId}:${amount.amount}:${amount.currency}`, paidAt: this.clock.now() }; }}
// test/fakes : un simple objet est un PaymentGateway, puisque le type est structurelexport const acceptingGateway: PaymentGateway = { charge: async (orderId) => ({ reference: `fake-${orderId}`, paidAt: new Date('2026-01-05T09:00:00Z') }),};Repository<A, Id> est le contrat que partagent tous les repositories (findById,
save), sous forme de classe abstraite. Le port de repository d'un agrégat l'étend et ajoute
ses propres méthodes de recherche, comme OrderRepository plus haut ; l'adaptateur implémente
ce port.
Une valeur qui n'est pas un port (un objet de configuration, une connexion à la base, une
liste, une primitive) est typée elle aussi par une classe abstraite, avec des propriétés
abstract readonly, et un simple objet lui est lié avec toValue de @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;}
// racine de compositionbuilder.bind(Database).toValue({ client: knex });builder.bind(PaymentSettings).toValue({ apiKey: configuration.paymentApiKey });C'est la seule manière : ni jeton chaîne, ni jeton symbole, ni utilitaire. Plutôt qu'un jeton à côté du type, deux noms à garder en phase :
export const PAYMENT_GATEWAY = Symbol('PaymentGateway');
constructor(@Inject(PAYMENT_GATEWAY) private readonly payments: PaymentGateway) {}écrivez le type seul, qui est le jeton :
constructor(private readonly payments: PaymentGateway) {}L'identité à travers les copies. Une classe est sa propre identité, et deux copies d'un
package chargées dans un même processus (une installation dupliquée, un bundle à côté de
node_modules) contiennent deux classes. Un port qui déclare
static readonly tokenKey = '<package>.<Port>' est connu par cette clé à la place, la même dans
chaque copie : tokenIdentity(port) renvoie ce par quoi un conteneur indexe, et
describeToken(port) son nom. Chaque port injectable du noyau en porte une (kernel.Clock,
kernel.CommandBus, ...) ; Repository<A, Id> est une base à étendre, jamais un jeton, et n'en
porte aucune.
export abstract class PaymentGateway { static readonly tokenKey = 'ordering.PaymentGateway';
abstract charge(orderId: string, amount: Money): Promise<PaymentReceipt>;}La limite, franchement : un port à vous qui ne déclare pas de tokenKey et qui est chargé deux
fois fait deux jetons. Ce qui est lié sous une copie n'est pas trouvé sous l'autre. Une
application compilée en un seul programme ne le rencontre jamais ; un port publié dans un
package qui peut être installé deux fois, ou embarqué dans un bundle à côté de
node_modules, déclare une tokenKey. La clé est l'identité : deux ports qui déclarent la même
clé sont le même jeton, alors préfixez-la par le package ou le contexte. Elle est déclarée,
jamais héritée : un port qui étend un port à clé déclare la sienne.
Quand en déclarer une : seulement pour un port qu'un processus peut charger depuis deux copies de son package. Un port d'une application, compilé une fois dans un seul programme, ne déclare jamais de clé : la classe suffit comme identité, et une clé ne serait qu'un nom à garder unique à la main.
Un port qui étend un port du noyau déclare sa clé comme n'importe quel autre, sans override :
les ports du noyau portent la leur comme propriété propre qui n'est pas un membre déclaré
(kernel.<ClassName>, posée dans un bloc statique), si bien que rien n'est redéfini.
export abstract class OrderRepository extends Repository<Order> { static readonly tokenKey = 'ordering.OrderRepository';}
export abstract class BusinessClock extends Clock { static readonly tokenKey = 'ordering.BusinessClock';}Ce dont dépend une classe est dit par son constructeur. Un décorateur ne liste rien : chaque
paramètre du constructeur est typé par une classe, son jeton, et ddd-ts-kit docs lit les
signatures dans les sources pour le fichier généré du contexte, qui les enregistre avec
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, généréinjected(PlaceOrderHandler, [OrderRepository, Clock]);
injectedDependencies(PlaceOrderHandler); // [OrderRepository, Clock] : ce avec quoi un conteneur construit le gestionnaireinjected(Class, [Tokens]) enregistre un jeton par paramètre de constructeur, dans l'ordre, dans
un registre partagé par toutes les copies du noyau qu'un processus charge ;
injectedDependencies(Class) le relit, et c'est un conteneur (@ddd-ts-kit/di) qui s'en sert.
Rien ici ne lit design:paramtypes : ces métadonnées n'existent qu'avec les décorateurs hérités
compilés par tsc ou swc, et pas avec les décorateurs standard, ni sous tsx ou esbuild. Un
décorateur accepte encore une liste comme dernier argument,
@CommandHandler(PlaceOrder, [OrderRepository, Clock]) : la forme qu'écrivaient les projets
0.1. Une déclaration la porte alors dans dependencies, qui vaut undefined quand c'est le
constructeur qui le dit, et un conteneur lui donne la priorité.
Ce que le compilateur refuse, dans les deux modes de décorateurs :
- une liste, d'
injectedou d'un décorateur, qui ne nomme pas un port par paramètre de constructeur, chacun du type de son paramètre, dans l'ordre : une entrée manquante ou en trop, un mauvais type, un mauvais ordre ;injected(Class)sans liste pour un constructeur qui prend quelque chose ; - une classe qui n'implémente pas le port dont elle est déclarée l'adaptateur, et une classe abstraite ;
- une classe concrète nommée là où son port est attendu :
[InMemoryOrderRepository, Clock]pour un paramètre typéOrderRepository. Une liste nomme des ports, jamais ce qui les implémente.
Ce qui a changé quand la liste est devenue facultative : un décorateur sans liste accepte n'importe quel constructeur, si bien que le compilateur ne repère plus, au décorateur, une classe dont la liste a été oubliée. Il n'a pas à le faire : la liste est déduite du constructeur, vérifiée là où elle est générée, et une classe restée sans liste est refusée nommément quand l'application est composée.
Ce qui est refusé au chargement du fichier, avec une DeclarationError de @Repository,
@Gateway et @Adapter, parce que le compilateur ne peut pas le voir :
- un port avec un corps :
TalkativePharmacy implements greet: a port is an abstract class with abstract members only. Un membre hérité d'un port parent compte ; - un adaptateur qui étend son port :
CornerPharmacy extends Pharmacy: an adapter implements its port, it never extends it.
portDefect(port, implementation) est cette vérification, qui renvoie la raison ou undefined ;
un conteneur l'applique à une liaison faite à la main. Une classe liée à elle-même n'est pas un
port et n'est pas vérifiée.
Les décorateurs
Section intitulée « Les décorateurs »| Décorateur | Sur | Exemple |
|---|---|---|
Requires(permission, options?) |
une commande ou une requête qui porte son acteur | @Requires(permissions['visit.schedule'], { resource: (c: ScheduleVisit) => ({ type: 'patient', id: c.patientId }), audited: ['startsAt'] }) |
Public(options?) |
une commande ou une requête que n'importe qui peut envoyer | @Public() |
CommandHandler(Message) |
l'unique gestionnaire d'une commande | @CommandHandler(PlaceOrder) |
QueryHandler(Message) |
l'unique gestionnaire d'une requête | @QueryHandler(GetOrder) |
IntegrationEventHandler(definition, { consumer, actorOf? }) |
le gestionnaire d'un événement d'intégration d'un autre contexte | @IntegrationEventHandler(OrderPlaced, { consumer: 'billing.invoice-on-order-placed' }) |
DomainEventHandler(Event) |
un gestionnaire d'un événement du domaine de son propre contexte | @DomainEventHandler(OrderPaid), rattaché au nom que la classe de l'événement déclare une fois dans static readonly eventName |
Scheduled(name, schedule and options) |
un job déclenché par le temps, qui déclenche des cas d'usage | @Scheduled('billing.send-reminders', { cron: '0 7 * * 1-5', timeZone: 'Europe/Paris' }) |
Projection(name) avec On(definition) sur ses méthodes |
un projecteur : la classe qui tient un modèle de lecture à jour | @Projection('scheduling.appointment-list'), @On(PaymentCaptured) |
Repository(Port, options?) |
l'adaptateur d'un repository du domaine | @Repository(OrderRepository) |
Gateway(Port, options?) |
l'adaptateur d'un port qui appelle un autre contexte | @Gateway(StockGateway) |
Adapter(Port, options?) |
l'adaptateur de tout autre port | @Adapter(PaymentGateway, { kind: 'external' }) |
Policy(permission, { name }) |
une règle qui a besoin de l'élément chargé | @Policy(permissions['invoice.void'], { name: 'billing.only-issuer-voids' }) |
- L'accès se dit sur le message. Une commande étend
CommandouAuthorizedCommand, une requêteQueryouAuthorizedQuery: la classe de base dit déjà laquelle des deux elle est, si bien que le décorateur dit seulement qui peut l'envoyer. Le compilateur refuse@Requiressur un message sans acteur.auditedliste les champs qu'un journal d'audit peut enregistrer (un nom, ou{ field, masked: true }), en données simples ; le compilateur vérifie que le message les a. - Les dépendances se disent par le constructeur, comme plus haut. Une liste écrite en dernier argument d'un décorateur, la forme de 0.1, nomme un port par paramètre et est vérifiée par rapport au constructeur. Il n'y a ni décorateur de paramètre ni réflexion.
- Le rôle est vérifié aussi : un gestionnaire d'un autre message ou d'un autre résultat, un
gestionnaire d'événement d'une autre charge utile, un job sans
runsont refusés par le compilateur. - Options d'un repository, d'une gateway et d'un adaptateur :
transient: true(une instance par résolution au lieu d'une par application),replaces: true(prend la place d'une liaison faite plus tôt, comme une valeur par défaut du module du noyau),aspects: [...](enveloppes, la première la plus externe) ; un adaptateur prend aussikind: 'external' | 'publisher'. - Un seul nom pour le décorateur et pour la forme de sa classe :
CommandHandler,QueryHandler,IntegrationEventHandleretDomainEventHandlersont chacun un décorateur et un type, si bien qu'un seul import sert@IntegrationEventHandler(OrderPlaced, ...)etimplements IntegrationEventHandler<OrderPlacedPayload>. Un job implémenteScheduledJob. - Les noms sont écrits, pas déduits : le nom du consommateur, celui de la politique, celui du
job et celui de l'événement du domaine survivent au renommage d'une classe, et la chaîne
vide est refusée. Le nom d'un événement du domaine s'écrit une fois, dans
static readonly eventNamesur sa classe : ses gestionnaires et ses projections l'y lisent (NamedDomainEventType), et une classe qui ne le déclare pas est refusée par le compilateur. - Une classe déclarée est son propre jeton :
@ddd-ts-kit/dilie un gestionnaire, un job ou une politique sous sa classe.
declarationOf(Class) renvoie ce qu'une classe a déclaré, en données simples gelées, ou
undefined ; listDeclarations() liste les classes déclarées que le processus détient encore,
pour le diagnostic. Une classe joue un seul rôle : une seconde déclaration sur la même classe
lève DeclarationError au chargement du fichier, tout comme @Requires ou @Public sur ce qui
n'est ni une Command ni une Query, et @Scheduled avec un nom ou une planification illisible.
Une déclaration n'est pas héritée par une sous-classe.
Les noms du point d'entrée principal diffèrent exprès : là-bas, IntegrationEventCallback et
DomainEventCallback sont les simples fonctions données à subscribe et à on, et
CommandHandler et QueryHandler sont les seules interfaces.
Où c'est stocké. Dans un registre par processus, atteint par Symbol.for, avec les classes
tenues faiblement. Un contexte compilé contre une copie du noyau est donc lu par un conteneur qui
en tient une autre (une installation dupliquée, un bundle à côté de node_modules), et un
module rechargé par un observateur de fichiers ne laisse aucune trace de ses anciennes classes.
Symbol.metadata n'est pas utilisé : Node 24 ne le définit pas, et le mode des décorateurs
hérités n'a pas de contexte pour le porter.
Pas dans le domaine. domain/ ne porte aucun décorateur : agrégats, entités, objets-valeurs,
événements du domaine, services du domaine et ports de repository ne déclarent rien. Un
événement du domaine est nommé par le gestionnaire qui y réagit, pas par lui-même. @UseCase
n'existe pas : l'arborescence canonique a un gestionnaire par commande et aucun service
d'application à lier.
Les événements d'intégration et leurs abonnés
Section intitulée « Les événements d'intégration et leurs abonnés »IntegrationEventPublisher est ce par quoi les cas d'usage publient, IntegrationEventSubscriber
ce sur quoi les gestionnaires sont abonnés, et EventBus les deux à la fois. EventSubscribers
donne à chaque contexte délimité son abonné : forContext(name) renvoie un
IntegrationEventSubscriber. Un seul bus sert tous les contextes par défaut ; un broker donne à
chaque contexte consommateur le sien, pour qu'un contexte extrait dans son propre processus garde
ses files d'attente. @ddd-ts-kit/di abonne les gestionnaires d'événements d'intégration d'un
contexte sur EventSubscribers.forContext(<context name>).
Scheduled
Section intitulée « 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)); }}Un job vit dans application/scheduling/, dans un fichier *.job.ts, et ne fait que
déclencher des cas d'usage : il reçoit des bus et des ports par son constructeur et envoie
typiquement une commande. Le second argument est la planification et, à côté, les options :
| Clé | Par défaut | |
|---|---|---|
every |
un intervalle, en millisecondes ou '30s', '15m', '2h', '1d', aligné sur l'époque Unix |
|
cron, timeZone |
timeZone: 'UTC' |
les cinq champs standard, sur les horloges d'un fuseau IANA |
overlap |
'skip' |
une occurrence qui arrive pendant que l'exécution précédente est en cours est sautée, ou mise en file derrière elle ('queue') |
missed |
'skip' |
les occurrences passées sans avoir été atteintes sont abandonnées, ou la dernière est exécutée une fois ('run-once') |
timeoutMs |
aucun | le signal d'une exécution plus longue est interrompu |
jitterMs |
0 |
un délai aléatoire avant chaque exécution |
enabled |
true |
fait pour être surchargé par la configuration là où le job est câblé |
singleInstance |
true |
le planificateur prend le Lock de chaque occurrence |
On écrit exactement l'un de every et cron, et le compilateur refuse le reste. Le nom est
<context>.<job-name> en minuscules, unique dans l'application. JobExecution est ce que reçoit
run : scheduledAt, startedAt, attempt et un signal d'interruption. Ce que signifient
les planifications lors d'un changement d'heure, ce que garantit une instance parmi plusieurs et
comment tester un job sont dans le README de @ddd-ts-kit/scheduling.
Background
Section intitulée « 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)); }}Un job en arrière-plan est nommé par son jobName statique, par lequel un cas d'usage le met
en file (jobs.enqueue(CompileStatementsBackgroundJob, { month }), la charge utile typée par
run). Le décorateur prend les options, toutes facultatives : maxAttempts (5), backoff
({ baseMs: 1000, factor: 2, maxMs: 300000 }), timeout et lease (30 s), en millisecondes
ou '10m'. Un nom qui n'est pas <context>.<job-name> et des options illisibles lèvent une
DeclarationError au chargement du fichier. BackgroundJobExecution est ce que reçoit run :
jobId, attempt, enqueuedAt, startedAt, le checkpoint qu'une tentative précédente a
enregistré, un signal d'interruption, saveCheckpoint(value) et
reportProgress({ done, total }).
Modes de décorateurs
Section intitulée « Modes de décorateurs »Chaque décorateur de ce point d'entrée fonctionne sous les deux modes de décorateurs de
TypeScript, et la même ligne de source passe la vérification de types sous les deux, vérification
du rôle et d'une liste de dépendances comprises. Aucun n'a besoin d'emitDecoratorMetadata, et
ce que prend un constructeur se sait de la même manière dans les deux : lu dans la source,
enregistré par injected.
| Stack | tsconfig.json |
|---|---|
| class-validator, NestJS, entités TypeORM, toute bibliothèque à décorateurs hérités | "experimentalDecorators": true |
| tout le reste | laissez experimentalDecorators non défini : les décorateurs standard (TC39) n'ont besoin d'aucune option |
Un programme a un seul mode, et c'est pourquoi le kit suit le projet au lieu de choisir. Un
décorateur de classe reçoit d'abord la classe dans les deux modes ; l'appel standard ajoute un
contexte, que ces décorateurs ne lisent pas. Rien d'autre ne diffère : ni
emitDecoratorMetadata, ni reflect-metadata, ni décorateur de paramètre dans aucun des deux
modes. Les deux sont compilés par TypeScript 5, 6 et le TypeScript 7 natif. Sous Vitest, les
décorateurs hérités n'ont besoin de rien ; les standard ont besoin de decorators() de
@ddd-ts-kit/testing/vitest.
Interfaces standard
Section intitulée « Interfaces standard »Standard signifie que chaque projet construit avec le kit a la même forme sous le même nom, si bien qu'un adaptateur, un middleware ou un tableau de bord écrit une fois leur convient à tous. Extensible signifie qu'un projet ajoute ses propres champs sans dupliquer le type (voir « Comment étendre »).
La session authentifiée d'un acteur, en données simples.
| Champ | |
|---|---|
id, actor, issuedAt, expiresAt? |
qui, depuis quand, jusqu'à quand |
authenticationMethod |
none, password, sso, token, api-key, service, ou une chaîne du projet |
authenticationStrength? |
{ mfa?, assuranceLevel? } |
impersonatedBy? |
id de l'acteur du support qui agit en tant qu'actor ; logFieldsOf le porte sur chaque ligne de journal |
locale?, timeZone?, client? |
étiquette BCP 47, fuseau IANA, { ip?, userAgent? } en chaînes opaques |
scopes? |
ce que le titre d'accès permet à quiconque le présente (le scope d'OAuth 2.0) : une limite sur le client, jamais ce que l'acteur peut faire |
extensions |
les champs du projet |
hasScope(session, scope) lit les scopes ; un projet nomme une fois son vocabulaire de scopes
par fusion de déclarations de SessionScopes
(interface SessionScopes { readonly 'invoices:read': true }), et SessionScope, le type que
prend une garde, est alors ce vocabulaire au lieu de n'importe quelle chaîne.
isExpired(session, clock.now()), isImpersonated(session), anonymousSession(),
systemSession(name, issuedAt?), et isSession(value), la garde structurelle qu'une frontière
applique à ce qu'elle a reçu comme principal.
ExecutionContext et ExecutionScope
Section intitulée « ExecutionContext et ExecutionScope »Ce que porte chaque unité de travail, quoi qui l'ait déclenchée : correlationId,
causationId?, actor, session?, tenantId?, elevation? (le personnel de la plateforme qui
lit par-dessus les tenants, voir « Multitenance »), locale?, deadline?, scheduledAt?
(pour un job planifié, l'instant où il était dû), origin (http, event, schedule,
queue pour un job en arrière-plan, cli, ou une chaîne du projet) et extensions.
executionContext(init) en construit un et prend le tenant de l'acteur ;
withActor(context, actor) est le même travail vu par un autre acteur.
ExecutionScope (current(), require(), run(context, work)) donne le contexte du travail en
cours au code à qui on ne le passe pas. La frontière le pose une fois :
// présentation ou plateforme : un run par requête HTTP, événement consommé, jobscope.run(executionContext({ correlationId, actor, session, origin: 'http' }), () => handle(request));// infrastructure : un adaptateur de repositoryconst row = { ...toRow(order), ...stampCreation(scope, clock), ...tenantScopeOf(scope) };Les deux bouts, avec l'implémentation qui suit la chaîne d'appels asynchrones :
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: {} };
// la frontière : posé une fois pour la requête HTTPawait scope.run(executionContext({ correlationId: 'req-7f3a', actor, origin: 'http' }), async () => { // ...la commande passe par le bus jusqu'à son gestionnaire, qui enregistre ; dans l'adaptateur du repository : 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;});Les cas d'usage et le domaine ne lisent jamais la portée : ils reçoivent l'acteur, et tout ce
dont ils ont besoin d'autre, dans la commande ou la requête (authz/actor-explicit). La portée
sert aux loggers, aux colonnes d'audit, au cloisonnement par tenant et aux en-têtes sortants.
Plutôt qu'un gestionnaire qui demande à la portée qui agit, une dépendance que sa signature
cache :
const actor = this.scope.require().actor;écrivez une commande qui porte son acteur, le premier argument d'une AuthorizedCommand, et
lisez command.actor dans le gestionnaire :
export class CancelOrder extends AuthorizedCommand { constructor(actor: Actor, readonly orderId: string) { super(actor); }}Un événement consommé ouvre son propre contexte : executionContextOfEvent(event, { consumer })
a pour origine event, la corrélation de l'événement (son id quand il n'en nomme aucune),
l'événement comme causationId, systemActor(consumer) comme acteur et le tenantId de
l'événement, si bien que le consommateur lit et écrit dans ce tenant seulement ;
inEventScope(scope, { consumer }, handler) enveloppe un gestionnaire avec. L'enveloppe standard
ne porte aucun acteur, pour qu'aucune donnée personnelle ne voyage par défaut : un projet dont
les enveloppes en nomment un passe actorOf. À la sortie, scopedPublisher(publisher, scope)
appose la corrélation et le tenant du travail en cours sur chaque événement publié sans eux,
si bien que tout ce qu'un même stimulus provoque se retrouve d'un processus à l'autre et reste
dans son tenant.
ActorContext demeure, inchangé. ScopedActorContext l'implémente par-dessus une portée :
l'acteur courant est l'acteur du contexte d'exécution courant, si bien que les deux ne peuvent
pas diverger. AsyncLocalStorageExecutionScope suit la chaîne d'appels asynchrones ;
FixedExecutionScope est le double de test, et suit un FixedActorContext quand on lui en
donne un.
debug, info, warn, error(message, fields?), child(fields), isLevelEnabled(level) et
flush(). LogFields énonce une fois les clés bien connues : correlationId, causationId,
context, actorId, tenantId, errorType, errorCode, durationMs ; toute autre clé est
acceptée. Un appel ne lève jamais, quels que soient les champs : quand ils ne peuvent être ni lus
ni sérialisés, l'entrée est écrite avec UNWRITABLE_FIELDS
(logError: "unserializable fields") à leur place. Le noyau n'écrit jamais sur la console :
NoopLogger est la valeur par défaut, InMemoryLogger enregistre des entries pour un test,
un adaptateur JSON lines est dans @ddd-ts-kit/aspects, et @ddd-ts-kit/pino et
@ddd-ts-kit/winston sont les adaptateurs de ces bibliothèques. Le domaine et les cas d'usage
ne journalisent pas.
Les erreurs sont des valeurs de journal. LogValue admet une Error, si bien que
logger.error('payment failed', { error }) compile tel quel, et un projet peut nommer la clé une
fois (interface LogFields { error?: Error }, voir « Comment étendre »). Une Error placée
directement sous une clé des champs est écrite par chaque logger telle que
describeError(error) la décrit, une ErrorDescription :
| Clé | |
|---|---|
type |
le nom de la classe |
code |
le code d'une DomainError ; absent pour toute autre erreur |
message |
le message |
stack |
la pile de cette seule erreur |
cause |
la cause, décrite de la même manière quand c'est une Error : huit erreurs de profondeur au plus, pour qu'une chaîne qui boucle se termine |
Plus profondément dans les champs (dans un objet imbriqué ou une liste), une erreur est laissée
au sérialiseur de l'adaptateur : passez-la sous une clé. describeErrorFields(fields) est la
copie des champs qu'écrit un logger ; un adaptateur à vous l'appelle et est tenu au résultat
par loggerContract.
Les messages des erreurs inattendues peuvent être retirés. Chaque logger du kit prend
scrubMessages: true (DescribeErrorOptions) : le message de toute erreur qui n'est pas une
DomainError devient SCRUBBED_MESSAGE ([message removed]) et sa pile perd sa première ligne,
qui le répète. Les messages des DomainError sont écrits par le projet et restent. C'est
l'option du même nom de @ddd-ts-kit/sentry, avec la même règle et le même marqueur. Par défaut
les messages sont gardés : un journal qui dit [message removed] coûte à chaque investigation.
Ce que le kit journalise de lui-même ne contient jamais de message. L'aspect logged,
loggingMiddleware et le planificateur de jobs ne remettent jamais une erreur à un logger :
ils écrivent errorType et errorCode, tels que identifyError(error) les donne, et l'erreur
elle-même va à l'ErrorReporter. Cela vaut quoi que dise scrubMessages. La description
complète est pour l'erreur qu'un projet décide de journaliser, dans un adaptateur ou une route ;
le retrait des messages est son choix explicite pour les journaux qui quittent ses mains.
isLevelEnabled(level) dit si une entrée de ce niveau serait écrite, pour le logger et ses
enfants. Interrogez-le avant de construire des champs coûteux ; le kit le fait, dans logged,
loggingMiddleware et le planificateur. NoopLogger répond false.
flush() se résout une fois que chaque entrée journalisée jusque-là, par le logger ou un
enfant, a été remise à sa destination, et ne rejette jamais. On l'appelle une fois, à l'arrêt du
processus, après la dernière entrée : l'application de @ddd-ts-kit/di l'appelle à la fin de
stop(). Ce qui est journalisé après un flush n'est pas spécifié, et ne lève jamais : pino et
StdoutLogger l'écrivent encore, winston, dont le flush termine le logger, l'abandonne. Un
second appel se résout aussi.
InMemoryLogger prend { level?, fields?, base?, scrubMessages? } comme un vrai adaptateur : un
seuil (par défaut debug, chaque entrée), des champs lus à chaque entrée, des champs ajoutés à
chaque entrée. Ses entries contiennent ce qu'écrit un logger : une erreur passée en champ est
enregistrée sous forme de sa description. CREDENTIAL_KEYS et REDACTED sont les noms de champs
et le marqueur avec lesquels les adaptateurs pino et winston masquent ; StdoutLogger et
InMemoryLogger ne masquent rien.
counter(name).increment(labels?, by?), histogram(name).record(value, labels?),
gauge(name).set(value, labels?). Les valeurs des labels viennent d'un petit ensemble fermé,
jamais d'un identifiant. NoopMetrics est la valeur par défaut ; InMemoryMetrics enregistre
des samples et répond counterValue, histogramValues et gaugeValue pour un sous-ensemble de
labels.
ErrorReporter
Section intitulée « ErrorReporter »report(error, report) et flush(timeoutMs) : là où vont les échecs inattendus pour être
examinés, un traqueur d'erreurs. report est synchrone : il met en file et rend la main, ne lève
jamais et n'attend jamais le traqueur ; flush attend la file avant l'arrêt du processus et ne
rejette jamais. ErrorReport est en données simples, uniquement des identifiants : origin,
operation, context, correlationId, traceId, actorId, tenantId, errorCode,
severity (fatal, error, warning) et les extensions du projet. NoopErrorReporter est
la valeur par défaut, InMemoryErrorReporter enregistre des reports pour un test, et
@ddd-ts-kit/sentry est l'adaptateur pour Sentry et GlitchTip.
Aucun gestionnaire, agrégat ou repository ne l'appelle : ils lèvent. Ce qui les entoure remonte, chaque pièce attachée de l'extérieur :
| Point de capture | Est | Remonte |
|---|---|---|
| bus | errorReportingMiddleware(reporter, scope), premier sur le bus |
une commande ou une requête qui échoue avec autre chose qu'une DomainError ; silencieux pendant la consommation d'un événement |
| HTTP | reportHttpErrors(reporter) de @ddd-ts-kit/http, en onError |
chaque requête HTTP répondue en 5xx |
| jobs | reportFailedRuns(reporter) de @ddd-ts-kit/scheduling, en onRun |
une exécution qui a échoué avec autre chose qu'une DomainError |
| événements | new DeadLetterReports(reporter) : onFailure sur le hook du fournisseur, observing(subscriber) autour de son abonné |
un événement que son fournisseur abandonne, jamais une livraison retentée |
| processus | reportProcessFailures(reporter) |
uncaughtException et unhandledRejection, puis code de sortie 1 après un flush |
Une DomainError est un refus métier et n'est jamais remontée, sauf si HTTP la répond en 5xx ou
si elle enterre un événement (un warning). Toutes passent par reportOnce : le premier point
de capture qui voit un objet d'erreur le remonte, les frontières qu'il traverse ensuite le
reconnaissent, si bien qu'une commande qui échoue dans une requête HTTP ou un job fait un seul
rapport, au nom de la commande. errorReportOf(error, source, executionContext?) construit le
rapport : les ids d'origine, de corrélation, d'acteur et de tenant viennent du contexte
d'exécution, jamais passés à la main.
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')); // la cotation, typée par sa clé
clock.advance(60_000);await cache.get(quoteKey('EUR-USD')); // undefined : expirée, selon l'horlogeLa clé porte le type de sa valeur. InMemoryCache(clock, { maxEntries? }) expire selon le
Clock et est borné. tenantCacheKey<Quote>(scope, 'quote', pair) est la même clé confinée au
tenant du travail en cours : ce qu'un tenant a mis en cache n'est jamais servi à un autre.
Purger les caches HTTP
Section intitulée « Purger les caches HTTP »Deux ports, pour qu'un cas d'usage ne puisse jamais purger avant sa validation :
| Port | Appelé par | Fait |
|---|---|---|
CacheInvalidation.invalidate(tags) |
un gestionnaire before-commit de l'événement du domaine qui a changé ce que montrent les réponses |
demande que les tags soient purgés une fois le travail validé ; domain-events/transactional-before-commit le permet |
CachePurger.purge(tags) |
le relais de l'outbox, jamais un cas d'usage (refusé avant la validation) | purge un cache partagé tout de suite : InMemoryCachePurger ici, Varnish et les CDN dans @ddd-ts-kit/http/purgers |
cacheTag('calendar', id) nomme une ressource (calendar:c-1, l'identifiant encodé en pourcent),
cacheTag('calendar') toutes les ressources d'un type ; les routes de @ddd-ts-kit/http
étiquettent leurs réponses avec la même fonction.
PublishedCacheInvalidation({ publisher, ids, clock, scope, perTenant? }), ce que lie
kernelModule() de @ddd-ts-kit/di, publie une CachePurgeRequest sur le publisher, les
tags sous le tenant du travail (tenantCacheTags) quand perTenant est posé, comme le pose
une application multitenant dont les réponses sont étiquetées par tenant (un travail qui ne
nomme aucun tenant est alors refusé avec NoCurrentTenantError : sa purge ne rencontrerait
aucune réponse) : derrière une outbox, c'est une ligne de la transaction, disparue avec une
annulation, conservée à travers un crash après la validation.
new PurgingPublisher(purger, broker) est ce par quoi le relais publie : les demandes de purge
vont au purgeur, les autres événements au broker, et une purge échouée lève pour que le relais
la retente. new RepeatedCachePurger(purger, { againAfterMs, onFailure? }) purge, puis purge les
mêmes tags une fois de plus againAfterMs plus tard, pour une lecture qui a chargé l'état
précédent avant la validation et atteint le cache après la première purge ; la seconde purge est
au mieux, remontée à onFailure. InMemoryCacheInvalidation retient les tags demandés
(invalidated), pour les 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) { // quelqu'un d'autre tient la clé return; }
try { await exportEverything(); } finally { await handle.release(); }}Un mutex entre processus, pour éviter de faire deux fois le même travail. acquire renvoie
undefined quand la clé est tenue, tout de suite ou après waitMs ; le verrou se libère de
lui-même après ttlMs ; le fencingToken d'un LockHandle croît à chaque acquisition d'une
clé, si bien qu'une ressource qui retient le plus élevé refuse un détenteur dont le verrou a
expiré. C'est un outil d'efficacité, jamais la garde d'un invariant : ce qui ne doit pas arriver
deux fois est refusé par une contrainte de la base de données.
InMemoryLock(clock, { retryDelayMs?, sleep? }) exclut à l'intérieur d'un processus et expire
selon le Clock : le fake, et assez pour une instance unique. L'adaptateur entre processus est
dans @ddd-ts-kit/redis. acquiring(attempt, waitMs, waiting) est la boucle de nouvelles
tentatives que partagent les adaptateurs.
FileStorage, StoredFile, FileScanner
Section intitulée « FileStorage, StoredFile, FileScanner »// La commande enveloppe ce que le contrôleur a reçu : le bus le met en zone de transit avant l'ouverture de la transaction.this.document = new FileUpload(received, 'appointment-documents');
// Le gestionnaire, dans l'unité de travail :const { file, fileName } = command.document; // un StoredFile, en transit et analyséappointment.attachDocument(unwrap(AttachedDocument.create(file, fileName, now)), now);await appointments.save(appointment);await staging.promoteOnCommit(file.key); // déplacé vers sa clé avec la validationFileStorage contient le contenu des fichiers, jamais la base de données : put (un flux en
entrée, mesuré et haché, refusé avec FILE_TOO_LARGE au-delà de maxBytes, sans rien laisser),
get (un flux en sortie), stat, delete, copy, move, list(prefix), et des liens signés
courts, signedDownloadUrl et signedUploadUrl (au plus MAX_SIGNED_URL_SECONDS, une heure).
Aucune méthode ne prend ni ne renvoie un fichier entier. Une clé est vérifiée par isFileKey
(des segments de lettres, de chiffres, de ., de - et de _, aucun ne commençant par un
point) avant que quoi que ce soit soit touché, et FileKeys la génère sur le serveur :
TenantFileKeys(ids, clock, scope) écrit tenants/<tenant>/<area>/<year>/<month>/<id>, le
segment du tenant seulement quand le travail en cours en nomme un (l'id du tenant tel quel :
le noyau refuse tout autre caractère que lettres, chiffres, - et _, si bien que deux
tenants ne partagent jamais un préfixe). Les liens de signedUploadUrl portent un maxBytes,
que les stockages servis par l'application font respecter.
StoredFile est ce que garde un agrégat : key, size, mediaType, checksum
(sha256:<hex>), jamais le fournisseur. FileScanner est le point d'accroche d'un antivirus ou
d'une vérification de contenu, appelé sur le fichier en transit avant qu'il soit accepté ;
AcceptEveryFileScanner est celui d'un projet qui n'en a pas encore. FileError porte les
refus : FILE_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE, FILE_NOT_FOUND, INVALID_FILE_KEY,
FILE_REJECTED, INVALID_FILE_URL_EXPIRY.
Une écriture dans le stockage n'est pas annulée avec une transaction.
FileStaging(storage, scanner, reporter) donne l'ordre qui garde les deux cohérents, chaque
étape une seule fois :
stage(key, content, options)écrit sousstaging/<key>et analyse ; un fichier refusé est supprimé (FILE_REJECTED). Un fichier venu d'un client voyage dans sa commande sous forme deFileUpload(leReceivedFileet la zone de sa clé), mis en transit parfileUploadsMiddleware(desk), un middleware de bus placé après la permission et avant l'unité de travail, si bien qu'aucune transaction n'attend un téléversement.upload.fileest leStoredFileen transit.promoteOnCommit(key)déplace le fichier vers sa clé comme dernière étape de l'unité de travail (atCommit, unCommitStepfait d'applyet d'undo) : une promotion qui échoue annule la commande, et une transaction annulée après elle renvoie le fichier dansstaging/. Une ligne validée ne pointe jamais dansstaging/.deleteAfterCommit(key)supprime, une fois la validation faite, un fichier que l'agrégat ne référence plus (afterCommit, au plus une fois : un échec ou un crash laisse un fichier non référencé, remonté).purge(olderThan)supprime les fichiers en transit plus anciens que l'instant, tous orphelins ; le jobPurgeStagedFilesl'exécute (la plateforme l'enregistre sousfiles.purge-staged, toutes les heures, pour les fichiers en transit depuis un jour ou plus).
Pour un gros fichier, le client téléverse directement vers le stockage :
uploadUrl(key, { expiresInSeconds, mediaType, accepted, maxBytes }) renvoie le link pour le
client et l'autorisation pending (PendingUpload : clé, plafond de taille, types de média) que
la commande enregistre sur l'agrégat ; confirm(pending), appelé par une commande qui relit
l'autorisation depuis l'agrégat, jamais depuis le client, vérifie ce qui est arrivé par rapport
au plafond et sur ses octets (contentMatches), l'analyse, et renvoie le StoredFile. Aucune
clé finale ne peut commencer par staging/ (assertFinalKey).
FileInspection est le passage par lequel chaque connecteur fait couler son flux : il compte,
hache et, avec une attente, retient les premiers octets jusqu'à ce qu'ils prouvent le type de
média déclaré (acceptsMediaType, contentMatches : un PDF commence par %PDF-, les documents
bureautiques sont des archives zip, le texte est de l'UTF-8 sans caractères de contrôle, SVG et
les formats binaires inconnus ne correspondent jamais).
Connecteurs : InMemoryFileStorage pour les tests (keys() liste ce qu'il contient) et
LocalFileStorage({ directory, baseUrl, secret }) pour le développement, tous deux
ServedFileStorage : leurs liens signés pointent vers l'application, qui y répond avec
signedFileRoutes de @ddd-ts-kit/http. Le connecteur de stockage objet est
@ddd-ts-kit/s3. Chacun passe fileStorageContract de @ddd-ts-kit/testing/contracts.
L'import et l'export en arrière-plan (validation ligne par ligne, un rapport d'erreurs, la
reprise) sont une conception bâtie sur ces ports et la file de jobs (voir « JobQueue »), pas
une fonctionnalité de cette version :
Import et export de fichiers.
JobScheduler
Section intitulée « JobScheduler »abstract class JobScheduler { abstract register(job: JobDefinition): void; // { name, schedule, run, ...options } abstract start(): Promise<void>; abstract stop(): Promise<void>; // attend les exécutions en cours, puis interrompt leur signal abstract runNow(name: string): Promise<JobRun>; // pour une opération ou un test abstract describe(): readonly JobDescription[]; // name, schedule, enabled, running, nextRunAt, lastRun}Ce qui exécute les jobs déclarés quand ils sont dus. Ce n'est pas le Scheduler de
@ddd-ts-kit/aspects, qui est une minuterie pour les nouvelles tentatives et les délais
d'expiration : celui-ci connaît les jobs, les planifications et les instances. Un JobRun est
une occurrence et son issue (succeeded, failed avec le code de l'erreur et jamais son
message, skipped-overlap, skipped-locked, missed). Un job qui échoue n'arrête ni le
planificateur ni les autres jobs, et runNow répond failed au lieu de rejeter : seul un nom
inconnu rejette (UnknownJobError), et un nom enregistré deux fois lève (DuplicateJobError).
Une exécution se déroule dans ExecutionScope, dans le contexte que construit
executionContextOfJob(name, { correlationId, scheduledAt, deadline?, tenantId? }) : origine
schedule, systemActor(name), une corrélation propre, et scheduledAt, l'instant où
l'exécution était due, que ExecutionContext porte pour cette origine. Un job est global par
défaut et ne s'exécute dans aucun tenant ; tenancy: 'per-tenant' fait que le planificateur
l'exécute une fois par tenant d'un TenantDirectory, chaque exécution dans ce tenant.
parseJobSchedule(schedule) valide une planification et renvoie la forme à partir de laquelle
les occurrences sont calculées (InvalidJobScheduleError, code INVALID_JOB_SCHEDULE, sinon) ;
describeJobSchedule(schedule) en est le texte ; jobPolicy(options) valide les options et
complète les valeurs par défaut. Le noyau ne contient aucun planificateur : celui en processus, le
calcul des occurrences et le planificateur manuel pour les tests sont dans
@ddd-ts-kit/scheduling, et le contrat du port est jobSchedulerContract de
@ddd-ts-kit/testing/contracts.
JobQueue
Section intitulée « 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>; // statut, tentatives, progression, code de la dernière erreur}Là où un cas d'usage confie du travail à un worker. Les EnqueueOptions sont delay ou
runAt, une idempotencyKey (un job de même nom et de même clé, mis en file pour le même
tenant et encore conservé, est renvoyé, duplicate: true) et un subject (l'id de ce sur quoi
travaille le job, que find renvoie à côté du tenant : une requête vérifie que le job
appartient à l'élément sur lequel on l'a interrogée). QueuedJobStatus est le vocabulaire fermé
des statuts (waiting, running, succeeded, cancelled, dead), à publier dans un contrat.
Un travail fait pour un tenant ne trouve et n'annule que les jobs de ce tenant (une
élévation, ceux des tenants qu'elle nomme). Appelé dans une unité de travail, un job est
ajouté ou annulé avec le changement métier : les stores des packages ORM écrivent avec la
transaction, un store qui ne le peut pas (Redis, la mémoire) attend la validation par
whenCommitted(write, failed?), l'utilitaire que tout adaptateur de ce genre utilise. Une
exécution se déroule dans ExecutionScope, dans le contexte que construit
executionContextOfBackgroundJob({ jobId, enqueuedBy, deadline? }) : origine queue, la
corrélation, l'acteur et le tenant du travail qui l'a mis en file (EnqueuedBy), le job
comme cause. backgroundJobPolicy(options) complète les valeurs par défaut et
retryDelayMs(policy, attempt) est le délai entre tentatives. La file, son worker et ses
stores sont dans @ddd-ts-kit/scheduling et les packages ORM ; pourquoi et comment :
Jobs en arrière-plan.
ProcessRunner et ProcessStore
Section intitulée « ProcessRunner et 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 sauf si version vaut celle stockée plus 1}Les gestionnaires de processus : une classe de application/processes/ (ProcessManager<State>)
avec un processName statique, un timeoutJob statique facultatif et une garde statique
isState ; ses étapes renvoient une ProcessDecision (state, commands, timeouts sous la
forme { name, at }, cancel, ended). Un état que la garde ne reconnaît pas lève
UnreadableProcessStateError. InMemoryProcessStore garde les instances en JSON, pour les tests
et un processus unique. L'exécuteur est StoredProcessRunner de @ddd-ts-kit/scheduling ;
pourquoi et comment :
Sagas et gestionnaires de processus.
RateLimiter
Section intitulée « 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); }}Une fenêtre glissante : à tout instant, au plus limit appels autorisés dans les windowMs qui
le précèdent. Une RateLimitDecision vaut { allowed, remaining, retryAfterMs } ; un appel
refusé n'est pas compté. La clé est ce qu'on limite : un client, un tenant, une route et une
adresse ensemble. Dans un projet multitenant, tenantKey(scope, 'password-reset', accountId) la
confine au tenant du travail en cours (tenant:<id>:password-reset:<account>) et refuse un
travail qui n'en nomme aucun.
InMemoryRateLimiter(clock) compte à l'intérieur d'un processus ; l'adaptateur partagé par
plusieurs instances est dans @ddd-ts-kit/redis. rateLimitDecisionOf(counted, limit)
transforme ce qu'un adaptateur a compté en décision.
Multitenance
Section intitulée « Multitenance »Le tenant du travail est ExecutionContext.tenantId, posé à la frontière et lu par
l'infrastructure seulement, comme le reste du contexte. Le noyau contient ce que toutes les
stratégies partagent ; l'isolation elle-même (colonne de tenant, sécurité au niveau des lignes,
schéma ou base de données par tenant) est dans les packages de persistance, et le tableau
complet dans Multitenance.
| Concept | Ce qu'il fait |
|---|---|
isTenantId(value), assertTenantId(id), InvalidTenantIdError |
la seule grammaire d'un id de tenant (lettres, chiffres, _, -, 63 au plus) : executionContext, executionContextOfEvent, acrossTenants, InMemoryTenantDirectory, tenantScopeOf, tenantReachOf et les réglages de sécurité au niveau des lignes refusent tout autre, pour qu'aucun id de tenant ne puisse être lu comme un joker, une liste ou un séparateur de clé |
tenantScopeOf(scope) |
le seul tenant que nomme une écriture ; NoCurrentTenantError pour un travail qui n'en nomme aucun |
tenantReachOf(scope) |
ce qu'une lecture peut voir : { kind: 'tenant', tenantId }, ou { kind: 'elevated', tenants } sous une élévation |
tenantsOf(actor), isTenantMember(actor, tenantId) |
les tenants auxquels appartient un acteur : son tenantId et son attribut tenants |
acrossTenants({ scope, trail, clock, ids }, { reason, tenants }, work) |
le personnel de la plateforme qui lit par-dessus les tenants : refusé sans raison ou sans tenant, enregistré dans l'AuditTrail (action tenancy:elevate) avant que le travail s'exécute ; les écritures nomment toujours un seul tenant |
tenantKey(scope, ...segments), tenantCacheKey(scope, ...segments) |
les clés de limites de débit, de verrous et de caches confinées au tenant : tenant:<id>:<segments>, chaque segment encodé en pourcent, pour que deux listes de segments ne donnent jamais une même clé |
TenantDirectory, InMemoryTenantDirectory |
les tenants qui existent, pour les jobs par tenant, le provisionnement et les migrations |
TENANT_SETTINGS, tenantSettingValues(context), TENANT_ISOLATION_POLICIES, tenantPolicyExpressions(column) |
ce que partage la sécurité au niveau des lignes des packages de persistance : les trois réglages d'une transaction (ddd_ts_kit.tenant_id, et pour une élévation seulement ddd_ts_kit.reach et ddd_ts_kit.every_tenant), leurs valeurs pour le travail en cours, les noms des quatre politiques (une par commande) et leurs expressions read et write |
Une élévation n'est jamais impliquée par un rôle : la plateforme décide de la permission
d'élever (un cas d'usage ou une garde), puis appelle acrossTenants. Un journal qui ne parvient
pas à l'enregistrer la refuse.
Page et PageRequest
Section intitulée « Page et PageRequest »Une seule forme pour chaque recherche, en deux variantes :
import { cursorPage, offsetPage } from '@ddd-ts-kit/kernel';
declare const cursor: string; // le nextCursor de la page précédente
offsetPage({ offset: 40, limit: 20, sort: [{ field: 'placedAt', direction: 'desc' }] });cursorPage({ cursor, limit: 50 });// les deux construisent une PageRequest ; la recherche renvoie une Page<T> : { items, total?, nextCursor? }Un curseur est opaque pour le client et lisible pour le lecteur : encodeCursor(position) écrit
les valeurs de tri du dernier élément d'une page, decodeCursor(cursor) les relit et refuse ce
qu'il n'a pas écrit. Il n'est pas signé : le port de lecture vérifie les types de ce qu'il décode
avant qu'ils atteignent une instruction. Un curseur a besoin d'un ordre total : ajoutez une
clé unique (la clé primaire) au tri, dans la même direction, mettez-la dans la position, et
comparez le tuple entier. Sinon, des lignes égales sur le champ de tri n'ont pas d'ordre défini,
et une page pourrait en répéter ou en sauter une. Les packages d'adaptateurs montrent la
requête pour chaque ORM.
offsetPage et cursorPage valident et lèvent InvalidPageRequestError
(INVALID_PAGE_REQUEST) au-delà de PageLimits (taille par défaut 20, maximum 100).
pageOf(items, more) et mapPage(page, map) construisent et traduisent la réponse. Les champs
triables sont un paramètre de type, PageRequest<'placedAt' | 'total'> : ils diffèrent pour
chaque port de lecture, ce qu'une augmentation globale ne pourrait pas exprimer.
AuditStamp et TenantScoped
Section intitulée « AuditStamp et TenantScoped »AuditStamp (createdAt, createdBy, updatedAt, updatedBy) et TenantScoped (tenantId)
sont des colonnes d'un enregistrement stocké, pas des propriétés d'un agrégat.
stampCreation(scope, clock), stampUpdate(scope, clock) et tenantScopeOf(scope) les
remplissent dans un adaptateur de repository :
// insertion : qui crée est aussi qui a modifié en dernierconst row = { ...toRow(order), ...tenantScopeOf(scope), ...stampCreation(scope, clock) };// mise à jour : les deux colonnes à poser, la création n'est pas touchée ; le tenant fait partie de chaque conditionawait update({ ...toRow(order), ...stampUpdate(scope, clock) }, { id: order.id, ...tenantScopeOf(scope) });stampUpdate(stamp, scope, clock) est la forme pour un adaptateur qui détient le tampon stocké
(un store de documents qui réécrit l'enregistrement entier) : il le renvoie entier, création
intacte. tenantScopeOf lève NoCurrentTenantError pour un travail qui ne nomme aucun tenant,
si bien qu'une instruction ne peut jamais s'exécuter sans restriction par oubli. Chaque package
d'adaptateur nomme les colonnes de la même manière : created_at, created_by, updated_at,
updated_by, tenant_id.
AuditTrail, AuditReader et AuditEntry
Section intitulée « AuditTrail, AuditReader et AuditEntry »abstract class AuditTrail { abstract record(record: AuditRecord): Promise<void>;}
abstract class AuditReader { abstract search(filter: AuditFilter, page: PageRequest<AuditSortField>): Promise<Page<AuditEntry>>;}Les deux ports d'un journal d'audit et la forme d'une entrée (AuditEntry, AuditRecord pour ce
que remet une source, AuditFilter, et les types qu'ils nomment), pour que le code qui enregistre
ou liste ne dépende que du noyau. AuditTrail est en ajout seul et est alimenté par
l'infrastructure et le code transverse (un middleware de bus, le hook d'autorisation, un
abonnement à des événements), jamais par un cas d'usage ou un agrégat. AuditReader est ce dont
dépend un gestionnaire de requête pour lister les entrées. Un projet ajoute des champs à chaque
entrée par AuditEntryExtensions.
Le noyau ne contient aucune implémentation : la chaîne de hachage, les sources, le masquage, la
vérification et les stores, celui en mémoire compris, sont dans @ddd-ts-kit/audit.
Hors du noyau
Section intitulée « Hors du noyau »Les feature flags n'ont pas de port ici. Leur modèle d'évaluation (booléen ou variante, ciblage, pourcentage, qui est le sujet) diffère selon chaque fournisseur, et OpenFeature est déjà le contrat neutre vis-à-vis des fournisseurs : un projet qui a besoin de flags déclare un port dans le contexte qui les lit, nommé d'après la décision qu'il prend.
Critères
Section intitulée « Critères »Une lecture d'un modèle de lecture, décrite une fois pour toutes les recherches : des filtres, un tri, une page. Des critères connaissent des noms de champs et rien d'un store. Ce n'est pas une spécification : une spécification est un prédicat métier sur un agrégat et vit dans le domaine ; des critères décrivent une lecture.
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' } }));Du gestionnaire jusqu'au store, des critères sont vérifiés deux fois, et le périmètre de l'acteur voyage avec eux :
Vérifiés par le compilateur. where prend un champ de la ligne, un opérateur qui s'applique
à ce que contient le champ, et une valeur de son type : where('seats', 'contains', '2'), un tri
sur un booléen, isNull sur un champ qui n'est jamais nul, une valeur hors d'une union de
littéraux et un champ hydraté au moment de la requête sont tous refusés. Il en va de même pour
une déclaration : le type de chaque champ, ses opérateurs et nullable sont vérifiés par rapport
à la ligne.
Vérifiés par le port de lecture. validateCriteria(definition, criteria) renvoie les
violations, ne lève jamais, et ne cite jamais une valeur. planCriteria(definition, criteria)
valide d'abord et lève InvalidCriteriaError (INVALID_CRITERIA) : des critères que leur
définition refuse n'atteignent jamais une instruction.
| Code | Chemin | Refusé |
|---|---|---|
unknown_field |
filter.<field> |
un champ que la définition ne déclare pas |
operator_not_allowed |
filter.<field> |
un opérateur que le champ ne déclare pas |
invalid_value |
filter.<field> |
une valeur qui n'est pas du type du champ, une liste vide, un between sans deux valeurs, un texte à chercher vide |
in_list_too_long |
filter.<field> |
une liste au-delà de maxInList (100) |
too_deep |
filter |
des groupes imbriqués plus profondément que maxDepth (1 : or(a, b) ; 2 : or(and(a, b), c)) |
empty_group |
filter |
un groupe sans filtre |
too_many_filters |
filter |
plus de conditions que maxFilters (20) |
unsortable_field |
sort |
un tri sur un champ non déclaré sortable (le départage l'est toujours) |
page_too_large |
page.limit |
une page au-delà de maxPageSize (100 ; la taille par défaut est 20) |
invalid_cursor |
page.cursor |
un curseur qui n'est pas le nextCursor d'une page du même tri |
Ce que signifient des critères
Section intitulée « Ce que signifient des critères »La référence est evaluateCriteria(definition, rows, criteria), qui exécute des critères sur des
lignes tenues en mémoire et avec quoi répond un fake d'un port de lecture. Chaque traducteur de
store est tenu au même tableau par criteriaContract de @ddd-ts-kit/testing/contracts.
| Règle | Signification |
|---|---|
| Types | string ; number ; boolean ; instant, une Date comparée par son instant, à la milliseconde ; decimal, une chaîne de chiffres (-12.50) comparée comme un nombre sans l'arrondi d'un flottant |
eq, ne, in |
par valeur : deux instants identiques sont égaux quel que soit le fuseau dans lequel ils ont été écrits, 10.5 vaut 10.50 |
gt, gte, lt, lte |
nombres, décimaux et instants par valeur ; chaînes par point de code Unicode, si bien que les majuscules viennent avant les minuscules ('B' < 'a') ; pas sur un booléen |
between |
les deux bornes incluses |
contains, startsWith |
sur une chaîne seulement, sensibles à la casse, le texte pris littéralement : %, _ et \ sont des caractères. Une recherche qui ignore la casse lit une colonne en minuscules qu'écrit la projection |
isNull |
true garde les lignes sans valeur, false celles qui en ont une ; seulement sur un champ déclaré nullable |
| Un nul | ne satisfait jamais une comparaison, ne compris ; seul isNull le voit |
| Conditions | jointes par ET |
and, or, not |
not vaut pour les lignes qui ne satisfont pas tous ses filtres, nuls compris : la logique a deux valeurs, si bien que not(room eq 'B') garde les lignes sans salle, là où SQL seul les écarterait |
| Tri | les appels à orderBy, puis le tri de la demande de page pour les champs pas encore ordonnés ; sans aucun, le defaultSort de la définition |
| Ordre total | le départage de la définition termine chaque tri, dans la direction du dernier champ, sauf si le tri le nomme |
| Nul dans un tri | la plus grande valeur : en dernier en ordre croissant, en premier en ordre décroissant (le défaut de PostgreSQL) |
false et true |
false d'abord en ordre croissant |
| Page par décalage | la tranche et total, le nombre de lignes filtrées |
| Page par curseur | pas de total ; nextCursor quand une autre page suit. Le curseur contient les valeurs de tri de la dernière ligne, est vérifié par rapport aux types déclarés à son retour, et appartient à un seul tri |
| Pas de page | la première page de la taille par défaut |
Deux conséquences découlent de l'endroit où un store garde ses données, et sont écrites dans le
README de chaque adaptateur. Les chaînes sont ordonnées par la collation de leur colonne : la
référence est la collation "C" de PostgreSQL, si bien qu'une colonne de texte triée ou comparée
par intervalle est déclarée COLLATE "C". Un décimal se relit tel que sa colonne le formate
(10.5, ou 10.5000 pour une échelle fixe) : comparez les décimaux comme des nombres.
Le périmètre de l'acteur
Section intitulée « Le périmètre de l'acteur »scoped(criteria, accessScope, mapping) renvoie des ScopedCriteria, ce que demande un port de
lecture : une lecture ne peut l'atteindre sans périmètre. Le périmètre est tenu à part des filtres
et leur est joint par un ET, si bien que rien de ce qu'écrit un appelant, un or ou un not
compris, n'élargit ce que l'acteur peut lire ; refine change les critères et garde la
restriction.
| Périmètre | Devient |
|---|---|
{ kind: 'all' } |
rien : les critères tels quels |
{ kind: 'none' }, ou ids sans aucun id |
une page vide, sans requête |
{ kind: 'ids', ids } |
in sur le champ que nomme mapping.ids ; la liste est celle de l'autorisateur et n'est pas bornée par maxInList |
{ kind: 'filter', filter } |
un eq par entrée, sur le champ que mapping.filter nomme pour sa clé |
Un périmètre que la correspondance ne sait pas exprimer lève UnmappedReadScopeError au lieu de
lire sans la restriction, tout comme un champ mis en correspondance que la définition ne déclare
pas. Les champs d'un périmètre n'ont besoin d'aucun opérateur dans la définition : c'est le port
de lecture qui les a choisis, pas l'appelant. Le périmètre est la forme AccessScope de
@ddd-ts-kit/auth, nommée ReadScope ici pour que le noyau ne dépende d'aucun modèle
d'autorisation.
Ce que cela signifie sur quatre lignes, exécutées en mémoire avec la sémantique de référence :
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` garde la ligne sans salle, là où SQL seul l'écarteraitevaluateCriteria(bookings, rows, Criteria.of<BookingRow>().not((all) => all.where('room', 'eq', 'B')));// items : a-1, a-3, g-1
// un `or` qu'écrit l'appelant n'élargit jamais le périmètre : le périmètre lui est joint par un ETconst 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 appartient à un autre tenant
// l'appelant ne peut pas filtrer lui-même sur tenantId : le champ ne déclare aucun opérateurevaluateCriteria(bookings, rows, Criteria.of<BookingRow>().where('tenantId', 'eq', 'globex'));// lève InvalidCriteriaError : filter.tenantId (operator_not_allowed)Côté store
Section intitulée « Côté store »planCriteria renvoie un CriteriaPlan, des données simples qu'un traducteur rend et rien
d'autre : le prédicat des filtres et du périmètre, le prédicat du keyset pour une page par
curseur, l'ordre total, les lignes à charger. Les négations sont déjà poussées jusqu'aux
comparaisons, si bien qu'un traducteur rend des and, des or et des tests ; le keyset est un
prédicat comme un autre, si bien que la pagination par curseur s'écrit une seule fois.
pageOfPlan(plan, rows, total?) transforme les lignes qu'une requête a renvoyées en Page. Les
packages d'adaptateurs contiennent les traducteurs : criteriaToKnex, criteriaToDrizzle,
criteriaToPrisma, criteriaToTypeOrm.
Limites. Pas de jointure : on filtre sur un champ d'un autre contexte une fois qu'une
projection l'a copié. Pas d'agrégation, pas de recherche plein texte. Un long périmètre ids est
une longue liste IN : au-delà de quelques milliers d'identifiants, projetez dans le modèle de
lecture le droit que représente le périmètre.
Hydratation
Section intitulée « Hydratation »Compléter les lignes d'une lecture avec des données d'un autre contexte délimité, au moment de la requête, par un port gateway : pour les vues de détail et les petites pages dont les données étrangères doivent être fraîches. Une liste qui filtre ou trie selon le champ étranger a besoin d'une projection à la place.
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 est (ids: readonly string[]) => Promise<ReadonlyMap<string, T>>, une méthode du port
gateway ; l'aspect cached de @ddd-ts-kit/aspects entoure la gateway comme n'importe
quelle autre.
| Règle | |
|---|---|
| Appels | un par relation pour toute la page, avec chaque clé une seule fois ; aucun quand aucune ligne n'a de clé. Jamais un par ligne |
| Concurrence | les relations sont recherchées en même temps |
| Résultat | la ligne et les propriétés hydratées, typées ; une Page reste une Page avec le même total et le même nextCursor, une liste de lignes devient une page de celles-ci ; warnings est ajouté |
| Une ligne sans clé, ou inconnue de l'autre côté | laissée sans la propriété, qui est typée facultative |
orElse(name, fallback) |
chaque ligne a la propriété, typée obligatoire : la valeur de repli là où rien n'a été trouvé |
| Une recherche qui rejette | fait échouer toute l'hydratation (politique fail, le défaut), valeur de repli ou non |
orElse(name, fallback, { onFailure: 'fallback' }) |
les lignes prennent la valeur de repli et warnings contient { relation, code: 'lookup_failed' } : jamais un succès silencieux, jamais le message d'erreur |
timeoutMs |
la recherche est abandonnée après ce délai (HydrationTimeoutError, code d'avertissement lookup_timed_out), puis suit onFailure |
Une propriété hydratée ne peut pas être nommée par une définition de critères : FieldOf<Row>
garde les champs qui contiennent un scalaire, et le compilateur refuse le reste. Les traces
montrent un span gateway <Port>.byIds par relation, comme pour tout appel à une gateway :
l'hydratation n'ouvre elle-même aucun span.
Projections
Section intitulée « Projections »Un projecteur tient un modèle de lecture à jour à partir d'événements : les événements d'intégration des contextes amont, les événements du domaine du sien. Il écrit son modèle de lecture par un port et ne fait rien d'autre : il n'envoie aucune commande et ne lit aucun autre contexte.
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>') est une déclaration comme les autres : les deux modes de
décorateurs, ses dépendances dites par son constructeur, une classe par fichier, dans
application/projections/, dans un fichier *.projection.ts. @On(definition) sur une méthode
dit « cette méthode applique cet événement d'intégration » ; @On(EventClass) le dit pour un
événement du domaine du contexte propre de la projection, sous le nom que sa classe déclare une
fois, static readonly eventName. Une méthode qui prend un autre événement est refusée par le
compilateur, une projection sans aucun @On et un @On sur une classe sans @Projection
lèvent au chargement du fichier. Une méthode renvoie ce qu'a renvoyé son store, ce qui permet à l'application de compter ce qui a été appliqué et ce qui était périmé.
Un rôle de la CLI. application/projections/ est un répertoire de l'arborescence canonique,
ddd-ts-kit generate projection <context> <Name> <event-type> écrit un projecteur avec sa spec,
le barrel généré <context>.declarations.ts l'exporte et enregistre ce que prend son
constructeur, et ddd-ts-kit describe et la référence de CONTEXT.md le listent avec les
événements qu'il applique.
Idempotentes et ordonnées par construction
Section intitulée « Idempotentes et ordonnées par construction »ReadModelStore<Row> est le port d'un modèle de lecture : get, upsertIfNewer, delete et
search(scopedCriteria). Chaque ligne porte le tampon de l'événement qui l'a modifiée en dernier,
et une écriture n'a lieu que si son événement est plus récent :
| Règle | |
|---|---|
| Tampon | stampOf(event) : l'id et l'instant d'un événement d'intégration ; le nom, l'agrégat et l'instant d'un événement du domaine ; plus stampOf(event, version) quand l'événement porte une version par clé |
| Plus récent | une version plus grande quand les deux tampons en portent une ; sinon un instant plus tardif ; au même instant, le plus grand id d'événement. Un même événement n'est jamais plus récent que lui-même |
upsertIfNewer(key, stamp, next) |
écrit next(current) ; stale quand la ligne contient cet événement ou un plus tardif ; ignored quand next renvoie undefined (un correctif d'une ligne qui n'existe pas) |
delete(key, stamp) |
la ligne disparaît pour get et search, et son tampon reste : un événement plus ancien livré en retard ne la ramène pas, un plus tardif oui |
| Concurrence | une écriture est conditionnée au tampon qu'elle a lu et est relue quand une autre est arrivée d'abord ; cinq tentatives, puis ConcurrencyError et la nouvelle livraison de l'événement |
| Transaction | le store écrit sur l'exécuteur de l'unité de travail, si bien que la ligne et la marque d'événement traité sont validées ou annulées ensemble |
Ainsi une nouvelle livraison, un doublon et deux événements d'une même clé livrés dans le désordre aboutissent tous à la même ligne. Ce que cela ne donne pas : un événement plus ancien écarté comme périmé n'est pas appliqué plus tard, si bien qu'un événement devrait porter l'état des champs qu'il pose, ou arriver dans l'ordre par clé (l'outbox ordonne par clé) ; une suppression qui atteint une ligne jamais vue ne laisse aucune trace ; avec des instants et sans version, l'ordre de deux événements d'une même clé est l'ordre des horloges qui les ont tamponnés ; une version est comparée à la version que contient la ligne, alors n'en passez une que si chaque événement d'une ligne vient du même flux. Une écriture vaut une ligne : un événement qui concerne de nombreuses lignes les liste avec des critères et écrit chacune.
TableReadModelStore est l'unique implémentation du port, sur une ReadModelTable que donne un
adaptateur (trois instructions et la requête d'un plan) : InMemoryReadModelStore ici pour les
fakes, KnexReadModelStore, DrizzleReadModelStore, PrismaReadModelStore et
TypeOrmReadModelStore dans les packages d'adaptateurs, tous tenus à readModelStoreContract.
Reconstruction
Section intitulée « Reconstruction »Un modèle de lecture alimenté par des événements qu'aucun event store ne garde se reconstruit à partir de la source de vérité, le contrat de lecture que publie le contexte propriétaire, derrière un port gateway qui renvoie des lignes tamponnées. Un modèle de lecture d'agrégats en event sourcing se reconstruit plutôt à partir du store : Alimentée par l'event store.
await new ProjectionRebuild({ table: appointments.table, source, unitOfWork }).run();// source.export(cursor) : une page de { key, row, stamp } et son nextCursor// source.lookup(keys) : les lignes de ces clés telles qu'elles sont maintenant| Étape | Ce qui se passe |
|---|---|
| 1 | une copie vide de la table est ouverte, avec ses index et ses contraintes et les traces des lignes supprimées ; dès lors, chaque écriture de la projection enregistre sa clé avant d'écrire |
| 2 | l'export est copié, page par page, dans la copie. Les événements continuent d'être appliqués à la table, qui continue de répondre aux lectures |
| 3 | les clés enregistrées sont relues depuis la source, par tours, jusqu'à ce qu'il en reste peu |
| 4 | en une transaction : les écrivains sont retenus, les dernières clés enregistrées sont relues, la copie prend le nom de la table, l'ancienne table est supprimée |
La copie est remplie à partir de la source seule, jamais de la table qu'elle remplace, si bien
qu'une reconstruction répare les lignes que la projection avait faussées. Aucun événement n'est
perdu : un événement est livré après que sa source l'a validé, si bien que soit l'export a lu
sa ligne après, soit sa clé a été enregistrée et relue, soit il est appliqué après l'échange à
une ligne dont le tampon est plus ancien. Aucun événement n'est appliqué deux fois : un
événement que la source reflétait déjà est stale face au tampon avec lequel sa ligne est venue.
Les deux reposent sur ce que la source dit de chaque ligne : le tampon du dernier événement que
son état reflète. Avec l'id et l'instant de cet événement, ou une version par clé, la garantie
est exacte ; avec un updatedAt seul, un événement du même instant exactement est appliqué une
fois de plus sur l'état qui le contient déjà, ce qui est sans conséquence pour un événement qui
pose un état et faux pour un événement qui compte.
La fenêtre. Les lectures ne sont jamais interrompues jusqu'à l'étape 4. Les écrivains
attendent pendant l'étape 4, le temps de relire les dernières clés (au plus settledBelow, 50
par défaut, sauf si la table change plus vite que maxRounds tours ne peuvent suivre) et de
renommer deux tables. Une écriture qui avait commencé juste avant échoue et est relivrée. Une
reconstruction qui échoue supprime sa copie et laisse la table telle qu'elle était. Sur
PostgreSQL, la copie est faite avec LIKE ... INCLUDING ALL : les privilèges accordés sur la
table par son nom, les politiques de sécurité des lignes et les déclencheurs ne sont pas copiés
et sont restaurés par la migration qui suit, et les index de la copie gardent les noms que
PostgreSQL leur a dérivés.
Alimentée par l'event store
Section intitulée « Alimentée par l'event store »Une projection d'agrégats en event sourcing peut être alimentée par le store plutôt que là où
les événements du domaine sont distribués :
@Projection('scheduling.attendance-history', { feed: 'event-store' }). Le distributeur ne
l'appelle alors jamais (@ddd-ts-kit/di n'enregistre aucun gestionnaire pour elle) ; un
abonnement de rattrapage le fait, sous le nom de la projection, et la même projection reconstruit
son modèle de lecture depuis la position zéro.
const apply = applyingProjection(projection, serializer); // les méthodes @On, par nom d'événement
const subscription = new CatchUpSubscription({ name: 'scheduling.attendance-history', store, checkpoints, unitOfWork, apply });
await subscription.catchUp(); // ou poll() depuis un job : un lot, une unité de travail
await new EventStoreProjectionRebuild({ table: history.table, store, unitOfWork, applierOn: (copy) => applyingProjection(new AttendanceHistoryProjection(copy), serializer),}).run();| Pièce | Ce qu'elle fait |
|---|---|
CatchUpSubscription |
Lit le store après le point de reprise de son abonné (CheckpointStore), applique un lot et déplace le point de reprise en une unité de travail. Un échec n'applique rien du lot ; deux exécutions d'un même abonné ne le déplacent jamais toutes les deux (ConcurrencyError). Une nouvelle projection commence à zéro, si bien qu'elle rattrape tout l'historique |
applyingProjection(projection, serializer) |
(@ddd-ts-kit/kernel/declarations) remet à la méthode @On qui l'applique chaque événement stocké, relu par le sérialiseur de son agrégat ; passe sur le reste du store |
storedStampOf(event) |
Le tampon d'un événement lu depuis le store : son id, son instant et sa version dans son flux, si bien que les lignes d'un même flux sont ordonnées par le flux, pas par les horloges. undefined pour un événement qui ne vient pas d'un store |
EventStoreProjectionRebuild |
Remplit une copie vide de la table en rejouant le store depuis la position zéro à travers la projection construite sur la copie (ShadowReadModelStore) ; puis, en une transaction, retient les écrivains, rejoue ce qui a été ajouté entre-temps et met la copie à la place de la table. L'abonnement continue depuis son point de reprise ; ce qu'il applique à nouveau est périmé face à la copie. Une reconstruction échouée laisse la table telle qu'elle était |
Une projection alimentée par le store n'applique que les événements du domaine de son contexte :
un événement d'intégration atteint une projection là où il est consommé, et @On(definition) sur
une telle projection lève au chargement du fichier.
Vue de l'extérieur
Section intitulée « Vue de l'extérieur »Par les ports qui existent. @ddd-ts-kit/di compte projection_events_total par context,
projection, event et result (applied, stale, ignored, failed) sur Metrics, et
pose projection_last_event_age_ms, l'âge du dernier événement appliqué au moment où il l'a été :
le retard. Une projection est abonnée comme consommateur sous son nom, si bien que chaque
livraison est un span process <projection>, avec les appels à son modèle de lecture en
dessous, et qu'un événement enterré après ses nouvelles tentatives atteint le rapporteur
d'erreurs comme pour tout consommateur.
Event sourcing
Section intitulée « Event sourcing »Choisi par agrégat, jamais par projet : les autres agrégats du contexte gardent leur état dans des tables. Quand le choisir et ce qu'il coûte : docs/concepts/event-sourcing.fr.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 }; }}| Pièce | Ce qu'elle fait |
|---|---|
EventSourcedAggregate<E, S> |
Une commande vérifie ses invariants sur l'état, puis record(event) : l'événement est appliqué par la méthode que appliers() lui associe, et gardé pour l'enregistrement. Seules les méthodes d'application (et restore, pour un snapshot) changent l'état (event-sourcing/state-in-apply). appliers() doit nommer chaque événement de l'union, sinon cela ne compile pas |
replay(history, restore?) |
Reconstruit à partir de { snapshot?, events }, par les mêmes méthodes d'application, sans rien enregistrer. version est le nombre d'événements du flux que l'agrégat reflète |
appendChanges(aggregate, append) |
Ce qu'appelle un adaptateur de repository : les événements enregistrés depuis le chargement et la version que le flux doit encore avoir ; en cas de succès l'agrégat est à la nouvelle version |
EventStore |
Le port : append(stream, expectedVersion, events) (une ConcurrencyError quand quelqu'un a ajouté d'abord, rien n'en est gardé), readStream(stream, after?), readAll(after, limit) dans l'ordre où les ajouts ont été validés, head(). InMemoryEventStore ici ; PostgreSQL par chaque adaptateur ORM. Tenu à eventStoreContract |
defineStoredEvent(Event, spec) |
Comment un événement est stocké : version de sa forme, encode(event): NamedData (données simples, dates gardées en texte ISO), decode(data, envelope) qui lit chaque champ par type (data.text('x'), data.instant('at'), data.record('amount')), et des upcasters depuis chaque version antérieure |
EventSerializer<E> |
Chaque événement stocké d'un agrégat (StoredEventTypes<E> : une entrée par événement, sinon cela ne compile pas). Un événement stocké n'est jamais réécrit : il passe par un upcaster à la lecture. Un événement d'une forme plus récente que ce que connaît la version publiée, ou une version sans upcaster, est une UnreadableEventError |
EventSourcedRepository |
Ce à quoi délègue l'adaptateur de repository : findById rejoue le flux <category>-<id> (depuis le dernier snapshot), save ajoute dans la transaction de l'unité de travail ; les événements partent ensuite avec l'enregistrement comme n'importe quels autres |
SnapshotPolicy |
Par agrégat : every tant d'événements, une version de la forme (une autre est ignorée, le flux rejoué), encode et decode. expectSnapshotEquivalence de @ddd-ts-kit/testing prouve qu'un snapshot reconstruit l'état que donne un rejeu complet |
personalData(subject, value) |
Marque une valeur personnelle dans encode. PersonalDataCipher sur un SubjectKeyStore la scelle avec la clé de sa personne concernée (AES-256-GCM) ; SubjectKeyStore.erase(subject) détruit la clé, et un rejeu lit alors 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); }}Pas de double écriture. Le store rejoint la transaction de l'unité de travail, et le
publisher des événements d'intégration est un gestionnaire des événements du domaine qui
s'exécute avant la validation et écrit l'outbox : le flux et les enregistrements de l'outbox
sont validés ensemble ou pas du tout. eventStoreUnitOfWorkContract de @ddd-ts-kit/testing le
prouve pour chaque store transactionnel : rien n'est publié pour un ajout annulé ou refusé par un
conflit de concurrence.
Comment étendre
Section intitulée « Comment étendre »Session, ExecutionContext, Actor, AuditEntry et ErrorReport ont chacun une interface
vide qu'un projet remplit par fusion de déclarations, une fois, dans un fichier de déclarations
de sa plateforme :
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 }); // erreur de compilation : orderId est une stringextensions, pas des champs de premier niveau. Un champ du projet vit sousextensions, si bien qu'un champ standard ajouté àSessionou àExecutionContextdans une version ultérieure ne peut jamais entrer en collision avec lui.- Facultatives à la lecture.
anonymousSession(),systemSession()etexecutionContext()ne portent aucune extension, et ils doivent rester véridiques dans chaque projet : une extension se lit commeT | undefined. Actorgarde son sac. Les autorisateurs lisent déjàactor.attributespar nom, et les acteurs voyagent dans les messages :ActorExtensionsdonne aux attributs connus un nom et un type à l'intérieur de ce sac au lieu d'en ajouter un second. Une extension qui n'est pas unActorAttribute(une chaîne, un nombre, un booléen ou une liste de chaînes) se lit commenever.AuditEntryExtensionscontient du JSON. Une entrée est hachée et stockée telle quelle : une extension qui n'est pas du JSON simple (uneDate, une classe) se lit commenever.LogFieldsetMetricLabelsacceptent de nouvelles clés bien connues de la même manière ; les clés inconnues restent permises.- Rien à enregistrer. L'augmentation ne porte que sur les types. Les adaptateurs du kit continuent de compiler : ils ne lisent jamais une extension de projet.