Wiring
Il faut bien choisir quelle implémentation sert chaque port, et construire les objets dans le bon ordre. Ces règles placent ce choix à un seul endroit par bounded context, son module, et le tiennent hors de tout le reste : les classes reçoivent ce dont elles ont besoin par leur constructeur, nomment ce qu’elles injectent, ne gardent aucun état d’une requête, et ne demandent jamais rien au container. La configuration se lit une fois, dans son propre fichier, et passe en valeurs. Le modèle ne connaît pas le framework, et la plupart du code qui l’entoure non plus.
La place du module relève de Ce qui va où (PLC-01). La façon dont le domaine déclare ses ports relève de LAY-01. Le démarrage de l’application dans les tests relève de Tests (TST-03).
En pratique
Le bounded context wallets d’une application de wallet. Chaque classe est une classe ordinaire ; le module les construit toutes.
Le module construit chaque classe avec construct, une fonction de wiring de shared/ : elle prend la classe et ses injection tokens, dans l’ordre du constructeur, et renvoie un provider NestJS. { ...construct(Implémentation, tokens), provide: Port } lie l’implémentation au port. Chaque token est une classe, son propre injection token (WIR-07), et le compilateur vérifie qu’il produit le type attendu à sa place : deux tokens inversés ne compilent pas.
// shared/shared.wiring.ts
type Tokens<Args extends unknown[]> = { [K in keyof Args]: abstract new (...args: never[]) => Args[K] };
export function construct<T, Args extends unknown[]>(
type: new (...args: Args) => T,
tokens: Tokens<Args>,
): FactoryProvider<T> {
return { provide: type, useFactory: (...args: Args) => new type(...args), inject: tokens };
}// wallets.module.ts
@Module({
controllers: [WalletController],
providers: [
{ ...construct(PrismaWalletRepository, [PrismaUnitOfWork]), provide: WalletRepository },
{ ...construct(PrismaJournal, [PrismaUnitOfWork]), provide: Journal },
{ ...construct(PrismaWalletViews, [PrismaClient]), provide: WalletViews },
construct(DebitWalletHandler, [UnitOfWork, WalletRepository, Journal, Outbox, Clock]),
construct(CreditWalletHandler, [UnitOfWork, WalletRepository, Journal, Outbox, Clock]),
construct(GetWalletHandler, [WalletViews]),
{ ...construct(WalletsFacadeAdapter, [CreditWalletHandler, GetWalletHandler]), provide: WalletsFacade },
],
exports: [WalletsFacade],
})
export class WalletsModule {}// application/wallet/debit-wallet.command.ts
export class DebitWalletHandler extends CommandHandler<DebitWalletCommand, void, DebitWalletError> {
readonly #unitOfWork: UnitOfWork;
readonly #wallets: WalletRepository;
readonly #journal: Journal;
readonly #outbox: Outbox;
readonly #clock: Clock;
constructor(unitOfWork: UnitOfWork, wallets: WalletRepository, journal: Journal, outbox: Outbox, clock: Clock) {
super();
this.#unitOfWork = unitOfWork;
this.#wallets = wallets;
this.#journal = journal;
this.#outbox = outbox;
this.#clock = clock;
}
// handle(command): load the wallet, let it create the entry, book it, as in the aggregates overview
}// driving/http/wallet.controller.ts
@Controller('wallets')
export class WalletController extends ResultController {
readonly #debitWallet: DebitWalletHandler;
readonly #getWallet: GetWalletHandler;
constructor(
@Inject(DebitWalletHandler) debitWallet: DebitWalletHandler,
@Inject(GetWalletHandler) getWallet: GetWalletHandler,
) {
super();
this.#debitWallet = debitWallet;
this.#getWallet = getWallet;
}
}- Le module du bounded context lie chaque port à son implémentation, et n’exporte que la facade de
public(WIR-01, WIR-03). app.module.tsimporte les modules des bounded contexts,main.bootstrap.tsdémarre l’application (WIR-02).- Le handler reçoit ses dépendances par son constructeur, et ne sait rien de NestJS : ni
@Injectable, ni@Inject(WIR-04, WIR-05). constructet@Injectnomment chaque injection token ;WalletRepositoryest une abstract class, et son propre injection token (WIR-06, WIR-07).- Le handler ne garde aucun champ par requête : la transaction circule par le Unit of Work (WIR-08).
- Le travail au démarrage passe par des use cases, et l’application ferme ce qu’elle a ouvert (WIR-09, WIR-10).
Règles
La composition root
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| WIR-01 | Le module du bounded context est le seul endroit qui lie un port à son implémentation | majeure | |
| WIR-02 | Le module de l’application assemble les bounded contexts, le bootstrap ne fait que démarrer | mineure | |
| WIR-03 | Le module d’un bounded context n’exporte que son contrat publié | majeure |
Dépendances
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| WIR-04 | Une classe reçoit ses dépendances par son constructeur | majeure | |
| WIR-05 | Seuls les fichiers de wiring et les adapters du framework connaissent le container | majeure | |
| WIR-06 | Chaque injection nomme son injection token | majeure | |
| WIR-07 | Un port est une abstract class, et son propre injection token | mineure |
Durée de vie
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| WIR-08 | Les champs d’un handler ou d’un adapter ne changent plus après le constructeur | majeure | |
| WIR-09 | Le travail au démarrage passe par des use cases, et un échec arrête le démarrage | mineure | |
| WIR-10 | Ce que le wiring ouvre, il le ferme | mineure |
Configuration
| Code | Règle | Sévérité | Détection |
|---|---|---|---|
| WIR-11 | La configuration se lit dans un fichier `*.config.ts`, puis passe en valeurs | majeure | fitness |