Six règles DDD pour structurer les domaines dans les applications NestJS
Apprenez six règles pratiques de conception orientée domaine pour organiser les modules, entités et événements de NestJS afin que les fonctionnalités restent isolées et faciles à maintenir.
Au bout de six mois dans la plupart des projets NestJS, une friction familière apparaît. Vous ajoutez un seul champ pour prendre en charge une fonctionnalité, et un test dans une partie complètement éloignée de l’application échoue soudainement. Quelqu’un dans l’équipe demande où se trouve réellement la logique de traitement des commandes, et la réponse honnête est « dispersée, un peu partout ».
Cela n’est pas un signe d’une ingénierie négligente. C’est généralement le signe que le code a été organisé en fonction de couches techniques plutôt qu’en fonction des concepts qu’il représente. Le Design Orienté Domaine à part entière est un ensemble complexe de pratiques que la plupart des équipes n’adoptent jamais pleinement. Ce qui suit est une approche plus sobre : six règles qui apportent réellement des avantages dans une application Nest, en omettant celles qui ne servent à rien. Considérez cela comme un DDD sélectif.
Règle 1 — Ne faites pas en sorte qu’un seul modèle serve toute l’application
Presque tous les systèmes backend complexes possèdent un objet central appelé « God object ». Il est souvent désigné sous le nom de Order, comporte des dizaines de colonnes pouvant être nulles, et la moitié du codebase l’importe. Finalement, une modification effectuée pour l’équipe des entrepôts endommage silencieusement le processus de paiement.
Cet unique Order représente en réalité trois domaines distincts cachés sous un même nom :
- Checkout gère les prix, les remises et l’intention de paiement.
- Fulfillment s’occupe des références produits et de l’adresse d’expédition, sans tenir compte des remises.
- Billing s’intéresse au montant à payer et au numéro de facture.
Lorsqu’une classe tente de gérer les trois domaines en même temps, un champ de remise se retrouve à côté d’une adresse d’expédition. Modifier l’un d’eux risque de dégrader les deux autres.
La solution consiste à attribuer à chaque domaine son propre modèle et à leur permettre de communiquer par le biais de messages plutôt que via une classe partagée.
class Cart {
lines: CartLine[];
discount: Money;
paymentIntentId: string;
}
class FulfillmentOrder {
orderId: string;
shipTo: Address;
picks: Pick[];
}
this.events.emit(new OrderPlaced(order.id, order.shipTo, picks));
Remarquez que FulfillmentOrder n’est identifié que par orderId et ne contient absolument aucune donnée relative aux prix ou aux remises — le processus de paiement transmet une information à l’entrepôt via l’événement OrderPlaced, sans exposer sa propre classe interne. Cela signifie qu’un changement de tarif ne peut en aucun cas s’infiltrer dans la logique de l’entrepôt, même si le compilateur le permet. Chacun de ces domaines constitue un contexte délimité : son propre modèle, où le mot « commande » désigne une chose précise. Dans un monolithe, il s’agit peut-être simplement d’un ensemble de modules possédant leurs propres tables de base de données ; dans une architecture à microservices, il peut s’agir d’un service entièrement distinct. Dans tous les cas, la règle reste valable : ne partagez jamais de modèle au-delà des frontières définies.
Un test utile est le suivant : un changement dans un contexte ne doit jamais vous forcer à modifier un autre. Si c’est le cas, vos limites sont mal définies.
Règle 2 — Un module est un domaine, pas une couche
Imaginez que votre chef de produit demande un support pour l’emballage des commandes en cadeau. Voyez quel est le coût de cela lorsque le projet est organisé par fonction technique plutôt que par sujet :
src/
├── controllers/ # order, auth, product, shipment, payment...
├── services/ # order, auth, product, shipment, payment...
├── entities/
└── enums/ # every enum in the whole app
Vous ouvrez le dossier controllers/ et vous faites défiler les éléments liés à l’authentification et aux livraisons pour trouver le contrôleur des commandes. Ensuite, vous répétez cette opération dans services/, puis dans entities/ et enums/. Quatre ou cinq dossiers, quatre ou cinq défis de navigation, et la seule fonctionnalité que vous ajoutez est dispersée dans chacun d’eux.
Cette structure répond à la question « Montrez-moi tous les contrôleurs », que presque personne ne pose réellement. La question que se posent vraiment les gens est « Montrez-moi tout ce qui concerne les commandes ». Organisez donc d’abord le code par domaine :
modules/orders/
├── controllers/
├── dto/
├── entities/
│ ├── order.entity.ts
│ └── order-status.enum.ts # the enum sits next to what it uses
├── repositories/
└── orders.module.ts
Avec cette structure, l’emballage des cadeaux ne touche qu’un seul dossier. Notez qu’il n’y a pas de répertoire général enums/ au niveau le plus élevé — un enum doit être placé à côté de ce qu’il décrit. La seule règle à respecter : common/ ne doit contenir que des éléments qui n’appartiennent à aucun domaine, tels que des outils de pagination ou une classe de repository de base. Dès que common/ commence à savoir ce qu’est une commande, il devient en fait un autre module déguisé.
Règle 3 — Les modules doivent dépendre d’interfaces, et non des services les uns des autres
Imaginez deux fonctionnalités livrées au cours de la même itération. La page du produit doit afficher « 3 commandes en cours », ce qui pousse catalog à utiliser OrderService. Parallèlement, le reçu a besoin des noms des produits, ce qui amène orders à utiliser ProductService. Nest refuse de relier ces éléments entre eux :
Nest cannot create the CatalogModule instance.
- A circular dependency between modules. Use forwardRef() to avoid it.
En enveloppant l’injection dans forwardRef(), on masque l’erreur, mais cela fusionne également de manière permanente les deux modules. La véritable solution consiste à dépendre d’une petite interface que l’on définit soi-même, plutôt qu’à faire appel aux services d’un autre module.
catalog doit empêcher la suppression d’un produit qui figure encore dans une commande en cours — mais seuls orders disposent de cette information. Au lieu d’importer orders, catalog définit simplement la question à laquelle il a besoin de réponse :
export interface ProductUsageGuard {
isProductInUse(productId: string): Promise<boolean>;
}
Le module orders fournit la réponse en implémentant cette interface et en s’inscrivant lui-même, de sorte que catalog peut poser la question sans avoir à importer quoi que ce soit depuis orders:
for (const guard of this.guards) {
if (await guard.isProductInUse(id)) throw new ProductInUseError(id);
}
Before: catalog ⇄ orders circular — Nest won't boot
After: catalog ◄──implements── orders one way — catalog owns the interface
La dépendance pointe désormais dans une seule direction, il n’y a donc ni cycle ni besoin de forwardRef(). En plus, si une règle telle que « on ne peut pas supprimer un produit lié à une abonnement actif » apparaît plus tard, le module des abonnements peut enregistrer sa propre protection, et catalog n’a pas besoin de changer du tout.
Règle 4 — Garder les contrôleurs légers et laisser les entités gérer la logique
Considérez une règle du type « on ne peut pas annuler une commande qui a déjà été expédiée ». Où cette logique doit-elle être placée ? Dans de nombreux projets, elle finit là où elle est nécessaire en premier lieu — généralement enfouie à l’intérieur d’un service. Ensuite, le tableau de bord administratif a besoin de cette même vérification, tout comme le job batch nocturne, et finalement un gestionnaire de webhook également. Chaque endroit réimplémente la règle de manière légèrement différente, quelqu’un oublie la quatrième copie, et soudainement les commandes expédiées reçoivent un remboursement.
Lorsqu’une entité n’est rien de plus qu’un ensemble de champs publics que d’autres codes modifient directement, on obtient un modèle faible — et le symptôme est toujours le même : les règles métier s’échappent et sont copiées-collées dans chaque service qui traite les données.
Plutôt, attachez la règle à l’objet qui possède réellement l’état :
@Entity()
export class Order {
status: OrderStatus = OrderStatus.DRAFT;
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error('Cannot cancel an order that already shipped');
}
this.status = OrderStatus.CANCELLED;
}
Désormais, « cancel » est défini en un seul endroit précis, et aucun appelant ne peut contourner cette vérification — il n’existe tout simplement pas de chemin alternatif. Il est très facile de réaliser des tests unitaires sans toucher à une base de données. La couche de service se contente d’orchestrer les étapes (order.cancel(), émettre le remboursement, persister les données), tandis que le contrôleur devient presque inutile :
@Post(':id/cancel')
cancel(@Param('id') id: string) {
return this.orders.cancel(id);
}
Voici la répartition des responsabilités en un seul schéma :
HTTP ─► Controller ─► Service ─► Order (the rules)
└─────► Repository ─► DB (the queries)
Les entités appliquent les règles, les repositories gèrent les requêtes, les services coordonnent la séquence des appels, et les contrôleurs s’occupent exclusivement d’HTTP.
Règle 5 — Concevez vos données de manière à ce que des états invalides ne puissent exister
La règle 4 a déplacé la logique vers les entités. Deux autres patterns complètent ce travail, chacun permettant d’éliminer un type spécifique de bug.
Les objets de valeur gèrent des primitives qui comportent des règles. Le montant total d’une commande n’est qu’un nombre, ce qui signifie qu’aucun mécanisme ne empêche un coupon de le faire descendre en dessous de zéro, ou qu’un remboursement en EUR ne soit appliqué à une commande facturée en USD. Les règles définissant le « monnaie » ne se trouvent nulle part en particulier. Corrigez cela en attribuant au montant financier son propre type qui applique ces règles :
export class Money {
private constructor(readonly cents: number, readonly currency: string) {}
static of(cents: number, currency: string): Money {
if (cents < 0) throw new Error('Money cannot be negative');
return new Money(cents, currency);
}
add(o: Money): Money {
if (o.currency !== this.currency) throw new Error('Currency mismatch');
return Money.of(this.cents + o.cents, this.currency);
}
}
Avec cette solution en place, il devient impossible de créer un montant total négatif ou une incohérence de devise — le type lui-même l’empêche. C’est ce qu’on appelle un objet de valeur : un type petit et immuable identifié par sa valeur plutôt que par un identifiant. Utilisez-le chaque fois qu’une primitive est associée à des règles que vous devez vérifier encore et encore — comme pour l’argent, les adresses e-mail ou les plages CIDR — mais évitez-le pour quelque chose d’aussi simple qu’un identifiant pur.
Les agrégats gèrent une règle qui s’applique à plusieurs objets. Le total d’une commande doit toujours correspondre à la somme de ses lignes. Si OrderLine dispose de son propre répertoire, tôt ou tard quelqu’un enregistrera une ligne sans mettre à jour la commande parente, ce qui provoquera silencieusement une erreur dans le total. La solution consiste à ne jamais permettre cette situation dès le départ : faire de Order la racine de l’agrégat — l’unique objet que l’on charge ou enregistre, le point d’accès principal à cette partie du modèle. Il n’existe pas de OrderLineRepository ; les lignes ne sont modifiées que par l’intermédiaire de la commande elle-même :
addLine(sku: string, price: Money, qty: number): void {
if (this.status !== OrderStatus.DRAFT) throw new Error('Order already placed');
this.lines.push(new OrderLine(sku, price, qty));
this.total = this.sumOfLines();
}
Avec un seul point d’accès, l’invariant ne peut pas être violé par accident. Gardez vos agrégats aussi petits que possible — uniquement ce qui doit réellement changer au sein de la même transaction — et faites référence à d’autres agrégats par leur identifiant plutôt que de conserver des références directes aux objets.
Règle 6 — Publiez des événements plutôt que d’appeler directement les services
Le processus de paiement commence simplement, puis la fonction place() s’enrichit progressivement : enregistrer la commande, appeler le service d’expédition, appeler le service de facturation, envoyer un e-mail. À ce stade, orders intègre la moitié de l’application et doit être au courant de chaque étape suivante. Ajoutez une fonctionnalité de points de fidélité au prochain trimestre, et vous retomberez à devoir modifier le module de paiement pour quelque chose qui n’a rien à voir avec lui.
Inversez la logique. orders effectue sa propre tâche puis annonce ce qui s’est passé — il ne sait pas qui, si quelqu’un, écoute :
this.events.emit(new OrderPlaced(order.id, order.customerId, items));
Chaque contexte concerné réagit de manière indépendante :
@OnEvent(OrderPlaced.name)
handle(e: OrderPlaced) { return this.shipping.createShipment(e); }
Ajouter des points de fidélité signifie maintenant intégrer un écouteur au sein du module de fidélité ; les orders restent inchangés. La même logique s’applique entre des services distincts via un broker de messages (comme RabbitMQ), avec une mesure de sécurité supplémentaire : une file d’envoi transactionnelle. Vous enregistrez l’événement dans la table outbox au cours de la même transaction qui enregistre la commande, puis un travailleur distinct le publie par la suite. Sans cette étape, une panne entre l’enregistrement de la commande et la publication de l’événement entraîne sa perte silencieuse — en production, c’est ce qui fait la différence entre un système fiable et un système problématique.
Un point d’attention : les événements obscurcissent le flux global, car aucun endroit unique ne montre la séquence complète des événements. Préservez-les pour les réactions qui dépassent les limites contextuelles, et non pour des étapes relevant d’une tâche cohérente.
Les avantages
Appliquer DDD de manière sélective ne signifie pas ajouter davantage d’architectures superposées. Il s’agit plutôt de placer chaque élément logique à l’endroit qui lui convient : les modèles gèrent les règles, les modules s’occupent de leurs domaines respectifs, les repositories gèrent les requêtes, et les événements relient un contexte à un autre. En respectant ces limites, votre application NestJS reste plus facile à comprendre, à modifier et à faire évoluer avec le temps.
Lectures complémentaires
- Pourquoi NestJS est meilleur pour des équipes et des codebases en développement — Découvrez comment la structure de NestJS, l’injection de dépendances et une conception axée sur TypeScript aident les équipes d’ingénierie à se développer sans sombrer dans le chaos.