Domain-Driven Design en Pratique : Comment Nous Traçons les Frontières de Modules
Patterns DDD pratiques pour TypeScript et NestJS. Tracer les frontières de modules, EventBus comme couche anti-corruption, quand le DDD est excessif, shared kernels et refactoring vers des frontières.
Le DDD, ce n'est pas le Blue Book
La plupart des articles sur le DDD commencent avec les agrégats, les objets valeur et les événements de domaine du livre d'Eric Evans. C'est de la théorie. En pratique, le concept le plus précieux du DDD se résume à une chose : les frontières.
Où s'arrête le module A et où commence le module B ? Quelles données appartiennent à chaque module ? Comment communiquent-ils ? Quand tu traces bien les frontières, le codebase reste propre au fil de sa croissance. Quand tu les traces mal, chaque fonctionnalité touche chaque module, et le refactoring devient impossible.
Nous avons tracé des frontières de modules dans plusieurs systèmes de production : un plugin Vendure à 6 modules, une plateforme de billetterie à 8 services, et un PIM avec 13 subscribers d'événements et une orchestration de workers complexe. Cet article couvre les patterns pratiques. Pour la communication event-driven entre frontières, consulte notre guide d'architecture event-driven. Pour les patterns spécifiques à Vendure, consulte notre guide d'architecture de plugins Vendure.
La règle de dépendance
La manière la plus simple d'identifier les frontières de modules : dessine un graphe de dépendances. Si le module A importe quelque chose du module B et que le module B importe quelque chose du module A, tu as une dépendance circulaire. La frontière est mal tracée.
// MAUVAIS : dépendances circulaires
WishlistService importe LoyaltyService (pour attribuer des points)
LoyaltyService importe WishlistService (pour vérifier la wishlist pour un bonus)
// BON : communication par événements
WishlistService émet WishlistItemAddedEvent
LoyaltyService s'abonne à WishlistItemAddedEvent (attribue des points)
LoyaltyService émet PointsAwardedEvent
WishlistService ne s'en soucie pas (pas d'abonnement nécessaire)
La règle : les dépendances vont dans une seule direction. Si deux modules ont besoin de communiquer de manière bidirectionnelle, utilise des événements. Les événements sont unidirectionnels par nature. L'émetteur ne sait pas (et ne se soucie pas de) qui s'abonne.
Comment vérifier
# Trouver les imports circulaires dans un projet TypeScript
npx madge --circular src/
# Trouver les imports inter-modules
grep -rn "from '.*wishlist.*'" src/modules/loyalty/
grep -rn "from '.*loyalty.*'" src/modules/wishlist/
Si ces commandes retournent des résultats, tu as des violations de frontières. Corrige-les en extrayant la préoccupation partagée dans un module séparé ou en remplaçant l'import par un événement.
EventBus comme couche anti-corruption
L'EventBus n'est pas juste un système de messagerie. C'est la couche anti-corruption entre les modules. Chaque module publie des événements qui décrivent ce qui s'est passé dans son domaine. Les autres modules s'abonnent et traduisent ces événements dans les concepts de leur propre domaine.
// Module wishlist : publie un événement de domaine
export class WishlistItemAddedEvent extends VendureEvent {
constructor(
public ctx: RequestContext,
public wishlistId: string,
public productVariantId: string,
public customerId: string,
) {
super();
}
}
// Service wishlist : émet l'événement après la logique métier
@Injectable()
export class WishlistService {
constructor(private eventBus: EventBus) {}
async addItem(ctx: RequestContext, input: AddItemInput): Promise<WishlistItem> {
// Logique métier : valider, vérifier les doublons, sauvegarder
const item = await this.saveItem(ctx, input);
// Publier l'événement : "quelque chose s'est passé dans mon domaine"
await this.eventBus.publish(
new WishlistItemAddedEvent(ctx, input.wishlistId, input.productVariantId, ctx.activeUserId)
);
return item;
}
}
// Module loyalty : s'abonne et traduit dans son propre domaine
@Injectable()
export class LoyaltyEventHandler {
constructor(
private eventBus: EventBus,
private loyaltyService: LoyaltyService,
) {
// S'abonner : traduire l'événement wishlist en concept loyalty
this.eventBus.ofType(WishlistItemAddedEvent).subscribe(async event => {
await this.loyaltyService.awardPoints(
event.ctx,
event.customerId,
'wishlist_add', // Concept propre à loyalty, pas à wishlist
10, // Montant de points (décision de loyalty)
);
});
}
}
Ce qui en fait une couche anti-corruption
- Le module wishlist ne sait pas que loyalty existe. Il publie
WishlistItemAddedEventquel que soit le nombre d'abonnés. - Le module loyalty n'importe pas les services de wishlist. Il ne dépend que de la classe d'événement.
- Le montant de points (10) est une décision du domaine loyalty, pas une préoccupation de wishlist.
- Si loyalty est supprimé, wishlist continue de fonctionner sans changement.
- Si un nouveau module (analytics) veut réagir aux ajouts wishlist, il s'abonne au même événement. Zéro changement dans wishlist.
Structure de module en TypeScript/NestJS
Un module bien structuré a une organisation interne claire :
src/modules/wishlist/
├── entities/
│ ├── wishlist.entity.ts
│ └── wishlist-item.entity.ts
├── services/
│ └── wishlist.service.ts
├── resolvers/
│ ├── wishlist-shop.resolver.ts
│ └── wishlist-admin.resolver.ts
├── events/
│ └── wishlist.events.ts
├── schemas/
│ ├── wishlist-shop.schema.ts
│ └── wishlist-admin.schema.ts
├── types/
│ └── wishlist.types.ts
└── wishlist.module.ts
Enregistrement du module
// wishlist.module.ts
@Module({
imports: [TypeOrmModule.forFeature([CiWishlist, CiWishlistItem])],
providers: [WishlistService, WishlistShopResolver, WishlistAdminResolver],
exports: [WishlistService], // Seulement si d'autres modules en ont légitimement besoin
})
export class WishlistModule {}
Le tableau exports est l'API publique du module. N'exporte que ce dont les autres modules ont véritablement besoin. La plupart des modules ne devraient rien exporter (communiquer via des événements à la place).
Ce qui traverse les frontières de modules
| Traverse la frontière | Comment |
|---|---|
| Événements | Publiés via EventBus, auxquels d'autres modules s'abonnent |
| IDs d'entités | Passés comme valeurs primitives (string/number), pas comme références d'entités |
| DTOs/interfaces | Types partagés pour les payloads d'événements |
| Ne traverse PAS la frontière | Pourquoi |
|---|---|
| Instances de services | Crée du couplage. Utilise des événements. |
| Références d'entités | Le module A ne devrait pas requêter les tables du module B. |
| Accès aux repositories | Chaque module possède ses propres données. |
| État interne | Privé au module. |
Quand le DDD est excessif
Les concepts DDD apportent de la valeur quand le domaine est complexe et l'équipe est grande. Pour beaucoup d'applications, des patterns plus simples fonctionnent mieux.
| Situation | DDD ? | Meilleure approche |
|---|---|---|
| Application CRUD avec des règles métier simples | Non | Pattern MVC/service-repository standard |
| 2-3 ingénieurs, un seul bounded context | Non | Monolithe bien organisé avec des dossiers clairs |
| MVP de startup (product-market fit inconnu) | Non | Avance vite, refactore plus tard quand le domaine se stabilise |
| Système d'entreprise avec 6+ domaines métier distincts | Oui | Bounded contexts avec des frontières de modules claires |
| Plusieurs équipes travaillant sur le même codebase | Oui | La propriété des modules s'aligne avec la propriété des équipes |
| Règles métier complexes qui varient selon le contexte | Oui | Les modèles de domaine capturent les règles explicitement |
Le juste milieu pragmatique
Tu n'as pas besoin d'agrégats, d'événements de domaine et de couches anti-corruption pour un blog avec des commentaires. Tu AS besoin de frontières de modules et de communication par événements pour une plateforme de commerce avec wishlist, avis, fidélité, récupération de panier et alertes de retour en stock.
Le juste milieu : organise le code en modules avec des frontières claires et une communication par EventBus. N'implémente pas les patterns DDD complets à moins que la complexité du domaine le justifie. Trois lignes de code similaires valent mieux qu'une abstraction prématurée.
Shared Kernel : le code qui n'appartient à personne
Du code est véritablement partagé entre les modules. Des constantes, des fonctions utilitaires, des types de base et des interfaces communes. C'est le shared kernel.
src/shared/
├── constants/
│ └── index.ts # TABLE_NAMES, permissions, etc.
├── types/
│ └── common.types.ts # DTOs partagés, types de pagination
├── utils/
│ └── date.utils.ts # Formatage de dates, helpers de fuseaux horaires
└── errors/
└── common.errors.ts # Classes d'erreurs de base
Règles pour le shared kernel :
- Minimal : uniquement ce qui doit véritablement être partagé. Si c'est utilisé par un seul module, ça appartient à ce module.
- Stable : les changements dans le shared kernel affectent tous les modules. Il devrait changer rarement.
- Pas de logique métier : uniquement des utilitaires, types et constantes. Les règles métier appartiennent aux modules de domaine.
- Possédé par l'équipe plateforme (ou explicitement par personne). Si le shared kernel n'a pas de propriétaire, il devient un fourre-tout.
Refactoring vers des frontières
La plupart des codebases ne démarrent pas avec des frontières de modules propres. Ils évoluent depuis un monolithe où tout importe tout. Le refactoring vers des frontières est un processus graduel.
Étape 1 : cartographier les dépendances actuelles
# Générer le graphe de dépendances
npx madge --image dependency-graph.svg src/
# Trouver les fichiers les plus importés (points chauds de couplage)
grep -rn "import.*from" src/ --include="*.ts" | awk -F"from " '{print $2}' | sort | uniq -c | sort -rn | head 20
Étape 2 : identifier les frontières naturelles
Cherche des groupes de fichiers qui changent ensemble. Si wishlist.service.ts, wishlist.entity.ts et wishlist.resolver.ts changent toujours dans la même PR, ils forment un module naturel.
Étape 3 : extraire un module à la fois
Itération 1 : Déplacer les fichiers wishlist dans src/modules/wishlist/
Itération 2 : Remplacer les imports directs par des événements
Itération 3 : Déplacer les fichiers loyalty dans src/modules/loyalty/
Itération 4 : Remplacer les imports directs par des événements
...
N'essaie pas d'extraire tous les modules en même temps. Extrais-en un, vérifie que ça fonctionne, puis extrais le suivant. Chaque extraction devrait être une PR séparée avec ses propres tests.
Étape 4 : appliquer les frontières
// Règle ESLint pour empêcher les imports inter-modules
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['*/modules/loyalty/*'],
message: 'Le module wishlist ne peut pas importer depuis loyalty. Utilise EventBus.',
},
],
}],
},
};
Applique les frontières avec du linting, pas avec de la documentation. La documentation est ignorée. Les erreurs de lint bloquent les PR.
Erreurs courantes
-
Commencer par le DDD au lieu de refactorer vers lui. Sur un nouveau projet, commence simple. Ajoute les frontières de modules quand la complexité du domaine l'exige. Ne conçois pas des agrégats pour une app CRUD.
-
Tout exporter. Si un module exporte tous ses services, les autres modules vont les importer. N'exporte rien par défaut. Utilise des événements pour la communication inter-modules.
-
Requêtes partagées de base de données entre modules. Le module A ne devrait pas écrire du SQL qui fait des jointures sur les tables du module B. Chaque module possède ses données. Si A a besoin de données de B, B les expose via un événement ou une méthode de service publique.
-
Événements avec trop de données. Un événement devrait transporter des IDs et un contexte minimal, pas des entités entières. L'abonné va chercher ce dont il a besoin dans son propre data store.
-
Pas d'application. Des frontières de modules sans règles de lint sont des suggestions. Les suggestions sont violées sous la pression des deadlines. Les règles de lint bloquent les violations.
-
Abstraction prématurée. Trois fonctions similaires dans trois modules, c'est acceptable. Extraire une abstraction partagée avant de comprendre le pattern crée la mauvaise abstraction. Attends la troisième fois.
-
Le vocabulaire DDD sans la compréhension DDD. Nommer les choses "aggregate" et "value object" sans comprendre leur but, c'est de l'architecture cargo cult. Le but est de capturer les règles du domaine, pas d'impressionner les reviewers de code.
Points clés à retenir
-
Le DDD concerne les frontières, pas les patterns. Où s'arrête un module et où commence l'autre ? Qu'est-ce que chaque module possède ? Comment communiquent-ils ? Réponds bien à ça et le codebase reste propre.
-
Les événements sont la couche anti-corruption. L'EventBus empêche les dépendances circulaires et garde les modules indépendants. Les émetteurs ne connaissent pas les abonnés. La communication est unidirectionnelle.
-
N'exporte rien par défaut. Les services d'un module sont privés sauf besoin légitime de les exporter. La plupart des communications inter-modules devraient passer par des événements.
-
Commence simple, refactore vers des frontières. Ne conçois pas des agrégats le premier jour. Construis la fonctionnalité, observe les frontières naturelles, puis extrais les modules.
-
Applique avec du linting, pas de la documentation. Les règles ESLint qui empêchent les imports inter-modules sont plus efficaces que les pages wiki qui décrivent la structure des modules.
-
Le shared kernel est minimal et stable. Constantes, types et utilitaires uniquement. Pas de logique métier. Les changements dans le shared kernel affectent chaque module.
Nous appliquons ces patterns dans nos projets de logiciels sur mesure et notre développement de plugins Vendure. Si tu as besoin d'aide pour l'architecture de ton codebase, parle à notre équipe ou demande un devis. Consulte aussi notre guide d'ingénierie logicielle pour nos principes d'ingénierie plus larges.
Sujets couverts
Guides connexes
Guide Entreprise des Systèmes d'IA Agentiques
Guide technique des systemes d'IA agentiques en entreprise. Decouvre l'architecture, les capacites et les applications des agents IA autonomes.
Lire le guideCommerce Agentique : Comment laisser les agents IA acheter en toute securite
Comment concevoir un commerce agentique gouverne. Moteurs de politiques, portes d'approbation HITL, reçus HMAC, idempotence, isolation multi-tenant et le protocole Agentic Checkout complet.
Lire le guideLes 9 endroits où ton système IA laisse fuir des données (et comment colmater chacun)
Cartographie systématique de chaque point de fuite de données dans les systèmes IA. Prompts, embeddings, logs, appels d'outils, mémoire d'agent, messages d'erreur, cache, données de fine-tuning et transferts entre agents.
Lire le guidePrêt à construire des systèmes IA prêts pour la production ?
Notre équipe est spécialisée dans les systèmes IA prêts pour la production. Discutons de comment nous pouvons aider.
Démarrer une conversation