Le DDD — Domain Driven Design — est l'une de ces approches dont tout le monde parle mais que peu appliquent vraiment. J'ai vu des équipes renommer leurs dossiers Domain/, Application/, Infrastructure/ et se convaincre d'avoir fait du DDD. J'ai vu des développeurs créer des hiérarchies d'agrégats à cinq niveaux pour modéliser… un module de gestion de congés. Après quinze ans et une dizaine de projets à forte complexité métier, voici ce que le DDD m'a appris — et comment je l'applique sans sur-ingénierie.

DDD stratégique vs DDD tactique : la distinction qui change tout

La première erreur que je vois systématiquement : les équipes sautent directement aux patterns tactiques (agrégats, value objects, domain events) sans avoir fait le travail stratégique. C'est construire une maison en commençant par les prises électriques.

Le DDD stratégique : délimiter le terrain

Le DDD stratégique, c'est l'analyse du domaine métier pour identifier les Bounded Contexts — les frontières au sein desquelles un modèle a un sens cohérent et univoque. Le même mot peut désigner des choses différentes selon le contexte.

Sur une plateforme de gestion de commandes que j'ai conçue, le mot "Client" n'avait pas du tout la même signification dans le contexte Vente (un prospect avec un historique d'achat, un commercial assigné, des conditions tarifaires) et dans le contexte Logistique (une adresse de livraison, un créneau, des instructions de manutention). Vouloir partager un seul modèle Client entre ces deux contextes était la source de toutes les complexités accidentelles.

"La carte n'est pas le territoire. Un modèle DDD n'est pas la réalité — c'est une représentation utile dans un contexte précis. Dès qu'il dépasse ce contexte, il devient un obstacle."

Core Domain, Supporting Subdomain, Generic Subdomain

Pas besoin d'appliquer les patterns tactiques DDD partout. Je distingue toujours :

La conception des agrégats : règle de cohérence avant tout

Un agrégat est un groupe d'objets du domaine traité comme une unité de cohérence transactionnelle. La règle d'or : un agrégat = une transaction. Si vous devez modifier deux agrégats dans la même opération, soit votre découpage est mauvais, soit l'opération doit passer par des événements asynchrones.

En pratique, les agrégats trop larges sont le problème le plus fréquent. J'ai hérité d'un projet où l'agrégat Order contenait le client, toutes les lignes de commande, les adresses, les paiements, les promotions et l'historique des statuts. Le moindre changement déclenchait le chargement de l'objet entier, et les conflits de concurrence étaient permanents.

La correction : réduire l'agrégat à sa frontière de cohérence minimale. La Order n'a besoin que de ses OrderItems pour maintenir ses invariants (montant total, quantités, remises). Le reste vit dans des agrégats distincts.

Un agrégat riche avec invariants en C#

// Agrégat riche — pas de setters publics, les invariants sont enforced dans les méthodes
public sealed class Order : AggregateRoot
{
    private readonly List<OrderItem> _items = new();

    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money TotalAmount { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

    private Order() { } // Pour EF Core

    public static Order Create(CustomerId customerId)
    {
        var order = new Order
        {
            Id = OrderId.New(),
            CustomerId = customerId,
            Status = OrderStatus.Draft,
            TotalAmount = Money.Zero
        };
        order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerId));
        return order;
    }

    public void AddItem(ProductId productId, Quantity quantity, Money unitPrice)
    {
        if(Status != OrderStatus.Draft)
        {
            throw new DomainException("Impossible d'ajouter un article à une commande non-brouillon.");
        }
        if(quantity.Value <= 0)
        {
            throw new DomainException("La quantité doit être positive.");
        }

        var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
        if(existingItem is not null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(OrderItem.Create(productId, quantity, unitPrice));
        }

        RecalculateTotal();
    }

    public void Confirm()
    {
        if(Status != OrderStatus.Draft)
        {
            throw new DomainException("Seule une commande brouillon peut être confirmée.");
        }
        if(!_items.Any())
        {
            throw new DomainException("Impossible de confirmer une commande vide.");
        }

        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, TotalAmount));
    }

    private void RecalculateTotal()
    {
        TotalAmount = _items.Aggregate(Money.Zero, (acc, item) => acc + item.LineTotal);
    }
}

Remarquez : pas un seul setter public. L'agrégat expose des intentions métier (AddItem, Confirm) et protège ses invariants avant chaque modification. Il est impossible de mettre Order dans un état incohérent depuis l'extérieur.

Value Objects : l'outil le plus sous-utilisé du DDD

Les Value Objects sont selon moi l'outil DDD le plus rentable et le plus sous-utilisé. Remplacer un decimal par un Money, un string par un Email, un int par une Quantity permet d'encapsuler les règles de validation et d'éliminer des classes entières de bugs.

// Value Object avec égalité structurelle — C# record est parfait pour ça
public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public static readonly Money Zero = new(0m, "EUR");

    private Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public static Money Of(decimal amount, string currency)
    {
        if(amount < 0)
        {
            throw new DomainException("Un montant ne peut pas être négatif.");
        }
        if(string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
        {
            throw new DomainException("La devise doit être un code ISO 4217 à 3 caractères.");
        }

        return new Money(amount, currency.ToUpperInvariant());
    }

    public static Money operator +(Money left, Money right)
    {
        if(left.Currency != right.Currency)
        {
            throw new DomainException($"Impossible d'additionner {left.Currency} et {right.Currency}.");
        }
        return new Money(left.Amount + right.Amount, left.Currency);
    }

    public override string ToString() => $"{Amount:F2} {Currency}";
}

// Domain Event — immuable, capte ce qui s'est passé
public sealed record OrderConfirmedEvent(
    OrderId OrderId,
    CustomerId CustomerId,
    Money TotalAmount) : IDomainEvent
{
    public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}

Modèle anémique vs modèle riche : le vrai débat

Le modèle anémique — des classes de données sans logique, avec uniquement des propriétés publiques, et la logique métier dispersée dans des services — est encore très répandu dans les projets .NET que je rejoins en mission. Ce n'est pas du DDD. C'est de la programmation procédurale habillée en objet.

Je ne suis pas idéologue là-dessus. Un modèle anémique peut être justifié pour des modules CRUD simples sans règles métier complexes. Mais dès que des invariants apparaissent — "on ne peut pas confirmer une commande vide", "un montant ne peut pas être négatif", "une livraison ne peut pas être reprogrammée après expédition" — ces invariants doivent vivre dans le domaine, pas dans les services applicatifs.

Le signal d'alarme : quand la logique métier est vérifiée à plusieurs endroits du code parce que "on ne sait jamais si quelqu'un a oublié de valider". C'est exactement ce que le modèle riche évite.

Domain Events : découpler les réactions sans perdre la cohérence

Les Domain Events permettent de découpler les effets secondaires d'une opération métier sans sortir de la transaction. Quand une commande est confirmée, peut-être faut-il envoyer un email de confirmation, décrémenter le stock, notifier la comptabilité. Ces responsabilités n'appartiennent pas à l'agrégat Order — elles appartiennent à d'autres contextes.

Mon approche : les domain events sont collectés dans l'agrégat pendant la transaction, puis dispatché via MediatR après la persistance. Cela garantit que les effets secondaires ne s'exécutent que si la transaction principale a réussi.

"Un Domain Event ne dit pas 'fais ça' — il dit 'ça s'est passé'. La différence est fondamentale : elle inverse le couplage."

DDD et Clean Architecture : une combinaison naturelle

Le DDD et la Clean Architecture se complètent naturellement. La couche Domain contient les agrégats, value objects et domain events — zéro dépendance externe. La couche Application orchestre les use cases via les commandes CQRS, dispatche les domain events, et coordonne l'Infrastructure. La couche Infrastructure implémente les repositories avec EF Core, traduit les entités de domaine en entités de persistance si nécessaire.

Sur des projets complexes comme ceux que je décris dans l'article sur les microservices et monolithes modulaires, cette combinaison garantit que les modules restent indépendants et que les règles métier ne fuient pas vers les couches techniques.

Le DDD mal appliqué génère de la complexité accidentelle. Le DDD bien ciblé — sur le Core Domain, avec des boundaries respectés — est l'investissement qui protège le code sur le long terme. La différence entre les deux tient à une seule question : cette complexité existe-t-elle dans le domaine métier, ou est-ce moi qui l'ai introduite ?

Vous modélisez un domaine métier complexe en .NET ?

J'interviens pour structurer votre domaine, former vos équipes aux patterns DDD
et revoir les agrégats existants qui posent problème.

✉ Me contacter Voir ma stack →