Guide technique

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.

6 février 202614 min de lectureÉquipe d'Ingénierie Oronts

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 WishlistItemAddedEvent quel 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èreComment
ÉvénementsPubliés via EventBus, auxquels d'autres modules s'abonnent
IDs d'entitésPassés comme valeurs primitives (string/number), pas comme références d'entités
DTOs/interfacesTypes partagés pour les payloads d'événements
Ne traverse PAS la frontièrePourquoi
Instances de servicesCrée du couplage. Utilise des événements.
Références d'entitésLe module A ne devrait pas requêter les tables du module B.
Accès aux repositoriesChaque module possède ses propres données.
État internePrivé 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.

SituationDDD ?Meilleure approche
Application CRUD avec des règles métier simplesNonPattern MVC/service-repository standard
2-3 ingénieurs, un seul bounded contextNonMonolithe bien organisé avec des dossiers clairs
MVP de startup (product-market fit inconnu)NonAvance vite, refactore plus tard quand le domaine se stabilise
Système d'entreprise avec 6+ domaines métier distinctsOuiBounded contexts avec des frontières de modules claires
Plusieurs équipes travaillant sur le même codebaseOuiLa propriété des modules s'aligne avec la propriété des équipes
Règles métier complexes qui varient selon le contexteOuiLes 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

  1. 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.

  2. 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.

  3. 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.

  4. É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.

  5. 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.

  6. 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.

  7. 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

DDD pratiquedomain-driven design réelbounded contextsarchitecture modulaireDDD TypeScriptDDD NestJSfrontières de modulescouche anti-corruption

Prê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