ddd-ts-kit
Amorcez, générez, documentez et vérifiez des projets en Domain-Driven Design et en Clean
Architecture, en TypeScript. Un seul binaire : init crée le projet, generate écrit chaque
morceau de code répétitif à sa place canonique, docs tient à jour la documentation
d'architecture, describe, status et todo relisent le projet sous forme de faits, check
prouve que le code suit toujours docs/ARCHITECTURE.fr.md.
init installe aussi le pipeline de qualité : ESLint avec @ddd-ts-kit/eslint-plugin,
dependency-cruiser, knip, des seuils de couverture, des hooks git et la CI de GitHub ou de GitLab.
Le générateur et le checker partagent une seule définition de l'arbre canonique, et un test génère chaque genre de pièce pour chaque stack en exigeant zéro violation : ils ne peuvent pas être en désaccord.
Les principales commandes se rangent en deux familles. La première écrit le projet : init une fois,
generate pour chaque pièce, docs, sdk et depcruise-config pour ce qui dérive du code et de
la configuration, et trois migrations qui font passer un projet d'une forme ancienne à la forme
actuelle. La seconde relit le projet sans le modifier : check le juge, list et describe
disent ce qu'il contient, status et todo disent ce qui reste à faire, affected dit ce
qu'une pull request doit exécuter.
Aucune dépendance d'exécution. Node 22.12 ou ultérieur.
npx ddd-ts-kit initnpx ddd-ts-kit generate context billing --classification core --term "Invoice=A request for payment"npx ddd-ts-kit generate aggregate billing Invoicenpx ddd-ts-kit generate command billing IssueInvoice --aggregate Invoice --permission invoice.issue --grant accountant --term "Issue=To make an invoice final"npx ddd-ts-kit generate route billing IssueInvoice --method POST --path /invoicesnpx ddd-ts-kit checkinit se termine en disant quoi lancer ensuite : pnpm install, puis pnpm run verify
(npm install, puis npm run verify avec --package-manager npm), puis les étapes recommandées
de la méthode, dans l'ordre (méthodologie) ;
quelle commande, quand donne la commande de chaque moment d'un
projet et la référence de chaque option. Un nouveau projet passe toute cette porte avant que quoi
que ce soit soit généré, sans contexte, avec un et avec plusieurs : la suite de bout en bout
l'installe pour de vrai et l'exécute, avec les deux gestionnaires de paquets et sur chaque stack
éprouvée. Après l'installation, la CLI est celle que le projet épingle :
pnpm exec ddd-ts-kit <command>, ou npx ddd-ts-kit <command>.
Chaque commande accepte --json (sortie lisible par une machine, erreurs comprises) et
--cwd <dir>. Les commandes autres que init cherchent ddd-ts-kit.config.json dans le
répertoire et ses parents. Codes de sortie : 0 succès, 1 violations, documentation périmée
ou, sous --strict, un commit au-delà de ses limites, 2 mauvais usage (le message dit quoi
faire).
ddd-ts-kit init [--http express|fastify|hono|nest|none] [--validation zod|class-validator|typebox|standard-schema|none] [--di tsyringe|nest|manual] [--tests vitest|jest] [--orm drizzle|knex|prisma|typeorm|custom|none] [--database postgres|mysql|sqlite|none] [--events in-memory|outbox] [--docs-ui swagger|scalar|redoc|none] [--ci github|gitlab|both|none] [--ci-extras <extra>,...|none] [--authn jwt|keycloak|none] [--authz rbac|abac|rebac|custom] [--broker none|rabbitmq|kafka|nats|redis|custom] [--cache none|redis] [--http-cache none|varnish] [--storage local|s3|none] [--audit on|off] [--errors none|sentry] [--logger stdout|pino|winston] [--style declared|explicit] [--assembly barrel|discovery] [--dependencies signature|listed] [--refusals factories|classes] [--routes controllers|functions] [--value-objects throwing|result] [--literals named|free] [--values classes|free] [--tenancy none|shared-table] [--queue database|redis|memory] [--schemas shared|per-route] [--package-manager npm|pnpm] [--allow-no-outbox "<reason>"] [--import-alias '$<name>/'] [--import-extension none|js] [--sdk] [--context <name>:<core|supporting|generic>]... [--preset identity]... [--dry-run] [--force]Fonctionne dans un répertoire vide comme dans un projet existant. Un fichier existant n'est
jamais remplacé sans --force ; il est signalé comme ignoré. init écrit aussi les contrats
générés tels que les scripts du projet les écrivent pour un projet qui n'a encore ni route ni
événement d'intégration à lui (openapi/openapi.yaml, docs/integration/asyncapi.yaml et
event-catalog.md ; en JSON sous "openapi": { "format": "json" } de la configuration), afin
que le premier commit les contienne et que docs:check passe d'emblée ; un contrat déjà
présent est laissé tel quel, --force compris. Un package.json existant est fusionné : les
scripts et les dépendances qui manquent sont ajoutés, les vôtres sont gardés et mentionnés dans
les notes. Relancé plus tard, init garde la stack enregistrée dans la configuration.
init --force sur un projet déjà initialisé réécrit les fichiers qui appartiennent à init et
garde la configuration : les contextes et leurs amonts, les règles, les limites, les dérogations
et toute autre clé restent tels qu'ils sont écrits, et la stack ne change que là où une option
nomme un choix (init --force --logger pino change le logger ; une combinaison que la stack
enregistrée ne peut pas accepter est refusée comme pour un nouveau projet). La racine de
composition, la preuve que chaque contexte se câble seul et le registre des permissions sont
réécrits depuis leurs templates, puis les contextes déclarés y sont câblés de nouveau ; ce
qu'un autre générateur avait ajouté à un fichier de la plateforme (la valeur d'une gateway, les
réglages d'un job) ne l'est pas, et une note le dit. Une configuration illisible est remplacée.
Le pipeline n'utilise que des outils gratuits. --ci-extras ajoute des jobs qui demandent
une offre payante sur un dépôt privé, ou qui ne remontent dans une merge request qu'avec une
offre payante : codeql, dependency-review (GitHub), gitlab-sast, gitlab-secret-detection,
gitlab-dependency-scanning (GitLab). La liste est enregistrée comme ci.extras dans la
configuration ; un nom inconnu, ou un job d'une plateforme que --ci ne couvre pas, est
refusé. Le tableau de chaque outil et de ses conditions :
pipeline/README.md.
Un import d'un fichier du projet s'écrit sans extension
(@/contexts/billing/domain/aggregates/invoice.aggregate) : init enregistre
"importExtension": "none", et --import-extension js demande à la place le .js du fichier
émis. Une configuration sans cette clé se lit comme js, si bien qu'un projet généré avant
l'existence de la clé garde son écriture. Voir docs/CONFIG.fr.md.
Valeurs par défaut : express, zod, tsyringe (nest avec --http nest, et seulement
alors), vitest, drizzle sur postgres, outbox quand il y a un ORM, swagger, github,
jwt, rbac, aucun broker, le cache dans le processus, les fichiers dans un répertoire local
(--storage local), le journal d'audit on quand il y a un ORM, les échecs inattendus remontés
nulle part (--errors none), des lignes JSON sur la sortie standard (--logger stdout), et le
style declared avec un barrel partout où les déclarations sont lues : avec --di tsyringe.
Gestionnaire de paquets. pnpm sauf --package-manager npm : chaque script, pipeline,
hook, le Dockerfile, les consignes et les permissions des agents sont écrits dans le dialecte
de celui qui est choisi, enregistré sous stack.packageManager. pnpm est épinglé dans
package.json et fourni par corepack ; pnpm-workspace.yaml dit qu'aucune dépendance n'exécute
de script de build sans être autorisée par son nom. Les deux sont prouvés par une installation
réelle d'un projet généré, son build et son verify. Le kit est installé depuis le registre dans
les deux cas.
Pipelines. Une pull request ou une merge request exécute les specs unitaires, les
specs d'intégration et le lint des contextes que son changement atteint par la carte des
contextes (ddd-ts-kit affected) ; les types, l'architecture, le graphe des
imports, le code mort, le bout en bout, le build et les documents tournent toujours sur le projet
entier, et la branche par défaut et l'exécution nocturne planifiée exécutent tout.
Deux styles. Dans le style declared, chaque classe dit ce qu'elle est par un décorateur de
@ddd-ts-kit/kernel/declarations (@Requires ou @Public sur une commande ou une requête,
@CommandHandler, @QueryHandler, @IntegrationEventHandler, @DomainEventHandler, @Scheduled,
@Policy, @Repository, @Gateway, @Adapter), un contexte n'a pas de <context>.module.ts, et
la racine de composition l'assemble en une ligne. Dans le style explicit, chaque liaison est
écrite dans le fichier de module du contexte : builder.bind(Port).toClass(Adapter, [Ports]),
builder.command(Message).requires(...).handledBy(Handler, [Ports]), builder.job(name, schedule).
--style declared est refusé avec --di manual et --di nest : les déclarations sont de simples
métadonnées, et seul @ddd-ts-kit/di (sur tsyringe) les transforme en liaisons.
--assembly dit comment un contexte déclaré rejoint la racine de composition : barrel (par
défaut) est un fichier généré par contexte, <context>.declarations.ts, statique, que suivent un
bundler, knip et dependency-cruiser ; discovery écrit à la place await discoverContext(...),
avec l'entrée knip et l'exception dependency-cruiser dont les fichiers découverts ont besoin
(publishers et jobs compris : ils sont découverts comme le reste), et intègre
@ddd-ts-kit/di en ligne (inline) dans les deux configurations vitest, afin que les fichiers
qu'il importe lui-même soient compilés par l'exécuteur des tests. La découverte est acceptée
derrière un broker : un contexte écoute sur l'abonné que EventSubscribers.forContext(name)
lui remet, dans l'un ou l'autre assemblage. Une configuration écrite avant l'existence de
stack.style est explicite.
Le même cas d'usage dans les deux styles. Dans le style déclaré, la commande et son gestionnaire
(handler) portent leur décorateur, et le constructeur du gestionnaire dit ce dont il a besoin. Le
corps du gestionnaire est celui qu'écrit
generate command billing IssueInvoice --aggregate Invoice --permission invoice.issue, avant
que son TODO(domain) soit traité ; l'entrée de la commande est remplie :
import { definePermissions } from '@ddd-ts-kit/auth';import { type Actor, AggregateRoot, AuthorizedCommand, type Clock, type IdGenerator, Repository } from '@ddd-ts-kit/kernel';import { CommandHandler, Requires } from '@ddd-ts-kit/kernel/declarations';
declare class Invoice extends AggregateRoot { static create(id: string, now: Date): Invoice;}
// domain/repositories/invoice.repository.tsexport abstract class InvoiceRepository extends Repository<Invoice> {}
// application/authorization/billing.permissions.tsexport const billingPermissions = definePermissions('billing', { 'invoice.issue': { description: 'Issue invoice' },});
// application/commands/issue-invoice/issue-invoice.command.ts@Requires(billingPermissions['invoice.issue'])export class IssueInvoiceCommand extends AuthorizedCommand<string> { constructor( actor: Actor, readonly customerId: string, ) { super(actor); }}
// application/commands/issue-invoice/issue-invoice.handler.ts@CommandHandler(IssueInvoiceCommand)export class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> { // chaque paramètre est typé par une classe, qui est aussi son jeton d'injection constructor( private readonly invoiceRepository: InvoiceRepository, private readonly ids: IdGenerator, private readonly clock: Clock, ) {}
async execute(command: IssueInvoiceCommand): Promise<string> { const invoice = Invoice.create(this.ids.next(), this.clock.now());
await this.invoiceRepository.save(invoice);
return invoice.id; }}Dans le style explicite, les mêmes classes ne portent aucun décorateur, et le fichier de module du contexte lie chacune d'elles, avec sa permission et les ports qu'elle reçoit :
import { definePermissions } from '@ddd-ts-kit/auth';import { defineContextModule } from '@ddd-ts-kit/di';import { AggregateRoot, AuthorizedCommand, Clock, type CommandHandler, IdGenerator, Repository } from '@ddd-ts-kit/kernel';
// les classes de l'exemple déclaré, sans leurs décorateursdeclare class Invoice extends AggregateRoot {}declare class IssueInvoiceCommand extends AuthorizedCommand<string> {}declare class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> { constructor(invoiceRepository: InvoiceRepository, ids: IdGenerator, clock: Clock); execute(command: IssueInvoiceCommand): Promise<string>;}declare class InMemoryInvoiceRepository implements InvoiceRepository { findById(id: string): Promise<Invoice | undefined>; save(invoice: Invoice): Promise<void>;}
export abstract class InvoiceRepository extends Repository<Invoice> {}
export const billingPermissions = definePermissions('billing', { 'invoice.issue': { description: 'Issue invoice' },});
// billing.module.ts : chaque liaison du contexte, une ligne chacuneexport const billingModule = defineContextModule({ name: 'billing', register(builder) { builder.bind(InvoiceRepository).toClass(InMemoryInvoiceRepository).as('repository'); builder .command(IssueInvoiceCommand) .requires(billingPermissions['invoice.issue']) .handledBy(IssueInvoiceHandler, [InvoiceRepository, IdGenerator, Clock]); },});check tient les deux formes : authz/declared signale une commande qui ne dit ni qui peut
l'envoyer ni qu'elle est publique, declarations/role-declared un fichier de rôle du style
déclaré sans son décorateur.
Un port est une classe abstraite, et son propre jeton d'injection.
application/ports/<name>.port.ts et domain/repositories/<name>.repository.ts contiennent
chacun une classe abstraite. Le code généré ne contient aucun token(), aucune constante
d'injection en UPPER_SNAKE, aucun <context>.ports.ts et aucun platform/tokens.ts : un
paramètre de constructeur typé par une classe de port nomme ce qui est injecté
(private readonly clock: Clock), les ports du kernel sont importés comme valeurs partout où ils
sont nommés dans une liste ou une liaison, et comme types là où ils ne font que typer un
paramètre, un adaptateur implements son port.
Au lieu d'une interface, d'une constante de jeton et d'une injection par nom :
export interface IPaymentGateway { charge(invoiceId: string, amountInCents: number): Promise<void>;}
export const PAYMENT_GATEWAY = Symbol('PaymentGateway'); // ports/no-token-constant
constructor(@inject(PAYMENT_GATEWAY) private readonly payments: IPaymentGateway) {}écrivez une seule classe abstraite, qui est à la fois le contrat et le jeton :
import { Adapter } from '@ddd-ts-kit/kernel/declarations';
export abstract class PaymentGateway { abstract charge(invoiceId: string, amountInCents: number): Promise<void>;}
// infrastructure/external/remote-payment-gateway.adapter.ts : implémente le port, ne l'étend jamais@Adapter(PaymentGateway, { kind: 'external' })export class RemotePaymentGateway implements PaymentGateway { async charge(invoiceId: string, amountInCents: number): Promise<void> { // TODO(platform): appeler le prestataire de paiement avec invoiceId et amountInCents }}
// un gestionnaire nomme le port par le type de son paramètre : rien d'autre ne dit ce qui est injectéexport class SettleInvoiceHandler { constructor(private readonly payments: PaymentGateway) {}
async settle(invoiceId: string, amountInCents: number): Promise<void> { await this.payments.charge(invoiceId, amountInCents); }}ports/abstract-class, ports/implements-never-extends, ports/no-token-constant et
ports/value-import tiennent cette forme, dans check et dans ESLint.
Le constructeur énonce les dépendances. Dans le style déclaré, une classe générée ne liste
rien dans son décorateur (@CommandHandler(IssueInvoiceCommand)) : chaque paramètre du
constructeur est typé par une classe, son jeton (declarations/constructor-injection).
generate, docs et le script dev lisent les signatures, avec le scanner sans dépendance du
checker et sans compilateur, et écrivent des instructions injected(Class, [Tokens]) dans le
fichier généré de chaque contexte : le barrel <context>.declarations.ts, ou
<context>.dependencies.ts sous discovery. @ddd-ts-kit/di injecte à partir de là. Rien ne
repose sur emitDecoratorMetadata : il est absent avec les décorateurs standard et sous tsx.
init écrit "stack.dependencies": "signature" ; une configuration sans la clé vaut listed,
la forme de 0.1 (@CommandHandler(M, [Ports])), qui continue de fonctionner sans changement, et
--dependencies listed la demande. Le style explicite reste explicite : son fichier de module
liste ce que reçoit chaque classe. Chaque fichier généré exporte une classe, une interface ou une
fonction, et porte son nom.
Un extrait du barrel du contexte notification de l'application d'exemple, tel que la CLI
l'écrit (jamais à la main) :
import { injected } from '@ddd-ts-kit/kernel/declarations';import { WithdrawReminderHandler } from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.handler';import { ReminderRepository } from '@/contexts/notification/domain/repositories/reminder.repository';import { KnexReminderRepository } from '@/contexts/notification/infrastructure/persistence/knex-reminder.repository';import { Clock, ExecutionScope } from '@ddd-ts-kit/kernel';import { KnexContext } from '@ddd-ts-kit/knex';
export * from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.command';export * from '@/contexts/notification/application/commands/withdraw-reminder/withdraw-reminder.handler';export * from '@/contexts/notification/infrastructure/persistence/knex-reminder.repository';
injected(WithdrawReminderHandler, [ReminderRepository]);injected(KnexReminderRepository, [KnexContext, ExecutionScope, Clock]);et l'unique ligne de la racine de composition qui ajoute tout le contexte :
contextModule('notification', notificationDeclarations) dans src/platform/compose-application.ts.
Les erreurs du domaine sont construites par des fabriques (factories). init écrit
"stack.refusals": "factories" : un contexte a une classe d'erreur par concept, qui étend
ConceptError du kernel, et generate error <context> <Concept> <refusal> y ajoute une fabrique
statique. Les règles errors/* tiennent cette forme. --refusals classes, ou une configuration
sans la clé, garde une classe par erreur construite avec new, comme 0.1 et 0.2 les écrivaient ;
error-factories fait passer un projet de l'une à l'autre.
L'ancienne forme, sous "stack.refusals": "classes" : une classe par erreur.
import { DomainError } from '@ddd-ts-kit/kernel';
export class SlotTakenError extends DomainError { readonly code = 'SLOT_TAKEN';
constructor(readonly calendarId: string) { super(`The slot of calendar ${calendarId} is already held.`); }}
const refusal = new SlotTakenError('calendar-1');La forme qu'écrit init : une classe par concept du glossaire, une fabrique par refus, que
generate error scheduling Appointment slotTaken ajoute.
import { ConceptError } from '@ddd-ts-kit/kernel';
export class AppointmentError extends ConceptError { static slotTaken(calendarId: string) { return AppointmentError.refusal('SLOT_TAKEN', `The slot of calendar ${calendarId} is already held.`, { calendarId }); }}
const refusal = AppointmentError.slotTaken('calendar-1');
// ce qui l'attrape reconnaît le refus à sa classe et à son code, et lit ses faitsif (AppointmentError.is(refusal, 'SLOT_TAKEN')) { console.log(refusal.facts.calendarId);}Une route est une classe contrôleur. init écrit "stack.routes": "controllers" : chaque
route est une classe à une seule méthode, dans un dossier à elle, derrière les gardes de son
groupe de routes, et un contexte déclaré ne liste aucune route : ses contrôleurs rejoignent
l'application par son fichier généré. --routes functions, ou une configuration sans la clé,
garde une fonction defineRoute par route, listée dans <context>.routes.ts, comme 0.1 à 0.3
les écrivaient ; controllers fait passer un projet de l'une à l'autre, un contexte à la fois.
Une forme se déclare une seule fois. init écrit "stack.schemas": "shared" : une route de
commande répond son identifiant avec acceptedSchema de @ddd-ts-kit/http, un seul composant
Accepted dans le contrat, et generate schema <context> <Name> (ou
schema shared-kernel <Name>) écrit une forme qu'utilisent plusieurs routes, que le contrat
documente une fois et à laquelle chaque route renvoie. Les règles schema/* tiennent cette
forme. --schemas per-route, ou une configuration sans la clé, garde un schéma par route,
réponse d'identifiant comprise, comme 0.1 à 0.5 les écrivaient.
Un objet-valeur lève ses refus. init écrit "stack.valueObjects": "throwing" :
generate value-object écrit un create qui renvoie l'objet-valeur ou lève le refus de son
concept, et une spec qui le vérifie avec expectRefusal(() => ..). --value-objects result,
ou une configuration sans la clé, garde un create qui renvoie un Result que l'appelant
déballe, comme 0.1 à 0.5 l'écrivaient ; value-objects fait passer un projet de l'un à l'autre.
Les événements du domaine partent avec la sauvegarde. Un gestionnaire de commande généré
construit ou charge son agrégat, appelle une méthode d'intention, await repository.save(aggregate)
et répond : pas de pullEvents(), et pas de DomainEventDispatcher dans un gestionnaire ni dans
un adaptateur de repository, qui ne fait que persister. Les événements que l'agrégat a
enregistrés sont distribués une fois la sauvegarde résolue, dans l'unité de travail de la
commande : par le module du kernel sur tsyringe, par dispatchingOnSave autour de chaque
repository dans platform/create-platform.ts en câblage manuel
(domain-events/dispatched-on-save).
Au lieu de tirer les événements et de les distribuer dans le gestionnaire :
async execute(command: IssueInvoiceCommand): Promise<void> { const invoice = await this.invoiceRepository.findById(command.invoiceId);
invoice.issue(this.clock.now()); await this.invoiceRepository.save(invoice); await this.dispatcher.dispatch(invoice.pullEvents()); // domain-events/dispatched-on-save}écrivez la sauvegarde, et rien après elle :
import { type Actor, AggregateRoot, AuthorizedCommand, type Clock, type CommandHandler, ConceptError, Repository } from '@ddd-ts-kit/kernel';
declare class Invoice extends AggregateRoot { /** Rend la facture définitive et enregistre InvoiceIssued, qui attend dans l'agrégat. */ issue(now: Date): void;}
declare class InvoiceError extends ConceptError { static notFound(invoiceId: string): InvoiceError;}
export abstract class InvoiceRepository extends Repository<Invoice> {}
export class IssueInvoiceCommand extends AuthorizedCommand<void> { constructor( actor: Actor, readonly invoiceId: string, ) { super(actor); }}
export class IssueInvoiceHandler implements CommandHandler<IssueInvoiceCommand> { constructor( private readonly invoiceRepository: InvoiceRepository, private readonly clock: Clock, ) {}
async execute(command: IssueInvoiceCommand): Promise<void> { const invoice = await this.invoiceRepository.findById(command.invoiceId);
if (invoice === undefined) { throw InvoiceError.notFound(command.invoiceId); }
invoice.issue(this.clock.now());
// InvoiceIssued est distribué une fois cette sauvegarde résolue, dans l'unité de travail de la commande await this.invoiceRepository.save(invoice); }}--events in-memory avec un ORM est refusé sauf si --allow-no-outbox "<reason>" est donné :
il enregistre une entrée deviations (stack.events) et écrit l'enregistrement de décision vers
lequel elle pointe. La persistance générée des quatre ORM est écrite pour PostgreSQL ; custom
démarre sur des adaptateurs en mémoire tenus aux suites de contrat, à vous de les remplacer. Les
choix sont enregistrés sous stack dans la configuration, docsUi, ci et logger compris.
| Écrit | Contenu |
|---|---|
ddd-ts-kit.config.json |
stack, contextes, sévérités des règles (docs/CONFIG.fr.md) |
package.json |
scripts et dépendances de la stack (ci-dessous) ; build est le compilateur, puis ddd-ts-kit build-aliases |
tsconfig.json, tsconfig.build.json |
strict, NodeNext, rootDir . et outDir dist, et paths : l'unique déclaration de l'alias d'import (@/* vers ./src/*, $test/*, $scripts/*, et $generated/* avec Prisma), dérivée de importAlias (--import-alias '$<name>/', @/ par défaut ; #<name>/ et @<name>/ sont refusés avec la raison) ; la configuration de build étend l'autre et ne déclare pas de paths ; experimentalDecorators (et emitDecoratorMetadata) seulement pour nest ou class-validator, dont les décorateurs sont de l'ancienne forme (legacy) : les déclarations et les aspects servent les deux modes |
src/main.ts |
racine de composition : le traçage d'abord (la remontée des erreurs en second avec --errors sentry), la configuration, la persistance, l'application (log, traceConsumers: config.tracing.consumers, resilience, jobs: { start: config.runJobs, settings: jobSettings(process.env) }), le relais de l'outbox, le serveur HTTP ; sur SIGTERM ou SIGINT, il ferme le serveur, arrête le relais, arrête l'application, ferme la persistance et éteint le traçage |
src/platform/ |
un export par fichier, chacun portant son nom, dans un dossier par préoccupation. À sa racine : tracing.ts, compose-application.ts et job-settings.ts (jobSettings(environment, context?) : une ligne par job, JOB_<CONTEXT>_<JOB>_ENABLED=false en désactive un). config/ (app-config.ts, load-config.ts, config-error.ts, read-tracing.ts : échec immédiat sur DATABASE_URL, LOG_LEVEL, LOG_FORMAT, LOG_SCRUB_MESSAGES, la politique OUTBOUND_*, RUN_JOBS et les variables de traçage ci-dessous) ; observability/ (create-logger.ts, voir --logger ; create-metrics.ts : un NoopMetrics derrière un TODO(platform)) ; resilience/ (resilience-settings.ts, default-resilience.ts, adapter-aspects.ts : nouvelle tentative avec temporisation croissante et délai d'expiration pour external et gateway, own-policy.ts pour un adaptateur qui énonce la sienne, self-governed.ts) ; events/, database/, auth/, audit/, redis/ (ci-dessous) ; http/ (start-http-server.ts, with-documentation.ts : /docs et /openapi.json, ou /openapi/<name>.json par document d'un contrat découpé, désactivés en production sauf API_DOCS=true ; openapi-documents.ts, le découpage qu'écrit ddd-ts-kit docs depuis openapi.documents). Avec --di tsyringe : compose-application.ts (composeApplication(persistence, options, contexts?) : kernelModule({...}), le module de la plateforme, le module d'autorisation, les contextes ; installe le moteur des aspects), lay-foundations.ts (une portée d'exécution, l'horloge, le générateur d'identifiants, le logger, les métriques, l'instrumentation et l'audit, construits avant tout module), platform.module.ts (ce qui remplace les valeurs par défaut du kernel dans le processus : unité de travail, magasin des événements traités, la classe de stockage, OutboxStore, EventSubscribers derrière un broker, ErrorMapper, et builder.around(['external', 'gateway'], ...), la résilience de chaque adaptateur qui sort du processus), platform-test.module.ts (ce qu'une spec de gestionnaire liste à côté de son contexte : la classe de stockage liée à une fabrique qui lève une erreur), buses/create-command-bus.ts et create-query-bus.ts (du plus extérieur au plus intérieur : errorReportingMiddleware, loggingMiddleware, metricsMiddleware, la permission, le span du cas d'usage, l'entrée d'audit, puis l'unité de travail d'une commande). Avec --di manual ou nest : compose-application.ts, platform.ts (interface Platform, avec jobs: JobScheduler et adapter, qui enveloppe ce qu'un fichier de module instancie), create-platform.ts, jobs/configured-job-scheduler.ts (applique job-settings.ts à ce qu'un module enregistre) |
| traçage | câblé une fois, par la plateforme (tracing/platform-only) : pas de hook decorate, pas de decorate-bindings.ts. tracing.ts démarre initTracing de @ddd-ts-kit/tracing/node avant tout autre module, sur ce que valide config/read-tracing.ts : OTEL_EXPORTER_OTLP_ENDPOINT (une URL ; absente, aucun span ne sort du processus), TRACE_SAMPLE_RATIO (de 0 à 1) et TRACE_CONSUMERS (continue, par défaut : un événement consommé reste dans la trace de sa publication ; link : chaque livraison démarre une trace liée à elle). Avec --di tsyringe : lay-foundations.ts construit traceApplication({ consumers }), compose-application.ts la remet à kernelModule({ instrumentation }) (un span par cas d'usage, appel de repository, événement du domaine, publication, consommateur et job) et donne au relais de l'outbox tracedPublisher(bus). En câblage manuel : create-platform.ts applique lui-même les membres de traceApplication() (useCase sur les deux bus, domainEvents, publisher, binding autour de chaque adaptateur, job, executionScope, errorReporter), avec tracedSubscriber et tracedPublisher autour du fournisseur. Avec une stack HTTP, dans les deux familles, auth/http-boundary.ts pose around: tracedRoutes() : un span SERVER par requête HTTP, nommé d'après sa route |
--logger |
src/platform/observability/create-logger.ts, un par choix : stdout (par défaut) est le StdoutLogger de @ddd-ts-kit/aspects, des lignes JSON, identifiants de connexion non masqués ; pino est createPinoLogger de @ddd-ts-kit/pino (dépendances pino et pino-pretty) ; winston est createWinstonLogger de @ddd-ts-kit/winston ; ces deux-là masquent les clés d'identifiants de connexion du kernel. Chaque entrée porte traceLogFields(scope) : l'identifiant de corrélation, l'origine, l'acteur et le tenant du travail en cours, et les identifiants de la trace active. LOG_LEVEL, LOG_FORMAT (json, par défaut, ou pretty ; avec stdout, qui n'a pas de format lisible, pretty est refusé au chargement de la configuration) et LOG_SCRUB_MESSAGES ([message removed] à la place du message d'une erreur journalisée qui n'est pas une DomainError) sont validés dans config/load-config.ts. L'arrêt de l'application vide le tampon du reporter d'erreurs, puis celui du logger, en dernier : container.stop() le fait sur tsyringe, compose-application.ts le fait explicitement en câblage manuel. Les outils et les tests composent l'application sans log et n'écrivent rien |
src/platform/auth/ |
create-authenticator.ts (--authn jwt : JwtAuthenticator sur AUTH_ISSUER, AUTH_AUDIENCE, AUTH_JWKS_URI ; --authn keycloak : keycloakAuthenticator sur KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_AUDIENCE et l'optionnel KEYCLOAK_CLIENT_ID ; --authn none : development-header-authenticator.ts, qui lit x-development-actor et lève une erreur en production), principal-of.ts, security-schemes.ts, offline-authenticator.ts, http-boundary.ts (resolvePrincipal qui répond une Session, executionScope, errorMapper et onError, étalés dans les options de montage : chaque requête HTTP s'exécute dans la portée d'exécution, avec son identifiant de corrélation), create-authorizer.ts et les fichiers du modèle (roles.ts, avec le rôle system que détient chaque acteur système ; attribute-policies.ts ; relationship-schema.ts, create-tuple-store.ts et sa spec de contrat, relation-tuples.ts, relate-system-actors.ts, permission-mapping.ts et permissions.ts ; custom-authorizer.ts, sa spec authorizerContract et permissions.ts), authorization.ts (authorizationModule avec tsyringe) ou use-case-protection.ts (la garde des bus câblés à la main), access-policies.ts, et avec --audit off log-decisions.ts (une entrée de journal par décision, identifiants et codes seulement). Sans stack HTTP, il n'y a ni authentificateur ni frontière |
--representations negotiated, --forms on |
src/platform/http/presentation-of.ts (Representations.standard() avec negotiated, et fieldDeciderOf sur l'Access de l'application), décomposé dans la frontière HTTP et donné au script du contrat ; avec --forms on, src/platform/http/with-forms.ts ajoute formRoutes aux routes et scripts/openapi.ts écrit openapi/forms/<operationId>.json. Les deux sont la valeur par défaut avec une stack HTTP ; json et off les laissent de côté (docs/CONFIG.fr.md) |
--audit on |
src/platform/audit/ : create-audit.ts (le journal, le onDecision de l'autorisation, les refus des gardes à la frontière remis à la frontière HTTP comme onGuardRefusal, le middleware avant l'unité de travail, les événements d'intégration abonnés sous le nom de consommateur audit), audited-events.ts (le registre auquel generate integration-event ajoute), audit.module.ts, et avec une stack HTTP le côté lecture : audit.permissions.ts (audit:trail.read), search-audit-entries.query.ts, son gestionnaire, search-audit-entries.route.ts (GET /audit/entries : pages par curseur, filtres actorId, action, outcome, correlationId). C'est une capacité de la plateforme, pas un contexte délimité : pas de modèle, une requête protégée sur le port AuditReader, bornée par le tenant de l'acteur (avec --tenancy shared-table, par le tenant du travail au moyen de TenantAuditReader : le personnel de la plateforme ne lit à travers les tenants qu'à l'intérieur de acrossTenants). Les refus d'un appelant sans identifiant de connexion ne sont pas enregistrés. Un job planifié est audité comme une requête HTTP : origine schedule, acteur system:<context>.<job>. Avec knex, les deux tables et leurs déclencheurs en ajout seul sont créés par la première migration ; avec drizzle, les tables sont dans schema.ts et npm run db:audit-guard affiche les instructions des déclencheurs pour une migration personnalisée ; avec prisma, typeorm, custom ou sans ORM, le journal est un InMemoryAuditTrail derrière un TODO(platform). Les bases de test laissent le schéma d'audit hors de reset() |
--tenancy shared-table |
enregistré comme stack.tenancy ; Knex ou Drizzle seulement. Chaque generate repository est cloisonné par tenant (comme --tenant-scoped), au moyen de tenantTable et ownTenantTable ou de tenantCondition et ownTenantCondition ; l'outbox garde le tenant de chaque événement (createOutboxTables(knex, { tenant: true }) et le tenant: true de KnexOutboxStore, ou tenantOutboxTable), si bien que les consommateurs s'exécutent dans ce tenant ; tenancy/scoped-statement juge les adaptateurs. La sécurité au niveau des lignes, un schéma par tenant et une base par tenant sont refusés, avec ce qu'il faut câbler à la main (Multitenance) |
--queue database|redis|memory |
enregistré comme stack.queue. database (par défaut sur Knex ou Drizzle avec tsyringe) : createJobTable et createProcessTable, ou jobsTable et processesTable, dans le schéma messaging, Persistence gagne jobs et processes, et le module de la plateforme relie JobStore et ProcessStore à eux, si bien qu'un job mis en file par une commande est écrit dans sa transaction ; redis (avec --cache redis) : RedisJobStore sur la connexion du cache, les processus dans la base de données ; memory (par défaut sinon) : rien n'est écrit, le module du kernel garde les deux dans le processus. Refusé hors de tsyringe, database avec un autre ORM, redis sans le cache |
--cache redis |
src/platform/redis/open-redis.ts (RedisServices : le cache lié au Cache du kernel, le limiteur à RateLimiter, le verrou à Lock, qui réserve un job planifié à une seule instance, le signal du relais comme wakeOn du relais de l'outbox avec relayWakeUpMiddleware sur le bus de commandes), http/refusal-for.ts (une fenêtre glissante par adresse d'appelant, 429 application/problem+json avec retry-after ; RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS), GET /health/ready sur le serveur HTTP, REDIS_URL validée, un service compose cache qui exécute Dragonfly avec un contrôle de santé. Sans serveur (outils, tests sans REDIS_URL), les mêmes ports restent dans la mémoire du processus |
--http-cache varnish |
un service compose varnish (Varnish 9.1.0, qui joint l'API sur host.docker.internal) avec son VCL dans docker/varnish/default.vcl (seules les réponses dont le Cache-Control les autorise par max-age ou s-maxage sont gardées, rangées sous leurs étiquettes xkey, purgées par PURGE depuis la machine de Varnish ou avec VARNISH_PURGE_KEY, x-cache: HIT|MISS), VARNISH_URL et VARNISH_PURGE_KEY validées, cacheTags: { header: CacheTagHeader.xkey } dans la frontière HTTP, et le relais de l'outbox de main.ts et de chaque point d'entrée de generate service qui publie au moyen de PurgingPublisher avec VarnishCachePurger (l'application de bout en bout enregistre les purges dans purges) ; sur --di manual et nest, la plateforme gagne cacheInvalidation ; avec --tenancy, les étiquettes des réponses et les purges sont rangées sous le tenant (perTenant) et le tenant d'une requête HTTP est lu dans x-tenant-id. Demande un framework HTTP et --events outbox : une purge est une ligne de l'outbox |
--errors sentry |
les échecs inattendus remontés à Sentry ou à GlitchTip (un seul protocole, seul le DSN diffère) au moyen de l'ErrorReporter du kernel, lié à @ddd-ts-kit/sentry. src/platform/errors.ts (initSentry sur la configuration validée, puis reportProcessFailures), le deuxième import de main.ts, de apps/<context>/main.ts et de apps/worker/main.ts, juste après tracing.ts ; errors remis à composeApplication, qui le donne au module du kernel sur tsyringe (errorReporter, vidé par container.stop() avant le logger, errorReportingMiddleware en premier sur les deux bus, le planificateur par défaut qui observe avec reportFailedRuns) ou à createPlatform à la main (le même middleware, onRun: reportFailedRuns(errors), platform.errors, vidé en deux secondes au plus quand l'application s'arrête) ; reportHttpErrors dans auth/http-boundary.ts pour chaque requête HTTP qui reçoit une réponse 5xx ; DeadLetterReports sur le hook onFailure d'un broker fourni (createEventProvider(config, errors), avec observing autour de chaque consommateur) et du relais de l'outbox. SENTRY_DSN, SENTRY_ENVIRONMENT (par défaut : NODE_ENV), SENTRY_RELEASE et SENTRY_SCRUB_MESSAGES validées dans config/load-config.ts comme errorReporting : sans DSN, l'application démarre et ne remonte rien ; un DSN qui n'est pas une URL refuse le démarrage. Dépendances @ddd-ts-kit/sentry et @sentry/node. Un profil compose errors qui exécute GlitchTip 6.2.6 avec son propre PostgreSQL et son propre Valkey (GLITCHTIP_PORT, sur la boucle locale seulement, amorçage de développement), et une section du README généré qui dit comment lire son DSN. Les outils et les tests composent l'application sans reporter. Il va avec tous les autres choix de la stack ; avec --errors none, rien de tout cela n'est écrit |
--authn keycloak |
un service compose keycloak (Keycloak 26.8.0, start-dev --import-realm, profil identity) et docker/keycloak/<project>-realm.json : le client de l'API, un client public <project>-local avec l'octroi direct, ses mappers d'audience et de tenant_id, le rôle de realm admin, et les utilisateurs de démonstration demo-admin et demo-user, pour le développement local seulement |
--storage local|s3 |
enregistré comme stack.storage : où vit le contenu des fichiers, jamais dans la base de données. src/platform/config/read-files.ts et src/platform/files/open-files.ts, un par choix : local, un répertoire en développement (FILES_DIRECTORY, .data/files) et les liens signés servis par l'application sous /files (FILES_PUBLIC_URL et FILES_SIGNING_SECRET requises en production) ; s3, un bucket de n'importe quel fournisseur du protocole S3 au moyen de @ddd-ts-kit/s3 (les variables S3_*, MinIO dans la stack compose) ; la mémoire du processus dans les tests et les outils, dans les deux cas. Avec --di tsyringe : files/bind-files.ts, files/files.module.ts et files/staging-uploads.ts ; en câblage manuel, files/create-files.ts ; avec une stack HTTP, files/file-routes.ts. Ils lient FileStorage, FileKeys, FileScanner et FileStaging, enregistrent la purge horaire des fichiers en attente et mettent en attente le fichier que porte une commande avant sa transaction ; generate file-upload en a besoin. --storage none n'écrit rien de tout cela (Stockage de fichiers) |
src/platform/events/ |
event-provider.ts (bus, forConsumer(name), start(), close()), in-process-events.ts, et pour --broker create-event-provider.ts : RabbitMqEventBus (AMQP_URL), KafkaEventBus (KAFKA_BROKERS, avec aggregate-key-of.ts pour l'ordre par agrégat), NatsEventBus (NATS_URL) ou RedisStreamsEventBus de @ddd-ts-kit/redis/event-bus (REDIS_URL : Redis, Dragonfly ou Valkey), chacun avec un abonné par contexte consommateur (forConsumer), assertTopology au démarrage, son adresse validée et un service compose avec un contrôle de santé ; ou custom-event-bus.ts avec sa spec eventBusContract. start-outbox-relay.ts avec l'outbox. --broker redis avec --cache redis fait un seul serveur et un seul service compose, cache ; seul, le service s'appelle streams |
src/platform/database/ |
persistence.ts (interface Persistence : unité de travail, magasins de l'outbox et des événements traités du package de l'ORM, la poignée de stockage), open-persistence.ts, persistence-over.ts, inert-persistence.ts, la connexion (connect-to-database.ts, create-client.ts ou create-data-source.ts), schema.ts, open-test-database.ts (PostgreSQL dans le processus, ou le serveur nommé par une URL). La classe de stockage qu'un adaptateur de repository nomme dans sa liste de dépendances : database.ts avec drizzle et prisma (abstract class Database extends le type de contexte du package de l'ORM), KnexContext de @ddd-ts-kit/knex, TypeOrmContext de @ddd-ts-kit/typeorm |
| outillage des migrations | drizzle.config.ts, knexfile.js et migrations/00000000000000_initial.js, prisma/schema.prisma et prisma.config.ts, ou data-source.ts |
scripts/openapi.ts, scripts/events.ts |
écrivent openapi/openapi.yaml (un fichier par document de openapi.documents), docs/integration/asyncapi.yaml et docs/integration/event-catalog.md, en JSON sous openapi.format: json ; --check échoue sur une différence |
docker-compose.yml, .env.example, Dockerfile, .dockerignore |
la base de données avec un contrôle de santé et un volume nommé, jaegertracing/jaeger:2.22.0 derrière le profil observability (son interface et son port OTLP liés à 127.0.0.1), les ports depuis .env ; une image multi-étapes, sans root, avec les seules dépendances de production |
vitest.e2e.config.ts, test/e2e/support/ |
start-application.ts (l'application entière sur un port libre, sur une base de données dans le processus sauf si DATABASE_URL est définie, aucun job lancé par le temps), create-credentials.ts, auth-environment.ts, test-environment.ts, story-of.ts (storyOf(trace) : une trace sans les appels qu'un adaptateur fait sur lui-même, la forme que vérifie expectSpanTree) |
docs/context-map.md, docs/integration/README.md |
voir docs |
test/architecture/extractable.spec.ts |
écrit avec le premier contexte : démarre chaque contexte seul sur le kernel et la plateforme, ses jobs compris |
eslint.config.mjs |
le preset dddTsKit de @ddd-ts-kit/eslint-plugin, lint typé sur le tsconfig du projet |
.dependency-cruiser.cjs |
les règles du graphe des imports, générées depuis la configuration (voir depcruise-config) |
biome.json |
le formateur : Biome avec son linter et son organisateur d'imports désactivés, largeur de ligne 120 ; npm run format écrit, npm run format:check vérifie, et ce que la CLI génère est déjà formaté |
knip.json |
code mort : les points d'entrée sont main.ts et apps/*/main.ts, les scripts, les specs et les fakes, les fichiers de module dans le style explicite, les fichiers de rôle sous discovery (jobs et publishers compris) |
vitest.config.ts |
projets unitaires et d'intégration, les seuils de couverture lus dans ddd-ts-kit.config.json (governedCoverage), par contexte et par couche, dans le mode des décorateurs standard decorators() de @ddd-ts-kit/testing/vitest, qui compile les fichiers contenant un décorateur avec le compilateur du projet (Vitest ne transforme pas les décorateurs standard ; il transforme lui-même les anciens, si bien qu'un projet avec experimentalDecorators ne reçoit pas de plugin), et sous --assembly discovery server.deps.inline: ['@ddd-ts-kit/di'] |
lefthook.yml, commitlint.config.mjs |
hooks pre-commit (ESLint, la vérification du format et check sur les fichiers indexés, check --commit-size --staged, qui ne bloque jamais), pre-push (types, tests unitaires) et commit-msg (npm run hooks:install), commits conventionnels |
--ci github |
.github/workflows/ci.yml, pr-title.yml, copilot-setup-steps.yml (Node et les dépendances, installés avant le démarrage de l'agent cloud de Copilot), .github/dependabot.yml, .github/branch-protection.md, renovate.json ; codeql.yml avec --ci-extras codeql |
--ci gitlab |
.gitlab-ci.yml (les constats d'architecture comme rapport Code Quality au moyen de check --format gitlab), .gitlab/merge-request-settings.md, renovate.json |
README.md, AGENTS.md, CLAUDE.md, .claude/rules/*.md, .github/instructions/*.instructions.md |
comment le lancer ; un guide léger dans AGENTS.md, que lisent Codex, Copilot et la plupart des agents de code et qu'importe CLAUDE.md (commandes, comment lire moins, la carte des règles, fichiers générés, note sur le compilateur, les skills du plugin : /ddd-ts-kit:start, glossary, use-case, document, quality-gate) et les invariants dans sept fichiers de règles, domain.md, application.md, published-language.md, infrastructure.md, presentation.md, platform.md et tests.md, chacun délimité par un frontmatter paths, et les mêmes sept pour GitHub Copilot, chacun délimité par applyTo |
.github/copilot/settings.json |
le plugin du kit, déclaré pour GitHub Copilot (extraKnownMarketplaces, enabledPlugins) : son agent cloud l'installe, la CLI Copilot et VS Code le recommandent ; un fichier existant garde ce qu'il contient et reçoit les deux entrées |
.claude/settings.json |
des permissions seulement : les vérifications et les générateurs s'exécutent sans demander, les fichiers .env sont illisibles (.env.example reste lisible), les trois documents générés ne sont pas modifiés à la main, git push, npm publish et toute modification des fichiers qui portent les règles et le pipeline (ddd-ts-kit.config.json, eslint.config.mjs, biome.json, .dependency-cruiser.cjs, knip.json, lefthook.yml, .gitlab-ci.yml, .github/workflows/**, .claude/settings.json, .github/copilot/settings.json) demandent d'abord |
.gitignore |
node_modules, dist, coverage, .env, *.tsbuildinfo et .ddd-ts-kit/, où écrit worker-scope |
--ci both écrit les deux pipelines, --ci none aucun (et pas de renovate.json). Les
fichiers de pipeline sont des copies des fichiers du répertoire pipeline/ du dépôt, livrées
dans templates/pipeline/ ; un test échoue quand une copie diffère de sa source au-delà de ses
emplacements à remplacer documentés.
Un .claude/settings.json existant n'est jamais écrasé : les permissions manquantes sont ajoutées
à ses listes, tout le reste est gardé, et les notes de la commande listent ce qui a été ajouté. La
règle sur .env repose sur la sémantique gitignore des motifs de permission : Read(./.env.*)
est refusé et Read(!./.env.example), une négation placée après lui dans la même liste, en
ressort le fichier d'exemple.
Chaque fichier .ts qu'écrivent init et generate importe par l'alias (@/platform/errors),
jamais par un chemin relatif ; le barrel et l'index d'un langage publié réexportent par lui aussi.
Scripts : dev (tsx watch, qui résout un alias depuis les paths de tsconfig.json, avec
.env chargé par --env-file-if-exists et le traçage préchargé),
build (le compilateur, puis ddd-ts-kit build-aliases), start (node seul sur dist/), typecheck, lint, lint:fix, test, test:e2e, check:arch, depcruise,
deadcode, quality, openapi:generate|check, events:generate|check, docs:generate|check,
db:generate, db:migrate, infra:up, infra:down, hooks:install, start:<context> et
start:worker une fois que generate service et generate worker ont écrit leur point d'entrée,
les scripts ci:* qu'appellent les pipelines, test:coverage (chaque spec une fois, chaque
contexte et chaque couche tenus aux seuils de ddd-ts-kit.config.json) et verify, qui enchaîne
toute la base : typecheck, lint, format:check, test:coverage, tests de bout en bout,
check:arch, depcruise, deadcode, docs:check.
package.json porte deux compilateurs : typescript ~6.0.3, que les outils chargent comme une
bibliothèque, et @typescript/native (npm:typescript@~7.0.2), que build et typecheck
appellent par son chemin (node node_modules/@typescript/native/bin/tsc). npx tsc lancerait
l'ancien.
init et generate context commencent par le langage : la première entrée de next est le
glossaire du contexte à remplir.
--preset identity (sur init, ou sur generate context ; une fois par projet) écrit un
contexte délimité entier au lieu d'un contexte vide : qui sont les personnes de l'application, les
organisations auxquelles elles appartiennent et avec quel rôle. Les comptes sont provisionnés
depuis le fournisseur d'identité à la première connexion (aucun mot de passe n'est gardé), les
organisations (les tenants sous --tenancy shared-table), les membres avec des rôles d'un
vocabulaire déclaré, les invitations par jeton avec une expiration, les délégations de droits pour
une période ; les permissions, les politiques, le langage publié (membre arrivé, rôle changé,
compte désactivé...), une recherche, les contrôleurs, les specs avec builders et fakes, les
adaptateurs de l'ORM, et le port vers l'API d'administration du fournisseur d'identité, tenu à
jour au moins une fois par les consommateurs des faits publiés, avec un adaptateur Keycloak donné
comme une recette (prouvé contre un serveur qui répond comme Keycloak, pas contre Keycloak dans le
pipeline). Personne ne change son propre rôle ni n'agit au-dessus de lui, quoi que prête une
délégation ; un compte désactivé quitte ses organisations ; une adresse que le fournisseur n'a pas
vérifiée ne provisionne aucun compte. Sur init sans --context de son nom, le contexte
identity est déclaré generic. Chaque personne connectée détient les permissions du contexte
quoi que dise --authz : l'attribution 'identity:*' de platform/auth/signed-in-grants.ts,
à laquelle répond SignedInGrantsAuthorizer avant le modèle dans createAuthorizer. Avec des
tenants, les tables du contexte n'appartiennent à aucun, sous un enregistrement de décision que
le preset écrit, et avec --authn keycloak l'authentificateur lit le claim organization
comme les tenants de la personne (un autre authentificateur reçoit une étape suivante qui dit
quoi faire correspondre). Le preset est écrit pour declared avec les dépendances signature,
factories, les objets-valeurs throwing, controllers, Knex ou Drizzle sur PostgreSQL, Zod ou
class-validator, Express ou Fastify, et un vérificateur de jeton (jwt ou keycloak) ; une
autre stack est refusée avec ce qu'il faut changer, de même qu'un second contexte écrit depuis
le même preset. Pourquoi, et quels autres contextes en méritent un :
Démarrer avec des contextes bien conçus.
generate (alias g)
Section intitulée « generate (alias g) »ddd-ts-kit generate <kind> ... [--term "<Term>=<meaning>"]... [--allow-name] [--local-only] [--dry-run] [--force]ddd-ts-kit generate --help --jsonUn passage de generate fait cinq choses, dans cet ordre, et n'écrit rien quand la première
refuse :
Dans l'application d'exemple, un cas d'usage nommé avec un mot que son glossaire ne définit pas est refusé, et le message dit comment continuer :
$ npx ddd-ts-kit generate command notification ArchiveNotification --permission notification.archive --aggregate Notificationerror: "ArchiveNotification" uses "archive", which the glossary of src/contexts/notification/CONTEXT.md does not define. fix: Either use an existing term (Notification, Send, Recipient, Contact, Confirmation, Reminder, Plan, Due, Withdraw, Mailer, Kind, Request, Rescheduling, Cancellation, Read, Search), or add the term in the same command: --term "Archive=<what it means for the business>". The glossary is written before the code (rule language/glossary-term).Avec le terme donné dans la même commande (et --dry-run, qui dit ce qu'il écrirait) :
$ npx ddd-ts-kit generate command notification ArchiveNotification --permission notification.archive --aggregate Notification \ --term "Archive=To set a sent notification aside, out of the default search" --dry-runwould create src/contexts/notification/application/commands/archive-notification/archive-notification.command.tswould create src/contexts/notification/application/commands/archive-notification/archive-notification.handler.tswould create src/contexts/notification/test/unit/archive-notification.handler.spec.tswould update src/contexts/notification/CONTEXT.mdwould update src/contexts/notification/application/authorization/notification.permissions.tswould update src/contexts/notification/notification.declarations.ts
Next, in this order: the language of the context first, then the business decisions only you can make src/contexts/notification/application/commands/archive-notification/archive-notification.command.ts:9 declare the input of "archive notification" as a constructor with the actor first, ... src/contexts/notification/application/commands/archive-notification/archive-notification.handler.ts:16 decide what "archive notification" does to a Notification: ... src/contexts/notification/test/unit/archive-notification.handler.spec.ts:38 replace each entry below by a test named after the business behaviour of "archive notification": ...| Genre | Arguments | Écrit |
|---|---|---|
context |
<name> --classification <c> [--purpose "<two sentences>"] [--term ...]... [--upstream <ctx>[:relationship]]... [--preset identity] |
CONTEXT.md avec son glossaire, published-language/index.ts, l'entrée de configuration, l'enregistrement dans platform/compose-application.ts et dans test/architecture/extractable.spec.ts ; sur tsyringe, avec HTTP, presentation/http/<name>.routes.ts (une fonction des deux bus, dans les deux styles) ; déclaré : le barrel <name>.declarations.ts ; explicite et câblage manuel : <name>.module.ts. application/authorization/<name>.permissions.ts naît avec la première permission, ou tout de suite quand platform/auth/permissions.ts liste les ensembles de permissions (rebac, custom). Avec --preset, le contexte est ensuite écrit en entier depuis ce preset, sa classification et son objet étant ceux du preset sauf si les options disent autre chose (Presets) |
aggregate |
<context> <Name> [--event-sourced] |
agrégat, builder, spec sur le modèle étant donné / quand / alors ; avec --event-sourced, un agrégat sur EventSourcedAggregate dont l'état est le rejeu de ses événements, né avec son événement du domaine Created, le codec de ses événements stockés dans infrastructure/persistence/<name>-events.mapper.ts, un builder qui rejoue un historique et une spec à partir d'événements donnés, et une note qui nomme le EventSourcedRepository auquel délègue son adaptateur de repository |
builder |
<context> <Aggregate> |
test/builders/<name>.builder.ts, une classe qui étend Builder |
entity |
<context> <Name> |
entité, spec |
value-object (vo) |
<context> <Name> |
objet-valeur, spec |
vocabulary |
<context> <Name> --value <value> [--value <value>]... |
domain/vocabularies/<name>.vocabulary.ts : un objet as const des valeurs, indexé par chaque valeur en PascalCase, et le type union du même nom ; relancé, ajoute les valeurs que le fichier ne contient pas |
domain-event |
<context> <Aggregate> <Fact> |
événement nommé <context>.<aggregate>.<fact>, spec |
error (refusal) |
<context> <Concept> <refusal> [--code CODE] [--term ...]... |
une fabrique statique <refusal> sur domain/errors/<concept>.error.ts, la classe des erreurs de ce concept (créée si elle manque, qui étend ConceptError), et son cas dans test/unit/<concept>.error.spec.ts. La fabrique est ajoutée après la dernière ; relancée, la commande ne change rien. <Concept> est un terme du glossaire (--term l'ajoute). Le code est le nom du refus en casse de constante, précédé du concept quand le nom est un seul mot ou un code du kit (Appointment notFound pose APPOINTMENT_NOT_FOUND) ; --code prend l'une des formes qu'accepte errors/factory-name. Refusés : un nom comme create ou error1, et un code déjà posé dans le contexte |
domain-error |
<context> <Name> [--code CODE] |
une classe par erreur avec un code stable, spec : la forme d'un projet dont la configuration dit "stack.refusals": "classes" ou rien. Refusé sous factories, avec la commande generate error à lancer à la place |
domain-service |
<context> <Name> |
service, spec |
state-machine |
<context> <Aggregate> [--value <state>]... [--transition "<intention>:<from>[,<from>]...[><to>]"]... |
le vocabulaire <Aggregate>Status (créé depuis --value, deux états au moins, ou complété des valeurs qui lui manquent), domain/state-machines/<aggregate>.state-machine.ts (<aggregate>Lifecycle : chaque valeur un état, la première initiale, les transitions demandées, un TODO(domain) pour le refus de l'erreur du concept) et test/unit/<aggregate>.state-machine.spec.ts (chaque transition là où elle mène, un refus depuis un état qu'elle ne quitte pas) ; c'est à l'agrégat de l'utiliser |
repository |
<context> <Aggregate> [--tenant-scoped] |
le port domain/repositories/<name>.repository.ts (abstract class <Name>Repository extends Repository<<Name>>), l'adaptateur de l'ORM du projet (qui implements le port) avec son mapper et sa table (ou son modèle), un fake, une spec repositoryContract exécutée sur les deux, l'enregistrement |
command |
<context> <UseCaseName> (--permission <aggregate>.<action> [--grant <role>] | --public) [--aggregate <Name>] |
<use-case>.command.ts sur AuthorizedCommand (simple Command quand elle est publique), <use-case>.handler.ts (il construit l'agrégat, le sauvegarde et répond : les événements partent avec la sauvegarde), une spec par le bus avec le refus d'un acteur à qui manque la permission, l'enregistrement avec .requires(...) ou .public() (et le repository s'il manque). Une commande générée répond void ou l'identifiant de son agrégat, si bien qu'aucun <use-case>.result.ts n'est écrit |
query |
<context> <UseCaseName> (--permission <aggregate>.<action> [--grant <role>] | --public) [--search] |
<use-case>.query.ts sur AuthorizedQuery (simple Query quand elle est publique), <use-case>.result.ts, <use-case>.handler.ts, le port de lecture application/ports/<use-case>-reader.port.ts (une classe abstraite), son adaptateur en mémoire, un fake, une spec, l'enregistrement sur le bus de requêtes. Avec --search (demande --permission) : une PageRequest du kernel dans la requête, l'élément, le tri et Page<Item> dans le fichier de résultat, un gestionnaire qui demande Access.scope et remet le périmètre au port de lecture, un lecteur en mémoire qui pagine par décalage ou par curseur |
port |
<context> <Name> |
application/ports/<name>.port.ts, une classe abstraite, et son fake |
route |
<context> <UseCaseName> --method <GET|POST|PUT|PATCH|DELETE> --path <p> [--command|--query] [--group <Name>] [--mapper] [--serializer] [--cache public|private|no-store] |
sous "stack.routes": "controllers" : une classe contrôleur dans presentation/http/<resource>/<use-case>/ (la ressource est le premier segment du chemin), qui dépend de CommandBus ou de QueryBus, derrière le groupe de routes de sa ressource, écrit avec elle quand il est nouveau (<resource>.routes.ts : préfixe, étiquette, Authenticated) ; un cas d'usage public ne se tient dans aucun groupe et dit guards: [Public] ; avec class-validator, une classe par fichier (<use-case>.params.ts, .body.ts, .answer.ts, .accepted.ts, .line.ts) ; --mapper et --serializer ajoutent un mapper de requête HTTP et un sérialiseur que reçoit le contrôleur ; une spec ; aucun enregistrement dans un projet déclaré, un contrôleur construit à la main dans <context>.routes.ts (le fichier de module en câblage manuel) dans un projet explicite. Sous "functions" : une fonction defineRoute, sa spec, son enregistrement dans <context>.routes.ts, et des classes <use-case>-<role>.dto.ts |
controller |
les mêmes arguments que route |
la classe contrôleur, quoi que dise stack.routes : pour un projet de routes en fonctions qui écrit sa prochaine route comme une classe. --cache écrit sa politique de cache : private (un GET d'un appelant, max-age=0, must-revalidate, ETag fort), public (un GET d'un cas d'usage public, max-age 1m, s-maxage 5m, étiqueté avec les ressources de son chemin), no-store ; refusé sur une écriture, et public derrière un groupe de routes |
file-upload |
<context> <UseCaseName> --path <p> (--permission <aggregate>.<action> [--grant <role>] | --public) [--method POST|PUT] [--group <Name>] |
un téléversement : un contrôleur qui déclare le fichier qu'il reçoit (sa taille et ses types de média dans le contrat, vérifiés au fil du flux), la commande qui le porte comme un FileUpload, mis en attente par le bus de commandes avant la transaction, le gestionnaire qui l'enregistre et le promeut avec la validation (commit), et leurs specs. Refusé sans stack.storage ou sans stack HTTP, avec le init --force --storage local à lancer |
route-group (group) |
<context> <Name> [--prefix <path>] [--group <ParentName>] [--public | --role <role>] |
presentation/http/<name>.routes.ts : une classe vide qui porte @RouteGroup, derrière Authenticated sauf si --public ou --role dit autre chose (HasRole(Role.Admin), le rôle lu dans le vocabulaire de la plateforme, <platformDir>/auth/role.ts, qui le reçoit s'il ne le contient pas) ; avec --group, il affine ce groupe et en hérite |
guard |
<context> <Name> |
presentation/http/guards/<name>.guard.ts : une classe de garde déclarée à remplir, et sa spec |
representation |
<context> <UseCase> <xml|csv|media/type>[,...] |
ajoute produces au @Controller du cas d'usage, et consumes: ['application/xml'] à celui qui reçoit un corps quand XML est demandé ; dit quand CSV a besoin d'une page ou d'une liste en flux, et quand stack.representations n'est pas negotiated |
form |
<context> <UseCase> |
ajoute const form = defineForm<Infer<typeof body>>({ ... }) avant le @Controller d'un cas d'usage qui reçoit un corps, et form à ses options : les indications de son formulaire à GET /forms/<operationId> |
schema |
<context|shared-kernel> <Name> |
presentation/http/shared/<name>.schema.ts du contexte, ou presentation/<name>.schema.ts du noyau partagé : une forme du contrat déclarée une fois (un schéma zod avec .meta({ id }), une classe class-validator, ou un schéma enveloppé par sharedSchema), à remplir ; les routes l'utilisent en entier, imbriquée ou en partie |
integration-event |
<context> <Aggregate> <Fact> [--version n] |
la déclaration defineIntegrationEvent dans le langage publié (sa charge utile dans <event>.v<n>.payload.ts avec class-validator ou un schéma à vous), l'export de l'index, le publisher application/integration/publishers/<event>.publisher.ts, une spec, l'enregistrement, une ligne de platform/audit/audited-events.ts |
integration-handler (consumer) |
<context> <upstreamContext> <eventType> |
application/integration/handlers/<name>.integration-handler.ts, une classe <Name>IntegrationHandler qui dépend de CommandBus, sa spec, son abonnement idempotent sous le nom de consommateur <context>.<name> ; refusé sauf si l'amont est déclaré |
event-handler |
<context> <Aggregate> <Fact> [--phase before-commit|after-commit] |
réaction à un événement du domaine du contexte, spec, abonnement ; avant la validation (commit) de l'unité de travail par défaut, après elle avec --phase after-commit (un effet qu'aucune transaction ne couvre, au plus une fois), et la spec exécute la réaction dans cette phase |
projection (projector) |
<context> <Name> <event-type> |
un projecteur dans application/projections/<name>.projection.ts, déclaré avec @Projection('<context>.<name>') et une méthode @On(definition) pour l'événement d'intégration, que publie le contexte lui-même ou un amont déclaré, et sa spec ; style déclaré seulement. Il tient un seul modèle de lecture : son port est reçu par le constructeur |
job |
<context> <Name> (--every <duration> | --cron "<expression>" [--time-zone <zone>]) --command <UseCaseName> |
application/scheduling/<name>.job.ts, une classe <Name>Job implements ScheduledJob qui envoie la commande, en tant que systemActor('<context>.<name>') quand elle est protégée, avec execution.scheduledAt quand la commande le prend ; test/unit/<name>.job.spec.ts, qui exécute le job (par son nom sur tsyringe, application.jobs.runNow ; job.run(occurrence) en câblage manuel) et vérifie la commande qu'il a envoyée ; son enregistrement ; son interrupteur dans platform/job-settings.ts ; l'autorisation de son acteur système (ci-dessous) |
background-job |
<context> <Name> --command <UseCaseName> |
application/background/<name>.background-job.ts, une classe <Name>BackgroundJob implements BackgroundJob<<Name>Payload> nommée par son jobName statique <context>.<name>, qui envoie la commande en tant que execution.actor quand elle est protégée, suivi d'un champ de <Name>Payload par autre paramètre du constructeur de la commande (chaînes, nombres, booléens, littéraux et listes de ceux-ci tels qu'écrits, une Date sous forme de son texte ISO ; tout le reste est refusé) ; test/unit/<name>.background-job.spec.ts, qui le met en file avec une charge utile de chaque champ en tant qu'utilisateur détenant la permission et exécute ce qui est dû ; son enregistrement (@Background(), ou builder.backgroundJob(..) dans le fichier de module). --di tsyringe seulement |
process (process-manager) |
<context> <Name> |
application/processes/<name>.process.ts (son état, processName, timeoutJob, isState, une première étape qui arme une échéance, une étape qui y met fin à temps, l'étape de l'échéance), application/background/<name>-timeout.background-job.ts, qui livre ses expirations au moyen de ProcessRunner, et les specs des deux (givenProcess, la file d'attente de l'application de test). --di tsyringe seulement |
acl |
<context> <upstreamOrSystemName> |
traducteur dans infrastructure/anti-corruption/, spec |
e2e |
<context> <UseCaseName> |
test/e2e/<use-case>.e2e.spec.ts qui pilote la route du cas d'usage : 401 sans identifiant de connexion, 403 sans la permission, le statut documenté avec elle ; pour une commande, un test de plus vérifie la trace de la requête HTTP, exacte sur les noms et la structure, avec installTestTracing() et expectSpanTree(storyOf(trace), [...]) de @ddd-ts-kit/tracing/testing (POST /invoices, puis command IssueInvoiceCommand, puis repository InvoiceRepository.save) ; demande la route |
policy |
<context> <Name> --permission <aggregate>.<action> |
application/authorization/<name>.policy.ts, spec ; déclaré : la classe porte @Policy(permission, { name }) et rien d'autre n'est écrit ; explicite : l'enregistrement dans platform/auth/access-policies.ts |
gateway |
<context> <upstreamContext> <Name> |
le contrat de lecture dans le langage publié de l'amont (<name>.contract.ts, une classe abstraite et le jeton de la valeur de l'amont), le port application/ports/<name>-gateway.port.ts, un adaptateur dans le processus dans infrastructure/external/ lié .as('gateway'), un fake, une suite de contrat ; refusé sauf si l'amont est déclaré |
adapter |
<context> <Port> [--kind external|gateway] [--decorators] |
infrastructure/external/remote-<port>.adapter.ts pour un port existant, sa spec, et sur tsyringe sa liaison builder.bind(<Port>).toClass(Remote<Port>).as(<kind>).with(ownPolicy(retry(...), timeout(...))) ; avec --decorators, sur toute stack, la classe porte @Retry et @Timeout de @ddd-ts-kit/aspects/decorators sur son unique méthode sortante et est liée .with(ownPolicy()). Explicite : un port déjà lié, ou le câblage manuel, reçoit une note au lieu d'une liaison. Déclaré : la classe porte @Adapter(<Port>, { kind, aspects }), ou @Gateway(<Port>, { aspects }) dans remote-<port>.gateway.ts avec --kind gateway, et un port qui a déjà un adaptateur déclaré est refusé |
service |
<context> |
src/apps/<context>/main.ts (kernel, plateforme, ce contexte seul, avec ses jobs : jobSettings(process.env, '<context>')), le script start:<context>, un service compose derrière le profil services, l'entrée knip, deployable: true dans la configuration. Le Dockerfile prend le point d'entrée comme argument de build ENTRY |
worker |
aucun | src/apps/worker/main.ts : les modules de main.ts, pas de serveur HTTP, pas de relais de l'outbox, les jobs démarrés quoi que dise RUN_JOBS, un arrêt propre ; le script start:worker, un service compose worker derrière le profil services, l'entrée knip, "worker": true dans la configuration, et RUN_JOBS qui vaut false par défaut à partir de là, si bien que l'API cesse d'exécuter les jobs qu'exécute le worker. Avec plusieurs instances, le verrou doit être un vrai verrou : --cache redis lie le verrou Redis, et sans lui le README généré dit la limite. Un contexte nommé worker est refusé |
adr |
"<title>" [--deviation <what>] [--reason "<why>"] |
docs/adr/NNNN-<slug>.md ; avec --deviation, l'entrée deviations de la configuration qui pointe vers lui |
- Le style déclaré, genre par genre. Aucun jeton nulle part : une liste nomme des classes de
port.
commandetquerymettent@Requires(<permissions>[...])ou@Public()sur le message et@CommandHandler(Message)ou@QueryHandler(Message, [<UseCase>Reader])sur le gestionnaire ;repositoryécrit@Repository(InvoiceRepository, [<storage>, ExecutionScope, Clock]), le stockage étantDatabasede la plateforme (drizzle, prisma),KnexContextouTypeOrmContext; l'adaptateur en mémoire d'un port de lecture estin-memory-<name>-reader.repository.ts, un fichier de rôle avec@Repository(<UseCase>Reader);integration-handlerécrit@IntegrationEventHandler(definition, { consumer })et est refusé quand l'amont ne déclare pas l'événement ;event-handleret le publisher deintegration-eventécrivent@DomainEventHandler(Event), rattaché aueventNameque déclare la classe d'événement (le publisher avec[IntegrationEventPublisher, IdGenerator]), et tous deux sont trouvés comme n'importe quel fichier de rôle : la racine de composition ne liste aucun publisher ;jobécrit@Scheduled('<context>.<name>', schedule);policyécrit une classe avec@Policy(permission, { name })et n'enregistre rien ;gatewayécrit@Gateway(<Name>Gateway, [<Name>Contract])et lie la valeur de l'amont dans les régions marquées deplatform/platform.module.tset deplatform/platform-test.module.ts: une valeur ou une fabrique, c'est ce qu'un décorateur ne peut pas dire ;routeajoute sa fonction àpresentation/http/<context>.routes.ts. Une spec de gestionnaire construit son application de test à partir deplatformTestModule, decontextModule('<context>', <context>Declarations), d'un module d'autorisation et de fakes (fake(InvoiceRepository, new FakeInvoiceRepository())). - Le style explicite, genre par genre. Les mêmes fichiers sans décorateurs, et une ligne du
fichier de module chacun :
builder.bind(InvoiceRepository).toClass(DrizzleInvoiceRepository, [Database, ExecutionScope, Clock]).as('repository'),builder.command(IssueInvoiceCommand).requires(...).handledBy(IssueInvoiceHandler, [InvoiceRepository, IdGenerator, Clock]),builder.on(definition, { consumer }).handledBy(Handler, [CommandBus]),builder.job('<context>.<name>', schedule).handledBy(<Name>Job, [CommandBus]); un publisher et un gestionnaire d'événement du domaine sont liés sous eux-mêmes et abonnés dans la régionstart. En câblage manuel (--di manual,--di nest), le fichier de module est une fonction de laPlatform:platform.adapter(new DrizzleInvoiceRepository(...), { kind: 'repository', context, token }), qui donne au repository son span et la distribution de ce qu'il sauvegarde,platform.commands.register(Message, new Handler(...), { context }),platform.jobs.register({ name, schedule, run }). - Les jobs. Le planning est validé quand le job est généré, par les règles du planificateur
(
parseJobScheduledu kernel, fixé par un test) : une durée (15m,2s, des chiffres seuls sont des millisecondes) ou une expression cron à cinq champs, avec un fuseau horaire IANA ;--time-zonesans--cronest refusé, de même qu'un job sans--command, une commande qui n'existe pas (avec la commande qui la crée) et une commande dont le constructeur demande plus que l'acteur et l'instant pour lequel le travail est fait : un job dit seulement que le moment est venu, le gestionnaire trouve sur quoi agir. Les paramètres optionnels ou qui ont une valeur par défaut sont laissés hors de l'appel. - L'autorisation d'un job. Un job dont la commande est protégée l'envoie en tant que
system:<context>.<name>, qui n'a aucun droit tant que le modèle ne lui en donne pas.generate jobécrit l'autorisation là où le modèle en fait une ligne, dans une région marquéesystem-grants, etnextpointe vers cette ligne :--authz rbac, l'identifiant de la permission sous le rôlesystemdeplatform/auth/roles.ts, le rôle quecreateAuthorizerdonne à chaque acteur système (systemRole) ;--authz abac, une règlepermit('system-<job>', [permission], ({ actor }) => actor.id === 'system:<job>')dansplatform/auth/attribute-policies.ts;--authz rebac, le tuplecontext:<context>#operator@system:system:<job>dansplatform/auth/relate-system-actors.ts, écrit dans le magasin de tuples au démarrage de l'application, tant que le schéma laisse encore un sujetsystemopérer un contexte. Avec--authz custom, une région supprimée, un schéma de relations remodelé ou une permission que le générateur ne sait pas lire, rien n'est écrit et le fichier du job porte leTODO(domain)qui nomme l'acteur et la permission. - Le barrel est dérivé.
<context>.declarations.tscontient unexport *par fichier de rôle, trié par chemin, puis uninjected(Class, [Tokens])par classe dont le constructeur reçoit quelque chose, lu depuis sa signature, avec les imports dont ils ont besoin. Chaquegenerate, etdocs, le réécrivent ;docs --checketcheck(declarations/barrel-current) signalent un barrel qu'un fichier de rôle écrit à la main a laissé en arrière ou dont un constructeur a changé depuis, en nommant la classe (stale src/contexts/billing/billing.declarations.ts: the dependency list of IssueInvoiceHandler is stale: ...). Le scriptdevchargeddd-ts-kit/dist/refresh-declarations.jsavant l'application, à chaque démarrage et à chaque rechargement detsx watch: un port ajouté à un constructeur est injecté au rechargement qui suit l'enregistrement du fichier. Le même module est le binaireddd-ts-kit-refresh-declarations. Il est mis en page comme Biome le met en page à une largeur de ligne de 120 ; un projet qui formate à une autre largeur laisse**/*.declarations.tset**/*.dependencies.tshors de son formateur. - Refus par défaut.
commandetquerysont refusés sans--permissionou--public. Une nouvelle permission rejoint<context>.permissions.ts; ses mots sont vérifiés dans le glossaire comme tout autre nom. Avec--authz rebac, l'exigence nomme le contexte comme sa ressource, un point de départ à remplacer par l'élément sur lequel porte le droit. Avec--authz rbac,--grant <role>accorde la permission au rôle dansplatform/auth/roles.ts(le rôle rejointrole.tss'il est nouveau) ; sans lui, une étapenextdit que l'attribution reste à faire (authz/granted). routeremetactorOf(context.principal)à un cas d'usage protégé et posesecurity(le schéma de--authn, oufalsepour un cas d'usage public).integration-handlerportesystemActor('<context>.<name>')pour la commande qu'il construit ; derrière un broker, il écoute sur l'abonné de son contexte (EventSubscribers.forContext), sa marque et son travail dans une seule unité de travail.- Un adaptateur de repository généré ne fait que persister : il prend
ExecutionScopeetClock, jamaisActorContextni un distributeur, et estampillecreated_at,created_by,updated_at,updated_byavecstampCreationetstampUpdate; la table utilise lesauditColumnsdu package de l'ORM (les cinq champs sont écrits en entier pour Prisma). Le fait d'exemple d'un agrégat estregisteredAt, puisquecreated_atest la colonne d'audit.repository --tenant-scoped(drizzle et knex), et chaque repository d'un projet dontstack.tenancyvautshared-table, ajoutetenantColumn, nomme le tenant à l'insertion et atteint la table par les fonctions utilitaires du package de l'ORM (tenantTableetownTenantTablesur Knex,tenantConditionetownTenantConditionsur Drizzle), avec une spec qui vérifie qu'un tenant ne voit pas les lignes d'un autre. Avec un autre ORM, l'adaptateur ordinaire est écrit et une note dit quoi ajouter. adapter --decoratorsest accepté sur toute stack : les décorateurs des aspects servent le mode standard et l'ancien mode (legacy) qu'impose untsconfig.jsonavecexperimentalDecorators.routed'une requête--searchlit la page avecpageQuerySchemaet répond avecpageSchemaOfetpageResponse. Un abonnement d'un gestionnaire d'événement d'intégration qui n'est pas unbuilder.onest écrit dans la régionstartavecidempotent(...)autour deinEventScope(...).- Le langage d'abord. Le nom d'un agrégat, d'une entité, d'un objet-valeur, d'un service du
domaine, d'un événement du domaine, d'une commande ou d'une requête est refusé quand l'un de ses
mots n'est couvert par aucun terme du tableau
## Ubiquitous languagedu contexte (les formes du pluriel, du participe passé et en -ing sont reconnues).--term "<Term>=<meaning>", répétable, ajoute les lignes dans la même commande. Les mots techniques (Manager,Helper,Data,Impl,Model...) et un fait d'événement qui ne se lit pas au passé sont refusés eux aussi.--allow-namepasse outre les trois vérifications ;checksignalera tout de même ce que le glossaire ne définit pas. - Le code généré compile tel quel et passe
check, le preset ESLint, dependency-cruiser et knip aux sévérités par défaut : un export par fichier, des fichiers bien en dessous de 350 lignes, des membresprivate, la disposition des lignes vides. Chaque endroit qui demande une connaissance métier porte un commentaire// TODO(domain): ...qui dit quelle décision est attendue, et un endroit où un adaptateur est à remplacer un commentaire// TODO(platform): ...; les seuls autres commentaires disent ce que le code ne peut pas dire. - Les specs contiennent un vrai test qui passe là où il peut s'écrire de façon générique, et
des entrées
it.todoqui nomment les comportements à couvrir. Aucun mock : des fakes, des builders et l'application de test. Sur tsyringe, une spec de gestionnaire passe parcreateTestApplicationde@ddd-ts-kit/diet observe les faits annoncés avec la sauvegarde parapplication.domainEvents.on('<name>', ...); en câblage manuel, elle passe parcreateTestBuses()de@ddd-ts-kit/testing, dontdispatching(fake)fait que la sauvegarde d'un fake annonce ses événements àdomainEvents. --jsonaffiche{ dryRun, created, updated, skipped, next, todos, notes }.nextliste les endroitsTODO(domain)enfile:line, le glossaire d'abord, puis les lignes qu'une commande a écrites pour que l'équipe les vérifie (l'autorisation d'un job) ;todosajoute leur texte.- Un genre qui a besoin d'autre chose pour compiler soit le crée (un repository ou un événement du domaine manquant), soit s'arrête avec la commande qui le crée (un agrégat, un cas d'usage ou une route manquant).
- L'enregistrement se fait entre des commentaires marqueurs (
// ddd-ts-kit:<region>...// /ddd-ts-kit:<region>, des commentaires HTML en Markdown). Retirez un marqueur, et le générateur affiche ce qu'il faut ajouter à la main. - Chaque
generaterafraîchit la documentation générée et.dependency-cruiser.cjsquand ils existent ; générer deux fois ne change rien. --local-onlyrefuse, en nommant les fichiers et sans rien écrire, quand le passage écrirait hors de<contextsDir>/<context>/et detest/e2e/*.e2e.spec.ts.generate --help --jsonaffiche chaque genre avec sonusage, sesaliases, sonsummaryet sonwritesShared, plussharedetsharedWhen: ce qu'il écrit hors de son contexte, et quand. Les deux servent un build réparti entre des workers (voirworker-scope).- Un fake est un point d'entrée pour knip et peut n'avoir aucun importateur pour dependency-cruiser : un port et son fake générés avant qu'un cas d'usage en ait besoin ne font échouer ni l'un ni l'autre analyseur. Un service du domaine est gardé en vie par la spec générée avec lui.
Les templates sont des fichiers ordinaires dans templates/<kind>/, avec des emplacements
{{name.pascal}}, {{name.camel}}, {{name.kebab}}, {{name.constant}}, {{context.kebab}}
et des blocs {{#if x}} / {{#unless x}}. Un emplacement inconnu est une erreur.
ddd-ts-kit docs [--check] [--stored-events]Régénère ce qui dérive de la configuration et du code, et rien d'autre :
docs/context-map.md, entre des marqueurs<!-- ddd-ts-kit:context-map -->: un diagramme Mermaid (un nœud par contexte, stylé selon sa classification, une flèche par relation, de l'amont vers l'aval, étiquetée du patron) et le tableau des contextes avec la première phrase de leur raison d'être, leurs amonts, leurs avals, les événements qu'ils publient et s'ils sont déployables seuls. Le texte hors des marqueurs est gardé ; à la première création, les quatre sections écrites sont posées avec des indications.- les listes générées de chaque
CONTEXT.md(agrégats, événements publiés, permissions, jobs planifiés en tableau de nom, planning et commande, relations) ; - le fichier généré de chaque contexte déclaré : le barrel
<context>.declarations.ts, avec ce que reçoit chaque constructeur, lu depuis sa signature, ou sous discovery<context>.dependencies.ts, qui ne contient que cela ; - la référence de chaque
CONTEXT.md: les faits dedescribe, en tableaux sous## Reference, entre des marqueursddd-ts-kit:reference; unCONTEXT.mdqui n'a pas cette section la reçoit après sa dernière liste générée ; docs/integration/README.md, créé une fois et jamais réécrit ; sa section Provider commence par la stratégie du broker choisi.
--stored-events enregistre aussi dans stored-events.lock.json la forme de chaque version d'un
événement stocké déclarée dans les codecs de infrastructure/persistence/, sans jamais réécrire
une version enregistrée : lancez-le une fois que la forme d'une version est définitive.
--check n'écrit rien, nomme chaque fichier périmé et chaque version d'événement stocké déclarée
qui n'est pas enregistrée dans stored-events.lock.json, et sort avec le code 1. Le catalogue
des événements et le document AsyncAPI ont besoin d'exécuter le code du projet, c'est pourquoi
ils sont produits par scripts/events.ts (npm run events:generate, qui fait partie de
npm run docs:generate).
ddd-ts-kit sdk [--check] [--dry-run]Génère le client typé de chaque document du contrat OpenAPI, lu depuis les openapi/*.yaml (ou
.json) commités : un package du workspace sous clients/<document> (clients/api pour le
contrat entier), sans dépendance d'exécution. Chaque opération est une méthode de
createClient() et une fonction à elle ; son résultat est typé par statut, un refus par les
codes d'erreur que le contrat liste pour elle (x-error-codes) ; un identifiant de connexion est
donné par schéma de sécurité. Le premier passage enregistre "sdk" dans la configuration
(init --sdk le fait pour un nouveau projet) et câble le projet : le workspace, l'entrée
paths de chaque client dans tsconfig.json, les exclusions de Biome et de knip, les scripts
sdk:generate et sdk:build, et docs:generate, docs:check, ci:docs, ci:build étendus. Le
package.json d'un client garde sa version et les clés qu'une équipe ajoute. Sous npm, tant que
package-lock.json ne liste pas encore un client, il dit de lancer npm install et de commiter
le lockfile, ce que npm ci refuserait. --check n'écrit rien, nomme chaque fichier périmé (et
un client que le lockfile ne connaît pas) et sort avec le code 1. La comparaison avec
openapi-typescript, hey-api et orval, et comment publier un client :
docs/tools/client-sdk.fr.md.
depcruise-config
Section intitulée « depcruise-config »ddd-ts-kit depcruise-config [--check | --write]Affiche la configuration de dependency-cruiser dérivée de ddd-ts-kit.config.json : couches,
langages publiés, amonts déclarés, packages autorisés, cycles et orphelins. --write la
sauvegarde sous .dependency-cruiser.cjs ; --check sort avec le code 1 quand le fichier
manque ou est périmé, ce que npm run depcruise exécute en premier. Modifiez la configuration,
pas le fichier.
value-objects
Section intitulée « value-objects »ddd-ts-kit value-objects [--dry-run]Fait passer les objets-valeurs d'un projet d'un Result à des fabriques qui renvoient la valeur
ou lèvent une erreur ("stack.valueObjects": "throwing"). Ce qu'il réécrit, et ce qu'il laisse à
une personne, est dans CONFIG.fr.md.
controllers
Section intitulée « controllers »ddd-ts-kit controllers [<context>] [--dry-run]Fait passer les routes en fonctions d'un projet, ou d'un contexte, à des classes contrôleurs
("stack.routes": "controllers"). Pour chaque presentation/http/<name>.route.ts :
- le contrôleur est écrit dans
presentation/http/<resource>/<name>/<name>.controller.ts, la ressource étant le premier segment de son chemin : la déclaration garde la méthode, le résumé, les schémas, l'unique réponse avec sa description et les codes d'erreur ; le gestionnaire de la route devienthandle, avec l'acteur lu dans la requête HTTP et la réponse renvoyée sans son statut ; - un groupe de routes par ressource est écrit (
<resource>.routes.ts: son préfixe, les étiquettes de ses routes,Authenticated, etFORBIDDENquand chaque route le déclarait) ; une route avecsecurity: falsene se tient dans aucun groupe et ditguards: [Public]; - les fichiers de schéma nommés d'après la route la suivent (
<name>-body.dto.tsdevient<name>.body.ts), les autres vont dansshared/sous leur nom, et chaque import d'un fichier déplacé est réécrit ; - la spec devient
<name>.controller.spec.tset exécute le contrôleur sans ses gardes ; - dans un projet déclaré, la liste
<context>.routes.ts, sa spec et sa ligne dans la racine de composition sont supprimées ; dans un projet explicite, la liste construit chaque contrôleur à la main ; - la configuration dit
controllersune fois qu'il ne reste plus aucune route en fonction dans les contextes.
Un contexte est converti en entier ou pas du tout. Une route qui n'est pas écrite comme
export function <name>Route(<bus>): Route { return defineRoute(spec, async (input) => { .. }); },
qui déclare plusieurs réponses, ou dont le gestionnaire répond autre chose que { status, body },
est nommée avec la raison, et son contexte est laissé tel quel : les routes en fonctions
continuent de tourner à côté des contrôleurs. La commande se termine par ce qui reste à une
personne : lancer le formateur et ddd-ts-kit docs, déplacer les fonctions utilitaires qu'un
contrôleur a gardées vers un mapper de requête HTTP ou un sérialiseur, donner leurs gardes aux
groupes, tenir les gardes dans les specs.
error-factories
Section intitulée « error-factories »ddd-ts-kit error-factories --planddd-ts-kit error-factories --apply [--dry-run]Fait passer un projet d'une classe par erreur (new SlotTakenError(..)) à des fabriques
statiques sur une classe d'erreur par concept (AppointmentError.slotTaken(..)), en deux étapes,
parce que le concept sur lequel porte un refus est une décision du modèle.
--plan écrit error-factories.plan.json : par contexte, une entrée par classe de
domain/errors/ qui étend DomainError, avec son code, le concept proposé pour elle (l'agrégat
par lequel son nom commence ou finit, vide quand aucun ne convient) et le nom de fabrique qui dit
son code (APPOINTMENT_NOT_FOUND sur Appointment donne notFound). Modifiez-le : chaque
entrée a besoin d'un concept, un terme du glossaire.
--apply suit le plan et refuse une entrée sans concept, un nom de fabrique vague ou un nom qui
ne dit pas le code. Il :
- écrit
domain/errors/<concept>.error.ts, une classe qui étendConceptErroravec une fabrique par classe déplacée là : les paramètres du constructeur, le message qu'il donnait àsuper, et comme faits les paramètres qui sont de simples données (texte, nombres, booléens, dates) ; - supprime chaque classe et sa spec, dont les cas vont sous
describe('<factory>')danstest/unit/<concept>.error.spec.ts; - réécrit chaque fichier sous la racine des sources et sous
test/:new X(..)devient<Concept>Error.<factory>(..),thenError(X)etthenError(X, code)deviennentthenError('<CODE>'),toThrow(X)etexpect.any(X)deviennent une assertion sur le code,toBeInstanceOf(X)devienttoMatchObject({ code }),e instanceof Xdevient<Concept>Error.is(e, '<CODE>'), un import de la classe devient l'import de sa classe de concept, et un type nomme la classe de concept. Une classe du même nom dans deux contextes va au concept du sien. Les commentaires et les chaînes sont laissés tels quels ; - pose
"stack.refusals": "factories"dans la configuration.
Il laisse, et le dit : une classe qui a des membres à elle, lit this dans son constructeur ou
donne des options à super (écrite à la main) ; un endroit où une classe tenait lieu de valeur
qu'il ne sait pas traduire ; une propriété de l'erreur devenue un fait (error.personId est
désormais error.facts.personId) ; une fabrique de plus de trois paramètres. Et toujours : quels
faits un client peut lire (aucun n'est exposé), les fabriques qui prennent encore une phrase
comme seul paramètre, et les faits à vérifier dans les specs. Lancez le formateur ensuite.
Relancé, il ne trouve plus rien à déplacer. --json répond
{ dryRun, moved, written, removed, manual }.
build-aliases
Section intitulée « build-aliases »ddd-ts-kit build-aliases [--project <tsconfig>]La seconde étape de npm run build, après le compilateur. Le compilateur émet un alias d'import
tel qu'il a été écrit (@/platform/errors), et Node ne résout aucun alias et ne complète aucune
extension : cette commande réécrit, dans chaque .js, .mjs, .cjs, .d.ts, .d.mts et
.d.cts sous le répertoire de sortie du tsconfig (tsconfig.build.json par défaut), chaque
spécificateur d'alias en chemin relatif vers le fichier émis qu'il nomme, avec son extension
.js (../../platform/errors.js), afin que node dist/src/main.js s'exécute sans chargeur ni
option.
// dist/src/contexts/billing/domain/aggregates/invoice.aggregate.js, tel que le compilateur l'émetimport { InvoiceError } from '@/contexts/billing/domain/errors/invoice.error';
// la même ligne après ddd-ts-kit build-aliasesimport { InvoiceError } from '../errors/invoice.error.js';Elle lit importAlias et importExtension de la configuration : @/platform/errors nomme
platform/errors.js sous none, @/platform/errors.js le nomme sous js, et aucune autre
écriture n'est devinée.
- Réécrits : le spécificateur de
import ... from,export ... from, unimportnu,import('...')etrequire('...')avec une chaîne littérale,import.meta.resolve('...'), et dans les déclarationsimport type,typeof import('...')etdeclare module '...'. - Exacte : le fichier est analysé, pas fouillé ; une chaîne qui ressemble seulement à un import, dans un autre code, un commentaire, un template literal ou une expression régulière, est laissée telle quelle. Les lignes ne bougent jamais et les source maps ne sont pas touchées.
- Idempotente : un second passage ne réécrit rien.
- Elle sort avec le code 2 et n'écrit rien quand un alias ne nomme aucun fichier émis, quand il
nomme un répertoire (un alias nomme un fichier :
.../published-language/index), quand il est écrit dans une forme que la configuration ne demande pas, quandimport()reçoit un alias complété à l'exécution (un template literal avec une interpolation, une concaténation), ou quand le répertoire de sortie n'existe pas. Chaque refus dit le fichier, la ligne et quoi écrire à la place. - Un package n'est jamais touché, quelle que soit la fin de son sous-chemin
(
zod/v4/core.js,@scope/package/file.js,node:fs).
--json répond { outDir, files, changed, specifiers }. ddd-ts-kit check signale un script
qui compile sans cette étape (imports/alias-declared). Tout le mécanisme :
docs/tools/import-aliases.fr.md.
ddd-ts-kit list [--context <name>]--context restreint la réponse à un contexte. Les contextes avec leur classification, la
taille de leur glossaire, leurs amonts, agrégats, commandes, requêtes, événements du domaine,
événements publiés, gestionnaires d'événements d'intégration, jobs planifiés (nom, planning, la
commande que chacun envoie ; jobs en --json), builders, permissions, politiques, gateways
et s'ils sont déployables seuls, puis les specs de bout en bout. Dans le style déclaré, les cas
d'usage, les gestionnaires, les politiques, les gateways et les adaptateurs sont lus dans les
décorateurs : une commande ou une requête montre ce qu'elle exige (ou public), un gestionnaire
son nom de consommateur et son événement, une politique sa permission, un adaptateur son port ;
--json les ajoute sous declarations. Dans le style explicite, les jobs sont lus dans les
lignes builder.job(...) et platform.jobs.register(...) du fichier de module.
describe
Section intitulée « describe »ddd-ts-kit describe <context>Un contexte sous forme de faits lus dans la configuration et le texte des sources, dans les deux
styles : les cas d'usage avec leur permission (ou public), les routes (méthode, chemin,
identifiant d'opération, cas d'usage, noms de schémas, codes d'erreur, les types de média dans
lesquels elles répondent et qu'elles lisent en plus de JSON, leur formulaire), les événements consommés
et publiés, les ports avec leurs adaptateurs, les politiques, les gateways avec leur amont, les
jobs planifiés, les amonts et les avals. Les listes de champs ne sont pas devinées : elles sont
dans openapi/openapi.yaml et docs/integration/event-catalog.md. docs écrit les mêmes faits
dans chaque CONTEXT.md.
--json affiche un seul objet :
| Clé | Valeur |
|---|---|
name, classification, purpose, deployable, doc |
le contexte, sa raison d'être (vide tant qu'elle n'est pas écrite) et le chemin de son CONTEXT.md |
glossary, aggregates, domainEvents, permissions |
des noms |
upstreams, downstreams |
{ context, relationship } |
useCases |
{ kind, name, permission, file }, kind valant command ou query |
routes |
{ method, path, operationId, useCase, permission, request: [{ part, schema }], responses: [{ status, schema }], errors, file }, et produces, consumes (types de média en plus de JSON) et form (GET /forms/<operationId>) quand la route les déclare |
consumedEvents |
{ type, version, from, handler, consumer, file } |
publishedEvents |
{ type, versions, publishers } |
ports |
{ name, kind, adapters, file }, kind étant celui de sa liaison (repository, external, gateway) |
policies |
{ name, permission, file } |
gateways |
{ name, upstream, adapters, file } |
jobs |
{ name, schedule, command } |
contracts |
{ openapi, eventCatalog } : où sont les listes de champs |
status, todo
Section intitulée « status, todo »ddd-ts-kit statusddd-ts-kit todo [<context>]status affiche la ligne Next: de docs/architecture/plan.md, les lignes de
docs/architecture/slices.md par statut et par responsable, et par contexte les TODO(domain),
TODO(platform) et it.todo restants, puis ceux hors des contextes. Un fichier manquant n'est
pas une erreur. --json affiche { next, slices, remaining } : next et slices valent null
quand leur fichier manque, slices contient { file, rows, byStatus, byOwner }, remaining un
{ context, domain, platform, test } par contexte, et un avec un context vide pour ce qui reste
hors d'eux.
todo liste ces marqueurs en file:line avec leur genre et leur texte, dans le projet ou dans
un contexte ; --json affiche { count, remaining: [{ kind, context, file, line, text }] }.
worker-scope
Section intitulée « worker-scope »ddd-ts-kit worker-scope <context> [--worktree <path>] [--branch <name>] [--dry-run]Prépare la place d'un worker d'un build réparti par contexte : crée un worktree git du projet
(../<project>-<context> par défaut) sur la branche build/<context>, y écrit
.ddd-ts-kit/worker.json ({ "context": "<context>" } ; init ignore .ddd-ts-kit/), et
affiche la consigne du worker : son périmètre, les générateurs qu'il peut lancer, ses tranches
encore à faire d'après docs/architecture/slices.md, les langages publiés qu'il consomme. Un
worktree existant est gardé et seul le fichier est réécrit ; --dry-run ne crée et n'écrit
rien. Il faut un dépôt git avec au moins un commit. Le worker génère ensuite avec
--local-only.
affected
Section intitulée « affected »ddd-ts-kit affected [--base <git-ref>] [--head <git-ref>] [--format text|json|github|env]Ce qu'une pull request ou une merge request doit exécuter, lu dans la carte des contextes et
dans les chemins qui diffèrent entre --base et --head (HEAD par défaut ; la base de fusion
est utilisée quand le clone l'a). Chaque chemin modifié atteint quelque chose :
- un chemin sous un contexte déclaré atteint ce contexte et chaque contexte en aval de lui,
puisque leur code peut lire son langage publié ; un chemin sous son seul
test/atteint ce contexte seul, puisqu'aucun autre contexte ne peut importer ses tests ; - un document n'atteint rien qui s'exécute : un fichier Markdown à la racine, tout ce qui est
sous
docs/,openapi/,.claude/,.github/instructions/ou.github/copilot/(la porte des documents tourne toujours) ; - tout le reste atteint tout : la plateforme, le noyau partagé,
main.ts, la configuration, le lockfile, le pipeline, un répertoire desrc/contexts/que la configuration ne déclare pas.
Sans --base, ou quand git ne sait pas dire ce qui a changé (une référence inconnue, un clone
superficiel sans la base), tout est retenu : c'est ce qu'obtiennent la branche par défaut et
l'exécution nocturne. La sélection n'est jamais plus étroite que les relations déclarées, parce
que context/declared-relationship garde chaque import entre contextes à l'intérieur d'elles, et
que cette règle tourne sur le projet entier dans chaque pipeline.
Dans l'application d'exemple, une branche qui modifie un agrégat de scheduling :
$ npx ddd-ts-kit affected --base mainAffected contexts: scheduling, notification, analytics (the changed contexts and the contexts downstream of them in the context map).Specs: src/contexts/scheduling/**/*.spec.ts src/contexts/notification/**/*.spec.ts src/contexts/analytics/**/*.spec.ts !src/contexts/**Left to the default branch: src/contexts/identity/
$ npx ddd-ts-kit affected --base main --format githubscope=contextscontexts=scheduling,notification,analyticsargs=--passWithNoTestslint=--ignore-pattern src/contexts/identity/--format github écrit des lignes key=value pour $GITHUB_OUTPUT, --format env les mêmes
clés en majuscules derrière AFFECTED_ pour un rapport dotenv de GitLab, --json la sélection
sous forme de données ({ scope, reason, contexts, specs, leftOut }). contexts est ce que
l'exécuteur des tests lit comme DDD_TS_KIT_AFFECTED, args ce qu'une exécution partielle y
ajoute, lint ce qu'elle ajoute à ESLint ; les pipelines générés câblent les trois.
ddd-ts-kit check [paths...] [--rule <id>]... [--format text|json|github|gitlab] [--changed-since <git-ref>] [--self]ddd-ts-kit check --clean-only <dir>... [--rule <id>]... [--format text|json|github]ddd-ts-kit check --commit-size [--staged] [--strict]Chaque violation a un identifiant de règle, une sévérité, un fichier, une ligne, un message et un
fix : une phrase qui dit quoi faire à la place. Code de sortie 1 quand il y a au moins une
erreur. Supposons que l'agrégat Reminder de l'application d'exemple lise l'horloge système dans
son domaine : un isDue() en ligne 63 qui appelle Date.now(), là où le vrai reçoit l'instant,
isDue(asOf: Date). check signalerait :
$ npx ddd-ts-kit check src/contexts/notification/domainsrc/contexts/notification/domain/aggregates/reminder.aggregate.ts:64 error purity/no-system-clock Date.now() is used in the domain layer. fix: Receive the instant as a parameter in the domain, and take it from the Clock port (`this.clock.now()`) in a handler, so that a test fixes time with FixedClock.
1 error, 0 warnings, 0 suppressions in 772 files.Le fix est le changement à faire. Au lieu de cette méthode hypothétique :
isDue(): boolean { return this.state.status === ReminderStatus.Planned && this.state.dueAt.getTime() <= Date.now();}écrivez :
import { Clock, FixedClock } from '@ddd-ts-kit/kernel';
// domaine : l'instant est un paramètre, comme dans l'agrégat Reminder de l'exempleexport class Reminder { constructor(private readonly dueAt: Date) {}
isDue(asOf: Date): boolean { return this.dueAt.getTime() <= asOf.getTime(); }}
// application : le gestionnaire prend l'instant du port Clockexport class SendDueRemindersHandler { constructor(private readonly clock: Clock) {}
isDueNow(reminder: Reminder): boolean { return reminder.isDue(this.clock.now()); }}
// un test fixe le tempsconst handler = new SendDueRemindersHandler(new FixedClock(new Date('2026-03-02T09:00:00Z')));const due = handler.isDueNow(new Reminder(new Date('2026-03-01T09:00:00Z'))); // trueddd-ts-kit explain <rule-id> affiche le même genre de mauvais et de bon exemple pour chaque
règle.
pathset--changed-sincelimitent ce qui est signalé, pas ce qui est analysé.--changed-sincelancegit diff --name-only --relative <ref>...dans le projet.--format githubaffiche des annotations::error file=...,line=...::.--format gitlabaffiche un rapport Code Quality de GitLab : un problème par violation,majorpour une erreur etminorpour un avertissement, avec une empreinte qui ne dépend pas de la ligne.- Les sévérités viennent de
rulesdans la configuration (error,warn,off). Par défauterror, sauftest/no-mocks,test/builder-required,clean/function-size,clean/no-noise-comment,clean/no-flag-argument,standard/no-parallel-interfaceet, tenue par ESLint,application/command-query-separation(warn).anydans un fichier de test est toujours un avertissement. limits: { "maxParameters": 3, "maxFunctionLines": 30 }dans la configuration règleclean/function-size.maxFunctionLinesva de 1 à 100 : une valeur au-dessus est lue comme 100 et signalée parconfig/valid, et une fonction de plus de 100 lignes est uneerrorquelle que soit la sévérité de la règle (seuloffla fait taire).clean/file-size(350 lignes) n'est pas configurable.onePerFile: { "allow": [{ "file": "<path or glob>", "reason": "<why these exports are one thing>" }] }exempte des fichiers du décompte declean/one-export-per-file, jamais de sa vérification du nom de fichier ;*correspond à l'intérieur d'un segment de chemin,**à travers les segments ; une entrée sans raison est ignorée et signalée parconfig/valid.domainAllowedPackages,applicationAllowedPackagesetpublishedLanguageAllowedPackagesétendent ce que les couches intérieures peuvent importer.@ddd-ts-kit/aspects,@ddd-ts-kit/audit,@ddd-ts-kit/redis,@ddd-ts-kit/scheduling,@ddd-ts-kit/sentryet@ddd-ts-kit/tracingrestent refusés dansdomain/etapplication/même quand une liste les nomme.standardInterfaces: { "<DeclaredName>": "<kernel interface to use instead>" }dans la configuration ajoute des noms àstandard/no-parallel-interfaceen plus de ses noms par défaut ; la valeurfalseretire un nom par défaut.- Désactivez une ligne avec
// ddd-ts-kit-disable-next-line <rule-id> -- ADR-0007: <reason>. Sans raison, le commentaire est lui-même une violation, tout comme une raison qui ne nomme aucun enregistrement de décision existant. Sur la première ligne d'un fichier, il couvre aussi les violations au niveau du fichier. --clean-onlyexécute les règles qui jugent un fichier seul (clean/stepdown,clean/function-size,clean/no-commented-out-code,clean/no-noise-comment,clean/no-escape-hatch,clean/no-hash-private,clean/blank-lines,clean/braces,clean/one-export-per-file,clean/file-size) sur n'importe quels répertoires, sans projet ni configuration.--selfrejoue les fixtures de règles livrées avec la CLI et sort avec le code 0 quand chaque règle se comporte comme attendu.
| Règle | Détecte |
|---|---|
layer/domain-purity |
dans domain/ : un package hors de domainAllowedPackages, tout module natif de Node, une autre couche, un autre contexte ; toute entrée de @ddd-ts-kit/aspects, @ddd-ts-kit/audit, @ddd-ts-kit/redis, @ddd-ts-kit/scheduling, @ddd-ts-kit/sentry ou @ddd-ts-kit/tracing, autorisée ou non ; @ddd-ts-kit/kernel/declarations, bien que le kernel soit autorisé : le domaine ne déclare rien |
layer/application-inward |
dans application/ : infrastructure/, presentation/, la plateforme, un package hors de la liste autorisée, un module natif de Node ; toute entrée de @ddd-ts-kit/aspects, @ddd-ts-kit/audit, @ddd-ts-kit/redis, @ddd-ts-kit/scheduling, @ddd-ts-kit/sentry ou @ddd-ts-kit/tracing, autorisée ou non, avec l'endroit où la préoccupation a sa place (la racine de composition, un middleware de bus, un adaptateur, un port) |
layer/presentation-no-infrastructure, layer/infrastructure-no-presentation |
l'import interdit, à l'intérieur d'un contexte |
context/published-language-only |
un import d'un autre contexte hors de son published-language/ |
context/declared-relationship |
un import d'un langage publié dont le contexte n'est pas un amont déclaré |
context/published-language-is-a-contract |
published-language/ qui importe autre chose que sa liste autorisée et d'autres langages publiés |
context/no-cycle |
un cycle dans les amonts déclarés, avec son chemin |
tree/closed |
un répertoire hors de l'arbre canonique et de extraDirectories ; un fichier source hors d'un répertoire de rôle ; dans le style déclaré, <context>.declarations.ts est accepté à la racine d'un contexte |
tree/context-doc |
un CONTEXT.md manquant, une section manquante, une classification qui contredit la configuration |
naming/suffix |
un fichier sans le suffixe de son rôle, ou pas en kebab-case. application/scheduling/ contient des *.job.ts ; un dossier de cas d'usage accepte un *.result.ts à côté de son message et de son gestionnaire ; presentation/http/<context>.routes.ts, nommé d'après son contexte au lieu d'un rôle, est accepté dans les deux styles |
naming/one-concept-per-file |
plusieurs classes exportées dans un fichier d'agrégat, d'entité, d'objet-valeur, d'événement, de commande, de requête, de gestionnaire, de gestionnaire d'événement, de gestionnaire d'événement d'intégration ou de job ; dans le style déclaré, plusieurs classes portant un décorateur de déclaration dans un même fichier de rôle |
naming/search-not-list |
dans un contexte, une méthode nommée list ou list<Something> dans un port (domain/repositories/*.repository.ts, application/ports/*.port.ts), dans une classe de infrastructure/ ou dans un fake de test/fakes/ ; sous application/queries/, un dossier ou un fichier nommé list, list-... ou list...., ou une classe nommée List ou List<Something>. Les propriétés et listAccessible (exigée par le port Authorizer) ne sont pas jugées. Nommez-les search... : generate query <context> Search<Things> --search |
naming/business-name |
dans domain/ et application/ d'un contexte et dans le noyau partagé hors de presentation/, une variable, une variable de boucle, un nom déstructuré, un paramètre ou une propriété nommés d'un mot générique (target, data, item et les autres mots de la liste, que businessNames de la configuration change) ; le correctif nomme le concept qu'un initialiseur construit ou lit quand le texte le dit, et npx eslint --fix le renomme |
test/spec-required |
un agrégat, un objet-valeur, un service du domaine, un gestionnaire, un gestionnaire d'événement du domaine, un gestionnaire d'événement d'intégration ou un job sans spec (un *.result.ts n'en a pas besoin) |
test/no-mocks |
vi.mock, vi.fn, vi.spyOn, leurs jumeaux jest, ou une bibliothèque de mocks dans les tests d'un contexte |
test/builder-required |
un agrégat sans test/builders/<name>.builder.ts |
events/name-format |
un nom d'événement qui n'est pas <context>.<aggregate>.<fact> en kebab-case avec le bon contexte |
events/name-declared-once |
une classe de domain/events/ sans static readonly eventName, ou dont le readonly name écrit le nom une seconde fois au lieu de le lire |
events/defined-in-published-language |
defineIntegrationEvent hors d'un langage publié, sans charge utile, deux fois pour une même version ; un événement décrit à la main |
language/glossary-term |
une classe du modèle nommée avec un mot que le glossaire de son contexte ne définit pas ; un glossaire vide ; sous "stack.refusals": "factories", une classe d'erreur de domain/errors/ aussi |
language/declared-vocabulary |
une chaîne littérale dont la valeur est une valeur d'un vocabulaire fermé que le projet déclare (un objet as const exporté avec le type union de son nom, un ensemble de permissions de definePermissions), hors du fichier qui le déclare, à l'endroit qui parle son langage (son contexte ; la plateforme et les groupes de routes pour la plateforme ; partout pour un identifiant de permission ou le noyau partagé) ; les tests, les langages publiés et le schéma de relations ne sont pas jugés ; sous "stack.literals": "named" |
language/no-technical-name |
Manager, Helper, Util, Utils, Data, Info, Processor, Wrapper, Impl, Base, Abstract, Dto, Entity, Model dans un type déclaré sous domain/ ou dans un dossier de cas d'usage |
purity/no-system-clock |
new Date() sans argument, Date.now(), performance.now() dans domain/ ou application/ |
purity/no-ambient-randomness |
Math.random, la source aléatoire de crypto, une bibliothèque d'identifiants |
purity/no-environment |
process.env et apparentés, console, fetch, les modules de système de fichiers et de réseau |
clean/stepdown |
une méthode privée avant la dernière méthode publique ou au-dessus de son premier appelant ; une fonction non exportée au-dessus de sa première utilisation |
clean/function-size |
plus de paramètres ou de lignes de corps que les limites (les constructeurs sont exemptés de la limite de paramètres, les fichiers de module et les callbacks de describe de la limite de lignes) ; toute fonction de plus de 100 lignes, celles-là comprises, signalée comme une error |
clean/no-commented-out-code |
des lignes de commentaire qui ont la forme d'instructions |
clean/no-noise-comment |
les bannières, les étiquettes d'accolades fermantes, les auteurs et les dates, les @param et @returns vides, un commentaire qui ne fait que répéter le nom déclaré, un TODO sans responsable ni référence |
clean/no-escape-hatch |
any, une assertion de non-nullité, @ts-ignore, @ts-nocheck, @ts-expect-error ou eslint-disable sans raison |
clean/no-flag-argument |
un paramètre boolean sur une méthode publique d'une classe du domaine ou sur execute |
clean/no-hash-private |
un membre de classe #name : champ, méthode, accesseur, statique |
clean/blank-lines |
une approximation de la disposition que tiennent ESLint et le formateur : un return dont la ligne précédente n'est ni vide, ni une accolade ouvrante, ni une étiquette case ; une ligne vide juste après une accolade ouvrante ou juste avant une accolade fermante |
clean/braces |
un if, else, for, while ou do dont le corps n'a pas d'accolades ou partage une ligne avec elles, un else sur une ligne à lui ; une approximation de ce que tiennent ESLint (curly) et le formateur |
clean/one-export-per-file |
sous la racine des sources, tests à part : plus d'une classe, interface ou fonction exportée que les tolérances n'expliquent pas (les types de la signature de l'export principal, un type et une valeur qui partagent un nom, un type avec ses constructeurs, les accesseurs d'un même état privé, onePerFile.allow) ; un fichier qui ne porte pas le nom de son export principal en kebab-case (invoice.aggregate.ts pour Invoice) ; un index.ts ou un <context>.declarations.ts qui contient autre chose que des réexports |
clean/file-size |
un fichier de plus de 350 lignes, sources et tests ; un chemin avec un segment generated et les fichiers de déclaration sont exemptés |
composition/only-in-module |
new X, ou bind(..).toClass(X, ..) et rebind(..).toClass(X, ..), d'une classe importée de infrastructure/, hors du fichier de module, de main.ts, de la plateforme et des tests ; dans un fichier qui importe @ddd-ts-kit/di, un appel de contextModule, discoverContext ou .include( hors de ces endroits et de apps/<context>/main.ts |
imports/alias-only |
sous la racine des sources, test/ et scripts/ : un import, export ... from ou import() avec un spécificateur relatif, même répertoire compris (la correction nomme l'alias à écrire ; eslint --fix l'écrit) ; un spécificateur d'alias avec un segment . ou .. |
imports/extension |
sous la racine des sources, test/ et scripts/ : avec importExtension none, un spécificateur d'alias qui se termine par .js, .mjs, .cjs, .ts, .mts ou .cts, ou qui nomme un répertoire contenant un index.ts ; avec js (ou sans clé), un spécificateur d'alias qui ne se termine pas par .js (la correction nomme le spécificateur à écrire ; eslint --fix l'écrit). Un sous-chemin de package n'est jamais jugé |
imports/alias-declared |
un importAlias qui n'est pas @/ ou $<name>/, ou qui est $test/, $scripts/, $generated/ ; une entrée paths de tsconfig.json manquante ou différente de ce que donne importAlias (la correction donne l'entrée) ; une autre entrée paths sous un alias ; un baseUrl ; un tsconfig.build.json dont les paths diffèrent ; des paths de knip.json qui associent un alias ; un script qui compile (tsc sans --noEmit) et ne lance pas ddd-ts-kit build-aliases ensuite ; un script qui lance tsx --tsconfig sur un autre fichier ; une configuration vitest à la racine sans tsconfigPaths: true, une configuration jest sans les entrées moduleNameMapper dérivées, ou .dependency-cruiser.cjs sans tsConfig ; un module et un moduleResolution de tsconfig.json (ou d'un tsconfig.build.json qui pose les siens) qui ne sont pas ce que donne importExtension : ESNext, Bundler et customConditions contenant node sous none, et sous js une valeur écrite qui n'est pas une résolution Node ; une association jest dont le motif ne se termine pas comme les imports. Un projet sans package.json n'est pas jugé |
config/valid |
un amont, une classification, une relation, un identifiant de règle, un mode de guard ou un importExtension inconnu ; un contexte absent du disque ou de la configuration ; une limite de commit qui n'est pas un entier positif |
config/suppression-needs-reason |
une désactivation sans identifiant de règle ou sans raison (gardée comme alias à côté de governance/recorded-deviation, qui juge la raison) |
authz/declared |
dans un fichier de module, builder.command(X) ou builder.query(X) sans .requires( ni .public( dans sa chaîne, sauf si la classe du message porte @Requires ou @Public de @ddd-ts-kit/kernel/declarations ; en câblage manuel ou nest, une classe de commande ou de requête absente de tous les requires(X, ...) et publicUseCase(X) du fichier de module et de application/authorization/ ; un defineRoute sans security (le type de la route le refuse déjà, et une liste vide aussi : c'est le filet au niveau du texte pour une définition construite de façon lâche) ; dans le style déclaré, la classe exportée d'un fichier *.command.ts ou *.query.ts qui ne porte ni @Requires ni @Public |
authz/granted |
avec stack.authz rbac, une permission du definePermissions d'un contexte que ni platform/auth/roles.ts ni platform/auth/signed-in-grants.ts n'accorde par son nom (par l'entrée de son ensemble, son identifiant ou <contexte>:*) ; le * du super-administrateur ne compte pas (un avertissement) |
authz/in-application |
presentation/ : un test sur les rôles, isAdmin, l'import d'un autorisateur ou de Access ; infrastructure/ : authorize( ou check( sur un autorisateur ou un accès, ForbiddenError ; domain/ : tout import de @ddd-ts-kit/auth, des types d'acteur du kernel (Actor, ActorContext, Session, ExecutionContext, les messages autorisés), une lecture de .roles ou .permissions sur un acteur. La glu de requête HTTP de @ddd-ts-kit/auth (actorOf, sessionOf, resolveSession, SessionAuthenticator) est libre dans presentation/. presentation/ et infrastructure/ : l'import de Requires, Public ou Policy depuis @ddd-ts-kit/kernel/declarations, ou des ports Access et PolicyAccess depuis @ddd-ts-kit/auth ; ils sont libres dans application/ |
authz/actor-explicit |
dans domain/ et application/ : ActorContext, ExecutionScope (comme type ou comme valeur : la classe est le jeton d'une liste de dépendances), AsyncLocalStorageExecutionScope, ScopedActorContext, AsyncLocalStorage ; .current() ou .require() sur un contexte d'acteur ou sur quelque chose nommé scope, scopes ou ...executionScope ; un import de stampCreation, stampUpdate ou tenantScopeOf |
authz/fields-by-schema |
sous application/queries/ : roles.includes(, .some(, .every(, .find(, .indexOf(, une comparaison avec un role, isAdmin |
aspects/outside-in |
un import de @ddd-ts-kit/aspects/decorators hors de infrastructure/ d'un contexte et du répertoire de la plateforme : presentation/, le fichier de module, published-language/, le noyau partagé, main.ts, toute autre source (domain/ et application/ sont signalés par les deux règles layer/* à la place) |
tracing/platform-only |
un import de @ddd-ts-kit/tracing ou d'un package @opentelemetry/* hors du répertoire de la plateforme, de main.ts et de apps/, tests compris |
tenancy/scoped-statement |
avec stack.tenancy autre que none, dans infrastructure/ d'un contexte, tests mis à part : sur Knex, l'exécuteur utilisé autrement que comme argument (executor()(, executor().from(, executor().raw(, const db = ...executor();) et tout .raw( ; sur Drizzle, instruction par instruction, une lecture, une mise à jour ou une suppression sur l'exécuteur dont le where n'a ni tenantCondition( ni ownTenantCondition( (en ligne ou dans la variable locale qu'il reçoit), une insertion dont la ligne n'a pas de tenantScopeOf(, une requête relationnelle (executor().query), un execute( brut, ou l'exécuteur gardé dans une variable ; sur les deux, un magasin de modèle de lecture construit sans son option tenant. Un where construit par une autre fonction n'est pas suivi |
standard/no-parallel-interface |
une classe, une interface ou un alias de type déclaré comme ILogger, AppLogger, LoggerService, LoggerInterface, IMetrics, MetricsService, ICache, CacheService, UserContext, CurrentUser, AuthContext, RequestContext (hors de presentation/ et de la plateforme), UserSession, AuthSession, SessionData, PaginatedResult, PageResult, PaginatedResponse, Paginated, PaginationParams, PageParams, AuditLog, AuditLogEntry, AuditRecord, IJobScheduler, JobSchedulerService, CronScheduler, TaskScheduler, IScheduledJob, CronJob, ScheduledTask, BackgroundJob, IErrorReporter, ErrorReporterService, ErrorTracker, ExceptionReporter, CrashReporter, CacheInvalidator, CacheInvalidationService, CachePurgeService, CdnPurger, I suivi d'un nom standard (IClock, IIdGenerator, IUnitOfWork, ISession, ...) ou un nom de standardInterfaces ; une classe qui implémente ou étend l'interface standard est un adaptateur et n'est pas signalée |
events/outbox-required |
stack.events autre que outbox avec un ORM et sans dérogation stack.events ; dans domain/ et application/, l'import d'un client de broker ou d'une implémentation de bus ou de relais ; un publisher qui dépend de plus que IntegrationEventPublisher ; un module de la plateforme qui distribue un publisher et ne nomme jamais OutboxPublisher |
events/consumer-idempotent |
une classe de gestionnaire d'événement d'intégration (application/integration/handlers/*.integration-handler.ts) qui ne porte pas @IntegrationEventHandler et n'est pas non plus enregistrée dans son fichier de module sous un nom de consommateur ; un @IntegrationEventHandler dont le consumer n'est pas une chaîne littérale non vide ; un on( ou un consume( libre sans processed ; un nom de consommateur utilisé deux fois, dans les déclarations comme dans les enregistrements |
domain-events/dispatched-on-save |
un appel de pullEvents() dans application/ ou infrastructure/ d'un contexte ; DomainEventDispatcher importé, listé comme dépendance ou reçu par un constructeur dans application/commands/, application/queries/ ou un adaptateur de repository. Le fichier de module, les tests, la plateforme et main.ts sont laissés tranquilles |
domain-events/transactional-before-commit |
un gestionnaire d'événement du domaine (une classe de application/event-handlers/*.event-handler.ts ou de application/integration/publishers/*.publisher.ts, ou toute classe portant @DomainEventHandler) qui s'exécute avant la validation (commit) et dépend de Cache, CachePurger, Lock, RateLimiter, JobScheduler ou EventBus du kernel, d'un port de application/ports/ qui n'est pas un port de persistance (aucun adaptateur sous infrastructure/persistence/, ou un adaptateur ailleurs sous infrastructure/), de quoi que ce soit importé de infrastructure/, d'un autre contexte, de la plateforme ou d'un package autre que @ddd-ts-kit/kernel et @ddd-ts-kit/auth ; un gestionnaire après la validation (phase: 'after-commit' dans son décorateur, ou dans le troisième argument de chaque .on( du fichier de module qui le mentionne) qui dépend de IntegrationEventPublisher. check-file laisse les adaptateurs d'un port à la vérification complète |
event-sourcing/state-in-apply |
dans une classe de domain/aggregates/ qui étend EventSourcedAggregate, une écriture sur quelque chose de this (affectation, incrément, appel qui modifie) hors du constructeur, des méthodes statiques, des méthodes apply<Fact> et de restore ; this.record(..) dans une méthode apply |
event-sourcing/past-tense |
dans un fichier de domain/aggregates/ qui contient un agrégat en event sourcing, un nom importé de domain/events/ dont le fait (les mots après le nom de l'agrégat) ne contient aucun participe passé, ou seulement un participe qui s'écrit comme son impératif en tête (SetPrice) |
event-sourcing/store-behind-repository |
EventStore, SnapshotStore, CheckpointStore, EventSourcedRepository, EventSerializer, CatchUpSubscription ou InMemoryEventStore importé dans domain/ ou application/ |
event-sourcing/projection-never-appends |
dans application/projections/ ou un fichier qui déclare @Projection, EventStore ou EventSourcedRepository importé ; dans une projection déclarée { feed: 'event-store' }, tout ce qui vient de domain/repositories/ aussi |
event-sourcing/upcaster-required |
un defineStoredEvent dont encode ne nomme aucun type, ou un type non déclaré dans le fichier, ou un type qu'il atteint et que le fichier ne déclare pas ; une version au-dessus de 1 sans upcaster depuis chaque version inférieure ; une version en dessous de la dernière enregistrée dans stored-events.lock.json, ou une version enregistrée dont la forme a changé. ddd-ts-kit docs --stored-events enregistre les nouvelles versions et n'en réécrit jamais une ; docs --check échoue tant qu'une version déclarée n'est pas enregistrée |
context/extractable |
un fichier de module qui importe un autre contexte hors de son langage publié ; une persistance qui importe l'infrastructure d'un autre contexte ou nomme son schéma ; un gestionnaire qui importe le repository d'un autre contexte ; export let ou un new Map, Set, WeakMap exporté au niveau du module ; deployable: true sans apps/<context>/main.ts ; dans le style déclaré, un fichier de rôle qui importe quoi que ce soit d'un fichier de port d'un autre contexte |
governance/recorded-deviation |
un commentaire ddd-ts-kit-disable ou eslint-disable dont la raison ne nomme aucun ADR-<number> avec un fichier docs/adr/<number>-*.md ; une règle mise à off (ou à warn en dessous de sa valeur par défaut), un répertoire supplémentaire, un package autorisé supplémentaire, un guard autre que enforce ou stack.events hors de l'outbox, sans entrée deviations { what, adr, reason } qui pointe vers un enregistrement existant |
declarations/role-declared |
style déclaré seulement : un fichier de rôle (autre que *.command.ts et *.query.ts, tenus par authz/declared ; *.job.ts sous application/scheduling/ avec @Scheduled et *.publisher.ts avec @DomainEventHandler sont des rôles) qui n'exporte aucune classe portant le décorateur de son suffixe et de son dossier, ou une classe qui en porte une autre ; une classe déclarée dans un fichier qui n'est pas un fichier de rôle ; une option consumer ou name manquante, vide, une variable, une concaténation ou un template literal avec une substitution ; tout décorateur de déclaration sur une classe sous domain/ |
declarations/barrel-current |
style déclaré seulement : avec stack.assembly: barrel, un <context>.declarations.ts manquant ou qui diffère d'un export * par fichier de rôle trié par chemin, suivi d'un injected(Class, [Tokens]) par classe dont le constructeur reçoit quelque chose (lu depuis les signatures, avec leurs imports) ; avec discovery, un barrel qui existe encore, et un <context>.dependencies.ts qui diffère de ces instructions injected (son absence est acceptée tant qu'il n'en contiendrait aucune). Le message nomme le fichier de rôle manquant ou la classe dont la liste est périmée. Elle a besoin de l'arbre, c'est pourquoi check-file ne l'exécute pas |
declarations/constructor-injection |
style déclaré seulement, dans un fichier de rôle : sur une classe dont le décorateur ne liste rien, un paramètre de constructeur optionnel, qui a une valeur par défaut, est un paramètre du reste ou est déstructuré, n'a pas de type, ou est typé par autre chose qu'un nom de classe (arguments de type mis à part) : une union, une intersection, un tableau, un nom qualifié, un type du langage, une interface, un alias de type, une enum ou une classe non exportée du fichier ou du fichier du projet d'où elle est importée ; une classe qui hérite son constructeur d'une classe que le projet ne sait pas lire. Sous stack.dependencies: signature, un décorateur qui porte une liste alors que le constructeur peut être lu : une liste qui le répète, une qui en diffère |
ports/abstract-class |
dans un fichier de port (domain/repositories/*.repository.ts, application/ports/*.port.ts) : une classe qui n'est pas abstract ; un membre qui n'est pas une méthode ou une propriété abstraite (static readonly tokenKey = '<text>' mis à part) : un constructeur, un corps, un initialiseur, un membre private, protected ou # ; une interface exportée (un alias type, une forme de données, n'est pas signalé) |
ports/implements-never-extends |
une classe portant @Repository(P), @Gateway(P) ou @Adapter(P), ou donnée à bind(P).toClass(C) ou rebind(P).toClass(C), sans implements P ou avec extends P ; sous infrastructure/, une classe qui étend une classe importée d'un fichier de port |
ports/no-token-constant |
un appel de token importé d'un package @ddd-ts-kit/* ; Symbol(..), Symbol.for(..) ou une chaîne littérale comme premier argument de bind, rebind, resolve, fake ou override dans un fichier qui importe @ddd-ts-kit/di ; un nom en UPPER_SNAKE_CASE dans une liste de dépendances ; un fichier nommé *.ports.ts ou tokens.ts |
ports/no-concrete-dependency |
une liste de dépendances qui nomme une classe importée de infrastructure/ ou de test/fakes/ d'un contexte ; dans le style déclaré, un paramètre de constructeur d'une classe déclarée qui ne liste rien, typé par une telle classe |
ports/value-import |
une classe nommée dans une liste de dépendances, ou donnée à bind ou rebind, importée avec import type ou import { type X } |
scheduling/job-triggers-use-case |
dans application/scheduling/*.job.ts : une classe exportée qui n'implémente pas ScheduledJob ; un run qui n'envoie aucune commande (pas d'appel .execute() ; un repository dans la liste de dépendances ou le constructeur ; un import de domain/aggregates/, domain/entities/, domain/services/ ou infrastructure/. Un enregistrement builder.job(..) d'une classe qui vit ailleurs |
scheduling/job-name |
le premier argument de @Scheduled(..) ou de .job(..) qui n'est pas une chaîne littérale <context>.<job-name> en kebab-case minuscule avec le contexte de son fichier ; un systemActor(..) d'un fichier de job qui passe un autre nom ; un même nom de job utilisé deux fois dans le projet |
errors/code-unique |
sous "stack.refusals": "factories", dans le domain/errors/ d'un contexte : un code posé une seconde fois, par une fabrique (refusal('CODE', ..)) ou par une classe (readonly code = 'CODE'), avec le fichier et la ligne du premier. Le même code dans deux contextes n'est pas signalé |
schema/presentation-only |
sous "stack.schemas": "shared" : dans le noyau partagé hors de presentation/, un import d'une bibliothèque de validation ; dans domain/, application/, infrastructure/, published-language/ et le noyau partagé hors de presentation/, un import d'un fichier de presentation/ du noyau partagé |
schema/no-duplicate-shape |
sous "stack.schemas": "shared", dans la couche présentation d'un contexte, ou dans celle-ci et presentation/ du noyau partagé : une classe DTO ou un schéma objet (z.object, v.object, Type.Object) qui déclare la forme d'un autre, avec l'endroit de l'original (le noyau partagé, puis presentation/http/shared/) ; deux contextes ne sont pas comparés, et les paramètres de requête HTTP et les formes qui portent encore un TODO(domain) du générateur ne sont pas jugés |
errors/known-code |
sous "stack.refusals": "factories", dans un *.route.ts : un code de la liste errors qu'aucun refus du contexte de la route ne pose, qu'aucune classe hors des contextes ne pose, et qui n'est pas un code du kit |
domain/value-object-class |
sous "stack.values": "classes" : dans domain/value-objects/ d'un contexte ou un *.vo.ts du noyau partagé hors de presentation/, aucune classe exportée ou une seconde, une classe qui n'étend ni ValueObject ni un autre objet-valeur du projet, un constructeur public, une classe concrète sans méthode statique, une fonction, une constante fléchée ou un objet littéral exporté (en ligne, par une liste ou par défaut) ; dans domain/ et dans le noyau partagé hors de presentation/, une fonction exportée qui répond un enregistrement de données : des entrées de données, aucune décomposition, rien qu'un appel du modèle a construit |
D'autres identifiants appartiennent à @ddd-ts-kit/eslint-plugin, qui lit l'arbre syntaxique là
où ce checker lit du texte : domain/aggregate-encapsulation,
domain/value-object-immutability, domain/no-generic-error, application/handler-shape,
application/command-query-separation et clean/no-magic-number parmi eux (ddd-ts-kit rules
les liste tous). rules et explain les montrent comme « checked by @ddd-ts-kit/eslint-plugin »,
la configuration les accepte sous rules, et check --rule sur l'un d'eux répond avec
npm run lint. Un identifiant, clean/formatted, appartient au formateur : rules et explain
le montrent comme « checked by Biome », et check --rule sur lui répond avec
npm run format:check.
guard dans la configuration (enforce, advise ou off) dit au hook d'éditeur du plugin
comment réagir à une violation après une écriture : la refuser, seulement la signaler, ou rester
silencieux. check l'ignore, en dehors de la validation de sa valeur.
Taille des commits
Section intitulée « Taille des commits »check --commit-size [--staged] [--strict] compte les fichiers et les lignes sur le point d'être
commités (les changements suivis depuis HEAD et les fichiers non suivis ; l'index seulement avec
--staged) par rapport à commit dans la configuration ({ "maxFiles": 20, "maxLines": 600 }
par défaut). Les documents générés, les lockfiles, .dependency-cruiser.cjs,
docs/context-map.md, les barrels de déclarations et .ddd-ts-kit/worker.json ne sont pas
comptés ; un fichier non suivi de plus de 1 Mo ne compte aucune ligne. Il nomme les tranches
auxquelles appartiennent les changements : un cas d'usage, une couche d'un contexte, la
plateforme. Indicatif : code de sortie 0, sauf avec --strict au-delà des limites. Hors d'un
dépôt git, il le dit et sort avec le code 0. Le lefthook.yml généré l'exécute sur l'index à
chaque commit, sans bloquer.
Le checker lit les sources avec un petit analyseur lexical et une lecture superficielle des
déclarations, pas avec un compilateur. Il suit les spécificateurs relatifs, les alias du projet
(importAlias, $test/, $scripts/, $generated/) et les packages nus, et classe les
fichiers d'après leur chemin : un import par alias est jugé par chaque règle de frontière
exactement comme le chemin relatif vers le même fichier.
- Les commentaires, les chaînes, les template literals et les expressions régulières sont
sautés ; une expression régulière dans une position inhabituelle peut masquer le reste de sa
ligne. La syntaxe
.tsxn'est pas comprise. - Les réexports, les barrels, les
pathsdetsconfigautres que l'alias et les spécificateurs calculés ne sont pas résolus, si bien qu'un type qui atteint une couche par le barrel d'une couche autorisée n'est pas vu. - Déclarations : les déclarations
functionde premier niveau et les méthodes de classe sont lues ; les fonctions fléchées affectées à des constantes ou à des propriétés, les méthodes de littéraux d'objet et les fonctions imbriquées ne le sont pas, si bien queclean/stepdown,clean/function-sizeetclean/no-flag-argumentne les jugent pas. clean/stepdownreconnaît les utilisations par leur nom : une fonction utilitaire masquée par une variable locale du même nom, ou une méthode privée appelée par un autre receveur quethis, est mal jugée. Les méthodes protégées sont neutres.clean/no-commented-out-codeetclean/no-noise-commentsont des heuristiques sur la forme d'une ligne : de la prose qui se termine comme une instruction peut être signalée, du code écrit comme une phrase passe inaperçu.language/glossary-termcompare mot à mot avec une flexion anglaise grossière ; elle ne connaît pas les synonymes, et un terme de plusieurs mots couvre chacun de ses mots séparément.purity/*cherche les jetons et les modules listés ; une enveloppe autour d'eux passe inaperçue.clean/no-escape-hatchne voit pas unanyproduit par inférence, ni une assertion de type vers un autre type queany.composition/only-in-modulevoitnewettoClasssur des noms importés ; une fonction fabrique ou un adaptateur réexporté lui échappe.events/name-formatvérifie la forme et le préfixe du contexte, pas la grammaire du fait.test/spec-requiredettest/builder-requiredvérifient que le fichier existe, pas son contenu.authz/*,events/*etcontext/extractablereconnaissent des noms et des formes : un import renommé, une définition de route construite à partir d'une variable ou d'un étalement, un test de propriété (invoice.ownerId === actor.id), un nom de schéma dans du SQL brut, une transaction entre contextes et un tableau ou un objet exporté modifiable passent inaperçus ; le contenu deapps/<context>/main.tsn'est pas jugé.governance/recorded-deviationvérifie que l'enregistrement de décision existe, pas ce qu'il dit ; une entrée de dérogation qui ne justifie plus rien n'est pas signalée, etlimitsetstandardInterfacesne sont pas gouvernés.authz/actor-explicitne connaît aucun type :.current()ou.require()sans argument sur un concept du domaine nomméscopeest signalé, une portée d'exécution gardée sous un autre nom ne l'est pas, etstampCreation,stampUpdateettenantScopeOfne sont vus que lorsqu'ils sont importés sous leur propre nom.aspects/outside-inet les packages extérieurs des règleslayer/*sont reconnus sur le spécificateur d'import : un décorateur ou un client réexporté par un fichier d'un endroit autorisé passe inaperçu.standard/no-parallel-interfacecompare des noms déclarés entiers avec un tableau et ne lit pas le glossaire : un concept du domaine légitimement appeléAuditLogouCurrentUserest signalé (retirez le nom dansstandardInterfacesou enregistrez une désactivation), une copie sous n'importe quel autre nom ne l'est pas, et l'exemption d'une classe lit les noms aprèsimplementsetextends, pas ce vers quoi ils se résolvent.- Un décorateur de déclaration est vu quand il se trouve sur une classe de premier niveau et que
son propre nom est importé de
@ddd-ts-kit/kernel/declarationsdans le même fichier. Un import renommé (Requires as Needs), un import d'espace de noms, un décorateur réexporté par un autre module, appliqué par une fonction utilitaire ou appelé comme une fonction, et un décorateur sur une expression de classe ou une classe imbriquée passent inaperçus (ESLint suit les renommages et les espaces de noms). Les optionsconsumeretnamene sont lues que dans un littéral d'objet écrit dans l'appel du décorateur : gardées dans une variable, étalées ou construites par une fonction, elles sont signalées comme n'étant pas une chaîne littérale. authz/declaredsuit un import direct du message depuis un fichier de module.context/extractablereconnaît un port à son fichier (domain/repositories/*.repository.ts,application/ports/*.port.ts) : un port d'un autre contexte atteint par un réexport n'est pas vu.composition/only-in-modulesignale chaque.include(d'un fichier qui importe@ddd-ts-kit/dihors des endroits de composition, quel que soit le receveur.declarations/barrel-currentcompare le texte exactement (fins de ligne mises à part) : un barrel reformaté est périmé. Elle ne vérifie pas que la racine de composition importe le barrel.clean/blank-linestient la partie bon marché de la disposition, lereturnet le bloc rembourré ; la ligne vide après un groupe de déclarations, autour deif,for,tryet d'une instruction sur plusieurs lignes et entre les membres d'une classe est tenue par ESLint (@stylistic), qui fait autorité et la corrige.clean/braceslit les instructions de contrôle comme du texte : une méthode ou une propriété nomméeif,for,while,elseoudoest distinguée par ce qui la suit, pas par un arbre syntaxique. ESLint (curly) fait autorité pour les accolades et le formateur pour leur emplacement.clean/formattedest listée et expliquée, jamais jugée : le formateur du projet, Biome, en décide (npm run format:check).clean/one-export-per-filelit les déclarations comme du texte : les types d'une signature sont les interfaces et les types exportés dont le nom apparaît dans le texte de l'export principal, une famille fermée est reconnue aux annotations de type de retour et aux noms des liaisons de niveau module. Un fichier que les deux analyseurs jugent différemment revient à ESLint ; ce que ni l'un ni l'autre n'explique va dansonePerFile.allow.- Le plafond de 100 lignes de
clean/function-sizeest jugé sur les fonctions que lit ce checker : une fonction fléchée et le callback d'undescribesont tenus par ESLint (clean-function-ceiling). - Les règles
ports/*lisent une liste de dépendances là où elle est écrite comme un littéral de tableau : le dernier argument d'un décorateur de déclaration, le second de.toClass(..)et de.handledBy(..). Une liste gardée dans une constante ou construite par une fonction n'est pas lue.bind,rebind,resolve,fakeetoverridene sont des appels de liaison que dans un fichier qui importe@ddd-ts-kit/di.ports/implements-never-extendslit la classe liée par un import direct relatif ou par alias ; une classe d'un package ou derrière un barrel n'est pas jugée. - Le constructeur d'une classe déclarée est lu comme du texte, par le scanner du checker : le
constructeur de la classe exportée qui porte le décorateur de son fichier de rôle, le sien ou le
plus proche dont elle hérite par des imports du projet. Un type importé d'un package est
présumé être une classe (le compilateur juge le fichier généré, qui l'importe comme une
valeur) ; un type atteint par un barrel est présumé l'être aussi. Un constructeur surchargé par
des signatures sans corps, ou construit par un mixin, n'est pas lu : écrivez un constructeur
simple.
declarations/barrel-currentcompare le texte exactement, si bien que le fichier généré n'est jamais reformaté à la main. scheduling/job-triggers-use-caseprend n'importe quel appel.execute(dansrunpour une commande envoyée ; un job qui exécute autre chose, ou qui envoie sa commande depuis une fonction utilitaire, est mal jugé.scheduling/job-namecompare des noms littéraux : un job enregistré sous un nom calculé est signalé pour son nom et échappe à la vérification d'unicité.domain-events/transactional-before-commitlit les dépendances là où elles sont écrites : un littéral de tableau (le décorateur,.toClass(Handler, [..])dans le fichier de la classe ou dans le fichier de module) et le type d'un paramètre de constructeur, résolu par les imports de ce fichier. Un nom qui n'est pas importé, un port du kernel importé sous un autre nom et un effet atteint sans dépendance (un client de niveau module) ne sont pas vus. Un gestionnaire du style explicite est associé à ses enregistrements.on(par son nom de classe ou par une variable affectée depuisnew <Class>(; un adaptateur est une classe quiimplementsle port sous le nom qu'exporte le fichier du port.- Rien ne contraint ce qu'importent
shared-kernel/,platform/etmain.ts.
check-file
Section intitulée « check-file »ddd-ts-kit check-file <path>Exécute les règles par fichier (couches, contextes, nommage, événements, langage, pureté, code propre, mocks, composition, désactivations) sur un fichier sans parcourir le projet : conçu pour un hook d'éditeur ou d'agent après chaque écriture. Un fichier qui n'est pas une source du projet est ignoré.
rules, explain
Section intitulée « rules, explain »ddd-ts-kit rulesddd-ts-kit explain <rule-id>rules liste les identifiants, les descriptions et les sévérités actuelles, et dit lesquels
ESLint vérifie. explain affiche la règle, pourquoi elle existe, un mauvais et un bon exemple,
et la syntaxe de désactivation.
Développement
Section intitulée « Développement »node node_modules/@typescript/native/bin/tsc -b packages/clinpx vitest run packages/cli # la suite rapidenpm run build && npm run ci:test:e2e # la suite lente : DDD_TS_KIT_E2E=1, un fichier après l'autrenode packages/cli/dist/bin.js check --selfnpm run clean:self --workspace ddd-ts-kit # les règles clean/* sur ce packagenpm run clean:packages --workspace ddd-ts-kit # les mêmes règles sur les sources de chaque autre packageLa suite lente vit dans test/e2e/, dans ses fichiers de spec et leurs fichiers de support ;
hormis pour les specs first-minute, elle génère chaque projet avec chaque genre, un job sur
un intervalle et un sur une expression cron avec un fuseau horaire, adapter --decorators,
generate service et generate worker compris. Elle s'exécute dans le processus, sans
conteneur :
zero-violation.spec.ts(fait aussi partie de la suite rapide) : les 39 stacks deSTACKSdanstest/e2e/stacks.ts, parmi lesquelles les trois loggers et les deux familles de câblage avec--errors sentry, passentchecketdocs --checktelles que générées.typecheck.spec.ts: les 36 d'entre elles que le workspace sait compiler (jest, prisma et typeorm à part) passent la vérification de types contre les sources des packages du workspace.proven-stacks.spec.ts: les huit projets dePROVENsont installés comme le serait un kit publié et tenus à tout ce qu'exécute leur propre pipeline :checkavec zéro violation et zéro avertissement, ESLint avec le preset (le test lit d'abord danseslint --print-configque les règles des ports, de la planification, de la forme des fichiers et de la disposition sont actives), dependency-cruiser avec la configuration générée, knip, leurs specs (chaque job exécuté, parrunNowsur tsyringe, et tenu à la commande qu'il a envoyée, un adaptateur décoré, l'extraction de chaque contexte), leurs specs de bout en bout (401 sans jeton, 403 sans la permission, 2xx avec elle, avec des jetons signés dans le test, et la trace de la requête HTTP en un seul arbre), leur build (le worker et le service extrait compris) et leurs documents générés. Ce sont la stack par défaut, qui est déclarée ; déclarée avec--authz rebac --broker rabbitmq --ci gitlab; déclarée avec--orm knex --assembly discovery, seule et derrière--broker natsavec--errors sentry(aucun DSN n'est configuré : rien n'est remonté, le câblage est linté, construit et exécuté) ; le style explicite sur la stack par défaut et sur--authz rebac --broker rabbitmq --ci gitlab; et le câblage manuel (--di manual) sur--authz abac --orm knexet sur--http fastify --orm knex --broker rabbitmq --cache redis --errors sentry --logger winston.first-minute.spec.ts,first-minute-proven.spec.ts,first-minute-no-context.spec.tsetfirst-minute-two-contexts.spec.ts: un projet est vert dès sa première minute, avant que quoi que ce soit soit généré. La première exécute le démarrage rapide du README racine ligne à ligne tel qu'il est écrit, sur pnpm et, dans les termes que le README donne pour lui, sur npm :initseul, l'installation et leverifyque nomme la note deinit(tenus aussi au README et auCLAUDE.mddu projet), puis un contexte, un second en aval de lui, un cas d'usage avec sa route,docs:generate, avec unverifyà chaque étape ; et la séquence avec laquelle le défaut de 0.1.0 a été signalé, des contextes demandés àinit. La deuxième fait de même sur les sept autres stacks éprouvées, un gestionnaire de paquets chacune à tour de rôle : aucun contexte, un, deux avec un amont, un premier cas d'usage. Les deux installent pour de vrai depuis le kit empaqueté et tournent dans les jobs de gestionnaires de paquets du pipeline (ci:test:managers). Les deux dernières prennent chaque stack que le workspace sait exécuter, installée comme le serait un kit publié, sans contexte et avec deux : aucun constat des quatre analyseurs, des specs qui passent, un build, et les contrats écrits parinitégaux à ce que génèrent les scripts du projet.package-managers.spec.ts: un projet par gestionnaire de paquets sur la stack par défaut et sur NestJS avec Prisma, chaque genre généré, installé pour de vrai depuis le kit empaqueté : l'installation figée d'un pipeline, le build,verify, etaffecteddepuis la CLI installée.first-class.spec.ts: la stack de première classe,--validation class-validator --orm knex --authn keycloak --authz rebac --cache redis --http-cache varnish --audit on --errors sentry --logger pino --style declared(mode des anciens décorateurs), trois fois, avec--broker redis(un seul serveur pour le cache et le bus d'événements),--broker natset--broker kafka: les mêmes analyseurs avant et après les décisions métier detest/first-class-proof.ts, un job en échec et un événement envoyé en file de rejet remontés au reporter en mémoire de l'application de test, puis une liste restreinte par les relations, le journal d'audit lu par sa route, l'identifiant de corrélation d'une requête HTTP suivi jusqu'à l'événement publié, cet événement porté d'un contexte jusqu'au gestionnaire d'un autre, et le tout vérifié comme une seule trace avecexpectSpanTree.error-reporting.spec.ts:--errors sentrydans les deux familles de câblage (déclaré sur tsyringe derrière RabbitMQ et l'outbox, à la main sans base de données), installé comme le serait un kit publié : les mêmes analyseurs sans constat, les trois points d'entrée construits avec le reporter comme deuxième import, puis une spec écrite dans le projet (fixtures/reported-failures.spec.ts.txt) qui fait échouer une requête derrière sa route : une remontée sur unInMemoryErrorReporter, nommée d'après la requête, et un vidage quand l'application s'arrête ; unNoopErrorReporteret une application qui fonctionne sansSENTRY_DSN; et, avec un DSN qui nomme un point d'accès de l'interface de boucle locale ouvert par la spec, exactement un événement envoyé par le vrai SDK, qui porte l'environnement, la version publiée et l'identifiant de l'acteur, et ni l'identifiant de connexion ni la chaîne de requête de la requête HTTP. Aucun serveur Sentry ou GlitchTip n'est impliqué.violations.spec.tsetnew-rule-violations.spec.ts: dans un projet de la stack par défaut, une violation délibérée à la fois doit être attrapée par l'analyseur qui tient la règle : une erreur générique dans le domaine (ESLint), un domaine qui importe son infrastructure (dependency-cruiser), un export inutilisé (knip), un barrel périmé (le checker etdocs --check), et par le checker comme par ESLint une commande qui dit que personne ne peut l'envoyer, un gestionnaire qui ne se déclare plus, un gestionnaire d'événement nommé d'après un autre événement, puisports/abstract-class,ports/implements-never-extends,ports/no-token-constant,ports/no-concrete-dependency,ports/value-import,clean/no-hash-private,clean/blank-lines(unreturncollé et un bloc rembourré),clean/braces,clean/one-export-per-file,clean/file-size, le plafond de 100 lignes declean/function-size,scheduling/job-triggers-use-caseetscheduling/job-name; deux jobs sous un même nom sont attrapés par le checker.
Les événements, le cache et la base de données restent dans le processus sauf si
DDD_TS_KIT_TEST_REDIS_URL, DDD_TS_KIT_TEST_NATS_URL, DDD_TS_KIT_TEST_KAFKA_BROKERS,
DDD_TS_KIT_TEST_AMQP_URL et DDD_TS_KIT_TEST_DATABASE_URL (stack de première classe
seulement, migrée par le test) nomment de vrais services : l'événement traverse alors vraiment le
broker.