Architecture & conception

DDD tactique

Entités, value objects, agrégats, repositories, domain events.

Vérifié en septembre 2026 · .NET 10 · environ 15 min

Le DDD tactique est une boîte à outils pour écrire un modèle métier qui refuse les états invalides : value objects, entités, agrégats, repositories, domain events. Il sert quand les règles sont nombreuses, qu'elles changent, et qu'une règle cassée coûte cher ; il ne sert pas pour un formulaire qui écrit une ligne en base. Le fil rouge est une commande et ses lignes, avec une règle : le total d'une commande ne dépasse jamais le plafond accordé au client. Le premier exemple est autonome. Les suivants sont les fichiers de deux projets : Ventes.Domaine, une bibliothèque, et Ventes.Application, qui la référence. Ils compilent ensemble, et ceux qui ont une méthode Executer affichent ce que disent leurs commentaires.

Pourquoi un modèle riche

Un modèle anémique sépare les données, rangées dans des classes à propriétés publiques, et les règles, rangées dans des services. La séparation a l'air propre et porte un défaut précis : la règle n'est vraie que sur les chemins qui passent par le service. Tout le reste — un import en masse, un second service écrit plus tard, un mapping de DTO, un test qui prépare ses données — écrit directement dans les propriétés, et le compilateur n'a aucune raison de s'y opposer.

using System;
using System.Collections.Generic;
using System.Linq;

namespace Ventes.Anemique;

// Des donnees, et rien d'autre : chaque propriete s'ecrit de partout.
public class Commande
{
    public Guid Id { get; set; }

    public Guid ClientId { get; set; }

    public decimal Plafond { get; set; }

    public List<LigneCommande> Lignes { get; set; } = [];
}

public class LigneCommande
{
    public string Reference { get; set; } = "";

    public int Quantite { get; set; }

    public decimal PrixUnitaire { get; set; }
}

// La regle vit ici, dans un service que rien n'oblige a appeler.
public class CommandeService
{
    public void AjouterLigne(Commande commande, string reference, int quantite, decimal prixUnitaire)
    {
        var total = commande.Lignes.Sum(l => l.Quantite * l.PrixUnitaire) + quantite * prixUnitaire;
        if (total > commande.Plafond)
        {
            throw new InvalidOperationException($"Plafond de {commande.Plafond} depasse.");
        }

        commande.Lignes.Add(
            new LigneCommande { Reference = reference, Quantite = quantite, PrixUnitaire = prixUnitaire });
    }
}

static class Demonstration
{
    public static void Executer()
    {
        var commande = new Commande { Id = Guid.NewGuid(), ClientId = Guid.NewGuid(), Plafond = 500m };

        // Ce chemin-ci passe par la regle...
        new CommandeService().AjouterLigne(commande, "ECRAN-27", 1, 300m);

        // ... et ceux-la non : un import, un autre service, un mapping, un test.
        commande.Lignes[0].Quantite = 3;
        commande.Lignes.Add(new LigneCommande { Reference = "CLAVIER", Quantite = 1, PrixUnitaire = -80m });

        Console.WriteLine(commande.Lignes.Sum(l => l.Quantite * l.PrixUnitaire)); // 820
    }
}

Le total atteint 820 pour un plafond de 500, avec une ligne à prix négatif, sans qu'aucune exception soit levée : le service a vérifié le premier ajout, et c'est tout ce qu'il pouvait faire. Savoir si la règle tient revient à relire tous les appels du service, et ne prouve rien sur le code qui ne l'appelle pas.

Un modèle riche renverse la charge. La règle vit dans le type, les propriétés ne s'écrivent pas de l'extérieur, et le seul moyen de modifier l'objet est une méthode qui vérifie. La question « la règle est-elle respectée partout ? » cesse d'être une enquête sur tout le code appelant : elle se tranche en lisant une seule classe.

Value objects

Un value object est défini entièrement par ses valeurs. Deux montants de 12,50 € sont le même montant ; demander « lequel » n'a pas plus de sens que pour deux chiffres 7. Trois propriétés en découlent. L'égalité compare les valeurs, ce qu'un record synthétise. L'objet est immuable : une opération rend un nouveau montant, et un montant partagé entre deux lignes ne peut pas changer sous l'une d'elles. La validation, enfin, se fait à la construction et une seule fois : un Montant négatif ne peut pas exister, si bien qu'aucune méthode qui en reçoit un n'a à le revérifier.

using System;
using System.Globalization;
using System.Linq;

namespace Ventes.Domaine;

public sealed record Montant
{
    public Montant(decimal valeur, string devise)
    {
        // Valider ici, c'est valider une fois pour toutes : un Montant invalide
        // ne peut pas exister, donc aucun code qui en recoit un n'a a le reverifier.
        ArgumentOutOfRangeException.ThrowIfNegative(valeur);
        if (devise is not { Length: 3 } || !devise.All(char.IsAsciiLetterUpper))
        {
            throw new ArgumentException("Code ISO 4217 attendu, par exemple EUR.", nameof(devise));
        }

        Valeur = valeur;
        Devise = devise;
    }

    // get seul, sans init : with ne peut pas les affecter, donc ne peut pas
    // fabriquer une copie qui aurait echappe au constructeur.
    public decimal Valeur { get; }

    public string Devise { get; }

    public static Montant Zero(string devise) => new(0m, devise);

    // Une operation rend un nouveau montant : l'ancien ne change jamais.
    public static Montant operator +(Montant gauche, Montant droite)
    {
        VerifierMemeDevise(gauche, droite);
        return new Montant(gauche.Valeur + droite.Valeur, gauche.Devise);
    }

    public static Montant operator *(Montant prix, int quantite)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(quantite);
        return new Montant(prix.Valeur * quantite, prix.Devise);
    }

    public bool Depasse(Montant plafond)
    {
        VerifierMemeDevise(this, plafond);
        return Valeur > plafond.Valeur;
    }

    public override string ToString() =>
        $"{Valeur.ToString("0.00", CultureInfo.InvariantCulture)} {Devise}";

    private static void VerifierMemeDevise(Montant a, Montant b)
    {
        if (a.Devise != b.Devise)
        {
            throw new InvalidOperationException($"Devises differentes : {a.Devise} et {b.Devise}.");
        }
    }
}

static class DemonstrationMontant
{
    public static void Executer()
    {
        var prix = new Montant(12.50m, "EUR");

        // L'egalite synthetisee compare les valeurs : 12,50 et 12,5 sont le
        // meme decimal, donc le meme montant.
        Console.WriteLine(prix == new Montant(12.5m, "EUR")); // True
        Console.WriteLine(prix * 3 + new Montant(2m, "EUR")); // 39.50 EUR

        // var negatif = prix with { Valeur = -1m };
        //   CS0200 : la propriete est en lecture seule.

        try
        {
            _ = prix + new Montant(1m, "USD");
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message); // Devises differentes : EUR et USD.
        }
    }
}

La forme positionnelle est écartée à dessein. Un record positionnel reçoit des propriétés init, que with affecte après avoir copié l'objet, sans repasser par le constructeur. Une validation posée dans l'initialiseur de propriété, là où cette forme la place d'ordinaire, est alors contournée par m with { Valeur = -1m }, qui compile et produit un montant négatif. Avec des propriétés en get seul, la même ligne ne compile plus : c'est l'erreur CS0200, propriété en lecture seule.

L'égalité hérite de celle de decimal : 12.50m et 12.5m n'ont pas la même échelle et ne s'affichent pas pareil, mais ils sont égaux et ont le même code de hachage. C'est le bon choix pour un montant, qui se compare par valeur et non par représentation. Quant à l'addition, elle refuse deux devises : un decimal nu aurait additionné des euros et des dollars sans rien dire. C'est l'argument le plus solide pour un value object plutôt qu'un type primitif : le type porte l'unité, et l'opération absurde cesse d'être possible.

Entités

Une entité est définie par une identité qui dure, pas par ses valeurs. Une ligne dont la quantité passe de 1 à 3 reste la même ligne ; deux lignes de même référence, même quantité et même prix sont deux lignes si le client les a saisies séparément. L'entité a un cycle de vie — elle naît, change, disparaît — et c'est son identité qui relie ses états successifs.

using System;

namespace Ventes.Domaine;

public sealed class LigneCommande
{
    // internal : hors du projet Ventes.Domaine, personne ne cree de ligne.
    // Dans le domaine, seule la racine le fait : c'est la regle de l'agregat.
    internal LigneCommande(string reference, int quantite, Montant prixUnitaire)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(reference);

        // L'identite est attribuee a la naissance, par le domaine, et ne
        // change plus : la ligne est la meme avant et apres l'enregistrement.
        Id = Guid.NewGuid();
        Reference = reference;
        PrixUnitaire = prixUnitaire;
        ChangerQuantite(quantite);
    }

    public Guid Id { get; }

    public string Reference { get; }

    public Montant PrixUnitaire { get; }

    // Lisible de partout, modifiable seulement par une methode qui valide.
    public int Quantite { get; private set; }

    public Montant Total => PrixUnitaire * Quantite;

    // La ligne garde sa regle a elle, et celle-la seulement. internal : vue
    // de l'application, une ligne n'a aucune methode qui la modifie.
    internal void ChangerQuantite(int quantite)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantite);
        Quantite = quantite;
    }

    // Deux lignes sont la meme ligne si elles ont la meme identite, quelles
    // que soient leurs valeurs ; deux lignes identiques en tout sauf l'Id
    // sont deux lignes.
    public override bool Equals(object? obj) => obj is LigneCommande autre && autre.Id == Id;

    public override int GetHashCode() => Id.GetHashCode();
}

Trois choix portent le sens. L'identité est attribuée par le domaine, à la construction : une clé générée par la base laisserait l'entité sans identité jusqu'au premier enregistrement, et deux lignes neuves, toutes deux à zéro, seraient égales. L'égalité est redéfinie sur l'identifiant ; sans cela, elle resterait celle d'object, par référence, et la même ligne chargée par deux contextes donnerait deux lignes distinctes. Les setters, enfin, sont privés : la quantité se lit de partout et ne s'écrit que par ChangerQuantite, qui vérifie la règle propre à la ligne et reste internal — la section suivante dit pourquoi.

Value objectEntité
Égalitépar les valeurspar l'identité
Changementimmuable, on le remplacechange au fil de son cycle de vie
Validationà la constructionà la construction et à chaque changement
En C#record scelléclasse, Equals sur l'identifiant
Avec EF Coretype complexe ou possédétype d'entité avec clé

La distinction n'est pas dans l'objet mais dans le domaine : une adresse est un value object pour une commande qui la recopie, et une entité pour un service postal qui la suit dans le temps. La question qui tranche : si deux instances ont les mêmes valeurs, le métier les confond-il ?

Agrégats et racine

Un agrégat est un groupe d'objets qui ne se modifie que d'un bloc, parce qu'une règle les traverse tous. La règle du plafond porte sur la somme des lignes : aucune ligne ne peut la vérifier seule, puisqu'aucune ne voit les autres. La commande et ses lignes forment donc un agrégat, dont la commande est la racine — le seul objet que le code extérieur a le droit de garder et de modifier. Il peut lire les lignes, le temps d'une opération ; vues de Ventes.Application, elles n'ont aucune méthode qui les modifie.

using System;
using System.Collections.Generic;
using System.Linq;

namespace Ventes.Domaine;

public readonly record struct CommandeId(Guid Valeur)
{
    public static CommandeId Nouvelle() => new(Guid.NewGuid());
}

public readonly record struct ClientId(Guid Valeur);

// partial ne sert qu'a decouper l'exemple : la section sur les domain events
// ajoute la validation de la commande dans un second fichier.
public sealed partial class Commande
{
    private readonly List<LigneCommande> _lignes = [];

    // Prive : une Commande ne s'obtient que par la fabrique, qui n'en rend
    // que des valides.
    private Commande(CommandeId id, ClientId client, Montant plafond)
    {
        Id = id;
        Client = client;
        Plafond = plafond;
    }

    public CommandeId Id { get; }

    // Un autre agregat se designe par son identite, jamais par l'objet.
    public ClientId Client { get; }

    // Le plafond accorde au client, fige a la creation de la commande.
    public Montant Plafond { get; }

    public bool EstValidee { get; private set; }

    // Les lignes se lisent, pour la duree d'une operation. AsReadOnly, pas la
    // liste : un IReadOnlyList qui serait la List elle-meme redeviendrait
    // modifiable par un simple cast.
    public IReadOnlyList<LigneCommande> Lignes => _lignes.AsReadOnly();

    public Montant Total =>
        _lignes.Aggregate(Montant.Zero(Plafond.Devise), (total, ligne) => total + ligne.Total);

    public static Commande Creer(ClientId client, Montant plafond) =>
        new(CommandeId.Nouvelle(), client, plafond);

    public Guid AjouterLigne(string reference, int quantite, Montant prixUnitaire)
    {
        VerifierModifiable();
        var ligne = new LigneCommande(reference, quantite, prixUnitaire);
        VerifierPlafond(Total + ligne.Total);

        _lignes.Add(ligne);
        return ligne.Id;
    }

    public void ModifierQuantite(Guid idLigne, int quantite)
    {
        VerifierModifiable();
        var ligne =
            _lignes.Find(l => l.Id == idLigne)
            ?? throw new ArgumentException("Ligne inconnue dans cette commande.", nameof(idLigne));

        // Le total est calcule tel qu'il serait, avant de toucher a quoi que
        // ce soit : si la regle refuse, rien n'a bouge.
        var totalApres = _lignes.Aggregate(
            Montant.Zero(Plafond.Devise),
            (total, l) => total + (l.Id == ligne.Id ? l.PrixUnitaire * quantite : l.Total));
        VerifierPlafond(totalApres);

        ligne.ChangerQuantite(quantite);
    }

    // La racine est une entite comme une autre : egalite par identite.
    public override bool Equals(object? obj) => obj is Commande autre && autre.Id == Id;

    public override int GetHashCode() => Id.GetHashCode();

    private void VerifierPlafond(Montant total)
    {
        if (total.Depasse(Plafond))
        {
            throw new InvalidOperationException($"Plafond de {Plafond} depasse : {total}.");
        }
    }

    private void VerifierModifiable()
    {
        if (EstValidee)
        {
            throw new InvalidOperationException("Une commande validee ne se modifie plus.");
        }
    }
}

Le constructeur privé et la fabrique Creer garantissent qu'aucune commande n'existe sans client ni plafond. AjouterLigne construit la ligne, calcule le total qu'elle donnerait et refuse avant d'avoir rien modifié ; ModifierQuantite fait de même. La racine, entité comme une autre, redéfinit son égalité sur son identifiant. Le client, autre agrégat, n'est désigné que par son identité : tenir l'objet Client permettrait de le modifier à travers une commande, dans la transaction de la commande.

Le plafond est copié à la création, et cela fixe la portée exacte de la règle : l'agrégat garantit le respect du plafond accordé au moment de la commande. Si une baisse du plafond doit s'appliquer aux commandes ouvertes, la règle relie deux agrégats et se tiendra par cohérence à terme, au moyen d'événements.

Depuis l'application, écrire commande.Lignes[0].ChangerQuantite(3) ne compile pas : la méthode internal d'un autre projet est invisible, et l'erreur est CS1061, membre inexistant. Une version publique aurait laissé passer cet appel sans rien signaler. Dans le projet du domaine, en revanche, internal n'arrête rien. Ce service du domaine passe à côté de la règle, parce qu'il tient une ligne et lui parle directement :

using System;
using System.Linq;

namespace Ventes.Domaine;

// Un service du domaine, dans le meme projet que LigneCommande : internal
// ne l'arrete pas. Le besoin : les cables se vendent par cartons de 10.
public static class Conditionnement
{
    public static void ArrondirAuCarton(Commande commande, Guid idLigne, int parCarton)
    {
        // Il tient une ligne et lui parle directement. La ligne verifie sa
        // regle (quantite positive) ; celle de la commande, elle ne la voit pas.
        var ligne = commande.Lignes.Single(l => l.Id == idLigne);
        var cartons = (ligne.Quantite + parCarton - 1) / parCarton;
        ligne.ChangerQuantite(cartons * parCarton);
    }

    public static void Executer()
    {
        var commande = Commande.Creer(new ClientId(Guid.NewGuid()), new Montant(500m, "EUR"));
        commande.AjouterLigne("ECRAN-27", 1, new Montant(300m, "EUR"));
        var cable = commande.AjouterLigne("CABLE-HDMI", 3, new Montant(25m, "EUR"));

        ArrondirAuCarton(commande, cable, 10);

        Console.WriteLine(commande.Total);                           // 550.00 EUR
        Console.WriteLine(commande.Total.Depasse(commande.Plafond)); // True, et aucune exception
    }
}

La liste en lecture seule protège la liste, pas ses éléments. La ligne applique sa règle à elle, accepte, et le total atteint 550 pour un plafond de 500. Le même besoin, adressé à la racine :

using System;
using System.Linq;

namespace Ventes.Domaine;

public static class Conditionnement
{
    public static void ArrondirAuCarton(Commande commande, Guid idLigne, int parCarton)
    {
        // Le service calcule ; c'est la racine qui modifie, parce qu'elle
        // seule voit le plafond et toutes les lignes.
        var quantite = commande.Lignes.Single(l => l.Id == idLigne).Quantite;
        var cartons = (quantite + parCarton - 1) / parCarton;
        commande.ModifierQuantite(idLigne, cartons * parCarton);
    }

    public static void Executer()
    {
        var commande = Commande.Creer(new ClientId(Guid.NewGuid()), new Montant(500m, "EUR"));
        commande.AjouterLigne("ECRAN-27", 1, new Montant(300m, "EUR"));
        var cable = commande.AjouterLigne("CABLE-HDMI", 3, new Montant(25m, "EUR"));

        try
        {
            ArrondirAuCarton(commande, cable, 10);
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message); // Plafond de 500.00 EUR depasse : 550.00 EUR.
        }

        Console.WriteLine(commande.Total); // 375.00 EUR : le refus n'a rien laisse derriere lui

        ArrondirAuCarton(commande, cable, 5);
        Console.WriteLine(commande.Total); // 425.00 EUR
    }
}

Le compilateur ferme la porte à l'application ; à l'intérieur du domaine, c'est la règle de l'agrégat qui la ferme : une ligne ne se modifie que par sa racine.

La frontière fixe aussi la transaction. Une transaction ne modifie qu'un agrégat, parce que l'agrégat est précisément l'unité dont on garantit la cohérence immédiate : ce qui doit être vrai ensemble est dedans, ce qui peut attendre est dehors. Modifier deux agrégats dans la même transaction verrouille deux ensembles de données et couple leurs conflits ; quand le métier l'exige vraiment, c'est souvent que la frontière est mal tracée. D'où la règle de taille : un agrégat est aussi petit que ses invariants le permettent. Une commande qui contiendrait le client et tout son historique serait chargée entière à chaque ajout de ligne.

Repositories

Un repository donne l'illusion d'une collection d'agrégats en mémoire : on y prend un agrégat entier, on l'y remet entier. Il y en a un par agrégat et non un par table : les lignes n'en ont pas, puisqu'elles n'existent qu'à travers leur commande. L'interface vit dans le domaine, à côté de l'agrégat, et l'implémentation dans l'infrastructure. C'est le domaine qui dit ce dont il a besoin, dans son vocabulaire, et la dépendance s'inverse : le domaine ne référence pas EF Core, l'infrastructure référence le domaine.

using System.Threading;
using System.Threading.Tasks;

namespace Ventes.Domaine;

// Dans le domaine, a cote de l'agregat qu'elle sert : c'est le domaine qui
// dit ce dont il a besoin, l'infrastructure qui l'implemente.
public interface ICommandeRepository
{
    // Charge l'agregat entier : la racine et toutes ses lignes, ou rien.
    Task<Commande?> Charger(CommandeId id, CancellationToken annulation = default);

    // Enregistre l'agregat entier. Qu'un cas d'usage ne modifie qu'un agregat
    // est une discipline : rien dans cette signature ne l'impose.
    Task Enregistrer(Commande commande, CancellationToken annulation = default);

    // Ce qui n'y figure pas, et pourquoi :
    // - IQueryable<Commande> : l'appelant composerait des requetes que
    //   l'implementation ne sait pas traduire, et le domaine ne dirait plus
    //   ce qu'il charge.
    // - ChargerLigne, ModifierLigne : une ligne n'existe qu'a travers sa racine.
    // - Rechercher pour un ecran de liste : la lecture n'a pas besoin de
    //   l'agregat, elle interroge la base directement, par projection.
}

Ce qu'elle n'expose pas compte autant que ce qu'elle expose. Pas d'IQueryable : l'appelant composerait des requêtes que seul le fournisseur sait traduire, et découvrirait à l'exécution qu'une méthode du domaine n'a pas d'équivalent SQL. Pas de méthode sur les lignes, qui rouvrirait la porte que la racine vient de fermer. Et pas de recherche pour les écrans : une liste paginée n'a besoin ni des règles ni de l'agrégat entier, elle se lit directement en base, par projection. Écrire passe par l'agrégat, lire n'y est pas obligé — c'est la forme la plus modeste de CQRS, que développe le cours CQRS et Event Sourcing, et elle évite de charger des centaines d'agrégats pour afficher trois colonnes.

Avec EF Core, l'implémentation tient en quelques lignes : Charger inclut les lignes, Enregistrer appelle SaveChangesAsync. Ce dernier ne connaît pas les agrégats : il enregistre tout ce que le DbContext suit, y compris un autre agrégat chargé par un autre repository dans la même requête, puisque le contexte est partagé. La signature suggère la règle d'un agrégat par transaction, elle ne la garantit pas. La règle tient par discipline : un cas d'usage charge et modifie un seul agrégat.

Domain events

Un domain event est un fait du domaine, au passé : la commande a été validée. Il rend possible ce que la règle d'un agrégat par transaction interdit autrement : réagir dans un autre agrégat — réserver le stock, consommer l'encours du client, prévenir la facturation — sans que la commande connaisse aucun de ces destinataires. Ajouter une réaction revient à ajouter un abonné, sans toucher à l'agrégat.

using System;
using System.Collections.Generic;

namespace Ventes.Domaine;

public interface IEvenementDomaine;

// Un fait, au passe, immuable : ce qui est arrive, et ce que les autres ont
// besoin d'en savoir.
public sealed record CommandeValidee(CommandeId Commande, ClientId Client, Montant Total)
    : IEvenementDomaine;

// La seconde partie de Commande : partial ne sert qu'a decouper l'exemple.
public sealed partial class Commande
{
    private readonly List<IEvenementDomaine> _evenements = [];

    public IReadOnlyList<IEvenementDomaine> Evenements => _evenements.AsReadOnly();

    public void Valider()
    {
        VerifierModifiable();
        if (_lignes.Count == 0)
        {
            throw new InvalidOperationException("Une commande vide ne se valide pas.");
        }

        EstValidee = true;

        // Leve au moment ou le changement est decide, par l'agregat qui le
        // decide, et seulement s'il a eu lieu. Rien n'est encore publie : la
        // liste attend que l'application enregistre, puis publie.
        _evenements.Add(new CommandeValidee(Id, Client, Total));
    }

    public void ViderEvenements() => _evenements.Clear();
}

L'événement est levé dans l'agrégat, par la méthode qui décide le changement, et seulement une fois les règles vérifiées : c'est l'agrégat qui sait que le fait a eu lieu. Lever ne veut pas dire publier. L'événement est ajouté à une liste, rien n'est envoyé, et aucun gestionnaire ne s'exécute au milieu d'une méthode du domaine, ce qui garde l'agrégat testable seul. C'est un choix de conception, pas le motif lui-même : Vaughn Vernon publie depuis la méthode de l'agrégat, vers des abonnés dont l'un range l'événement dans la même transaction.

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Ventes.Domaine;

namespace Ventes.Application;

public interface IPublieur
{
    Task Publier(IEvenementDomaine evenement, CancellationToken annulation);
}

public sealed class ValiderCommande(ICommandeRepository commandes, IPublieur publieur)
{
    public async Task Executer(CommandeId id, CancellationToken annulation = default)
    {
        var commande =
            await commandes.Charger(id, annulation)
            ?? throw new InvalidOperationException($"Commande {id.Valeur} introuvable.");

        commande.Valider();
        await commandes.Enregistrer(commande, annulation);

        // L'enregistrement a reussi : ce qu'on annonce est vrai. Publier
        // avant, c'est risquer d'annoncer une validation que la base a refusee.
        var evenements = commande.Evenements.ToList();
        commande.ViderEvenements();
        foreach (var evenement in evenements)
        {
            await publieur.Publier(evenement, annulation);
        }
    }
}

La publication vient après l'enregistrement, dans le service d'application. Publier avant, c'est risquer d'annoncer une validation que la base a refusée — un conflit de concurrence, une contrainte violée — et des abonnés réserveraient du stock pour une commande qui n'est pas validée. Cet ordre a pourtant son propre trou : si le processus s'arrête entre l'enregistrement et la publication, l'événement est perdu. Le remède classique est la boîte d'envoi, ou outbox : les événements sont écrits dans une table, dans la même transaction que l'agrégat, puis un processus séparé les publie. Ce mécanisme garantit une publication au moins une fois, pas exactement une fois, et les abonnés doivent donc tolérer un doublon. Le cours Microservices et messagerie écrit le relais et le consommateur idempotent.

Une variante répandue distribue les événements juste avant SaveChanges, dans la même transaction : c'est le choix de l'application de référence eShop de Microsoft, qui inclut ainsi les effets de bord dans la transaction d'origine. Elle est plus simple à écrire, et elle renonce, pour ces effets-là, à la règle d'un agrégat par transaction.

Ce que ça coûte

Tout cela a un prix, et il se paie avant d'avoir écrit la moindre règle : des fabriques, des constructeurs privés, un value object par notion, une interface par agrégat, et un mapping plus exigeant. EF Core accepte un constructeur privé et des champs de stockage, mais il ne mappe pas par convention une propriété sans setter, et il ne découvre pas seul qu'un Montant est un type complexe. Chaque encapsulation se paie d'une ligne de configuration.

Le modèle est disproportionné quand il n'y a rien à garder. Un référentiel de codes pays, un back-office qui édite des fiches, un service qui recopie des données d'un système à l'autre : l'invariant le plus fort y est « ce champ est obligatoire », qu'un attribut de validation tient très bien. Le signal ne trompe pas : quand les méthodes de l'agrégat se réduisent à des setters renommés, le modèle riche ne protège rien et ne fait que coûter.

Il l'est aussi quand l'agrégat est mal découpé. Trop grand, il se charge entier pour modifier un champ et concentre les conflits de concurrence ; trop petit, une règle tombe entre deux agrégats et doit être tenue par des événements, avec la cohérence à terme et les compensations que cela suppose. Le bon découpage vient des invariants, pas du schéma de la base : on écrit d'abord les règles qui doivent être vraies à chaque instant, et chacune dessine une frontière.

Le DDD tactique s'applique enfin par zone, pas à tout un système. Le même logiciel peut porter un modèle riche pour son cœur — la prise de commande, où une règle cassée coûte de l'argent — et un simple CRUD pour ce qui l'entoure. L'imposer partout coûte autant que ne l'employer nulle part.

Ce cours vous a servi ? Offrir un café Signaler une erreur