← Retour

Conception détaillée d'une application — Modélisation et architecture interne

Après avoir défini les objectifs métier et découpé le système en Bounded Contexts, l'étape suivante consiste à descendre au niveau de la conception détaillée : comment concevoir l'intérieur de chaque domaine pour garantir robustesse, maintenabilité et clarté ?

Reprenons notre fil conducteur e-commerce (Prise de commande) pour explorer les décisions de conception détaillée au cœur du système.


🏗️ 1. Modélisation tactique DDD : Agrégats, Entités & Value Objects

Dans un domaine métier, tout n'a pas la même nature. La conception détaillée distingue précisément :

💎 Value Objects (Objets-Valeurs)

Objets immuables définis uniquement par leurs valeurs (pas d'identifiant technique). Ils encapsulent la validation et empêchent les états incohérents dès l'instanciation.

🛒 Exemples : Money(amount: 199.99, currency: EUR), Quantity(2), Email("client@domain.com").

🆔 Entités (Entities)

Objets possédant une identité unique continue dans le temps, même si leurs attributs changent au fil du cycle de vie.

🛒 Exemple : OrderLine(id: "item-12", productId: "PROD-98", quantity: 2, unitPrice: 199.99€).

🛡️ Racine d'Agrégat (Aggregate Root)

La grappe d'objets traitée comme une unité transactionnelle indivisible. C'est la seule porte d'entrée : aucun composant externe ne modifie les objets internes directement.

🛒 Exemple : Order ou Cart sont des racines qui contrôlent leurs lignes internes, recalculent leurs totaux et valident les transitions.

🏷️ Diagramme de classes métier & classification tactique

Voici le diagramme de classes complet intégrant l'ensemble du cycle de vente : du Catalogue Produit et du Panier (Cart) jusqu'à la Commande (Order) et ses contextes satellites. Chaque classe arbore son stéréotype tactique explicite : «Aggregate Root», «Entity» ou «Value Object».

Légende des stéréotypes DDD : 🛡️ «Aggregate Root» 🆔 «Entity» 💎 «Value Object» 📦 Catalog Context «Aggregate Root» CatalogProduct - id: ProductId - sku: SKU - title: ProductName - unitPrice: Money - isPublished: boolean + updatePrice(newPrice) + publish(): void 📦 Cart Context (Panier) «Aggregate Root» Cart - id: CartId - custId: CustomerId - updatedAt: Date + addItem() + removeItem() + clear() «Entity» CartItem - id: CartItemId - prodId: ProductId - qty: Quantity - price: Money 📦 Ordering Context (Prise de Commande) «Aggregate Root» Order - id: OrderId - customerId: CustomerId - status: OrderStatus - address: ShippingAddress - version: number + addLine(prodId, qty, price) + markAsPaid(): void + cancel(reason): void «Entity» OrderLine - id: OrderLineId - productId: ProductId - quantity: Quantity - unitPrice: Money «Entity» AppliedPromotion - promoCode: PromoCode, discount: Money ⚡ Événements de Domaine émis : • CartCheckedOut(cartId, customerId) • OrderPlaced(orderId, customerId, totalAmount, lines[]) • OrderPaid(orderId, paymentTransactionId) • OrderShipped(orderId, trackingNumber) • OrderCancelled(orderId, reason) 💎 Value Objects (Objets-Valeurs partagés / spécialisés) «Value Object» Money amount: number currency: Currency «Value Object» Quantity value: positiveInt max: 20 per item «Value Object» ShippingAddress street: string, zipCode: string, city: string country: CountryCode «Value Object» OrderStatus DRAFT | PAID | PENDING | SHIPPED «Value Object» PromoCode code: string discountRate: number «Value Object» — Identifiants Fortement Typés • OrderId(value: UUID) • CartId(value: UUID) • CartItemId(value: UUID) • OrderLineId(value: UUID) • CustomerId(value: UUID) • ProductId(value: SKU / string) • TransactionId(value: UUID) • IdempotencyKey(key: string) «Value Object» — Règles & Formats • CustomerEmail(validFormatRegex) • SKU(code: "ELEC-SAM-S24") • VatRate(percentage: 20.0) • ProductName(title: string) • TrackingNumber(carrier, num) 🌐 Bounded Contexts Distants «Aggregate Root» (Identity) Customer - id: CustomerId - email: CustomerEmail - creditLimit: Money + canPlaceOrder(): bool «Aggregate Root» (Stock) InventoryItem - productId: ProductId - availableQty: Quantity + reserve(qty): bool + release(qty): void «Aggregate Root» (Payment) PaymentTransaction - txId: TransactionId - orderId: OrderId - amount: Money + capture(): Receipt checkout()

📐 Règles d'invariants et frontières transactionnelles

Comme illustré dans le diagramme de classes ci-dessus :

  1. Cycle Panier $\rightarrow$ Commande (Cart vs Order) :
    • Le Panier (Cart) est un agrégat volatile et permissif (ajout/retrait sans réservation définitive).
    • Lors de la validation (Checkout), le panier se fige et instancie l'agrégat Commande (Order) avec ses invariants stricts (prix garantis, adresses validées, vérification des plafonds).
  2. Découplage avec le Catalogue (CatalogProduct) :
    • L'agrégat OrderLine et CartItem ne référencent le produit que par son ProductId et copient son prix instantané dans un Money immuable. Si le catalogue modifie le tarif le lendemain, les commandes antérieures restent inchangées.
  3. Intégrité forte dans chaque agrégat :
    • Les entités internes (OrderLine, CartItem, AppliedPromotion) ne sont accessibles et modifiables que par leurs racines respectives (Order, Cart).
  4. Découplage inter-contextes (Customer, Stock, Payment) :
    • Les interactions entre contextes ne passent jamais par des références d'objets en mémoire mais par des identifiants fortement typés (CustomerId, ProductId, OrderId) et des événements de domaine asynchrones.

🎯 2. Conception des contrats et flux de données (Ports & DTOs)

La conception détaillée formalise précisément la frontière entre l'extérieur et le cœur métier grâce à des contrats explicites :

1. Flux architectural : DTO d'entrée ➔ Use Case ➔ Domaine (Objets Métier) ➔ DTO de sortie

Une DTO d'entrée (Data Transfer Object) transmet les données brutes depuis l'extérieur (API REST, message broker, UI) vers le Use Case sans comporter de logique métier. Le Use Case orchestre l'exécution, charge et manipule les Objets Métier du Domaine (Racines d'agrégat, Entités, Objets-Valeurs), applique les règles d'invariants, puis projette le résultat final dans une DTO de sortie immuable.

Flux DTO d'entrée vers Use Case, Domaine Métier et DTO de sortie pour tous les scénarios 📥 DTO d'entrée (Command / Query) ⚙️ Use Case (Application) 🏛️ Domaine Métier (Objets & Invariants) 📤 DTO de sortie (Result) SearchCatalogQuery • keyword?: string • category?: string • minPrice / maxPrice?: number • sortBy?: 'price' | 'popular' + GetProductQuery(id) Critères de recherche & filtres SearchCatalogUseCase 1. Valide les critères de filtre 2. CatalogRepository.search() 3. Filtre produits actifs en stock 4. Calcule prix remisés / TVA 5. Mappe vers CatalogResult Consultation & indexation Domaine : Référentiel Catalogue 🟣 «Aggregate Root» CatalogProduct 🟢 «Value Objects» ProductId, SKU, Money, VatRate Invariants : SKU unique, Prix >= 0 Règle : Produit inactif masqué en recherche Référentiel produit immuable en lecture CatalogResult • total: 42 • items: ProductSummary[] • facets: CategoryFacet[] + ProductDetails Vue paginée & enrichie AddCartItemCommand • cartId: CartId • productId: ProductId • quantity: number (> 0) + UpdateCartItemCommand + Remove / ClearCartCommand Commandes de mutation panier ManageCartUseCase 1. CartRepository.findById() 2. CatalogGateway.checkStock() 3. cart.addItem(item, qty) 4. CartRepository.save(cart) 5. Mappe vers CartResult Gestion de session & articles Cœur de Domaine : Panier 🟣 «Aggregate Root» Cart 🟠 «Entity» CartItem 🟢 «Value Objects» CartId, ProductId, Money, PromoCode Invariants : Max 10 unités / référence Calcul : Total = Σ(CartItem.price * qty) Agrégat dynamique & permissif CartResult • cartId: UUID • itemsCount: 3 • totalHT: 85.00 € • totalTTC: 102.00 € • items: CartItemDto[] Synthèse du panier mis à jour CreateOrderCommand • customerId: CustomerId • cartId: CartId • shippingAddress: {...} + ConfirmOrderCommand + CancelOrderCommand(reason) Création / Validation commande PlaceOrder / ConfirmOrderUseCase 1. Charge Panier & Client 2. Instancie l'agrégat Order 3. Order.validate() + calculate() 4. OrderRepository.save(order) 5. EventPublisher.publish() Orchestration & Transactions Cœur de Domaine : Commande 🟣 «Aggregate Root» Order 🟠 «Entity» OrderLine, Promo 🟢 «Value Objects» ShippingAddress, Money, OrderId Invariants : Total = Sum(Lines) - Remise Transition : DRAFT ➔ PAID ➔ SHIPPED Règles métier pures sans framework OrderResult • orderId: UUID • status: "PENDING" • totalTTC: 129.90 € • createdAt: ISO8601 • lines: OrderLineDto[] Réponse projetée immuable AuthorizePaymentCommand • orderId: OrderId • amount: number • currency: "EUR" + CapturePaymentCommand + RefundPaymentCommand(amount) Ordre de transaction financière PaymentUseCase 1. Vérifie statut de la Commande 2. Appel Stripe SPI (charge) 3. Enregistre PaymentTx 4. Émet PaymentAuthorizedEvent 5. Mappe vers PaymentResult Sécurisation & délégation PSP Cœur de Domaine : Paiement 🟣 «Aggregate Root» PaymentTransaction 🟢 «Value Objects» TransactionId, Money, OrderId Invariants : Montant conforme > 0 Idempotence : 1 seul débit / OrderId Traçabilité & transactions financières PaymentResult • txId: UUID • status: "AUTHORIZED" • receiptUrl: string • paidAt: ISO8601 + PaymentReceipt Confirmation du règlement GetTrackingQuery • orderId: OrderId • trackingNumber?: string + ListOrderEventsQuery(orderId) + GetDeliveryStatusQuery(id) Requêtes d'état d'expédition TrackOrderUseCase 1. Vérifie existence OrderId 2. CarrierGateway.getShipment() 3. EventStore.getDomainEvents() 4. Agrège historique des étapes 5. Mappe vers TrackingResult Suivi en temps réel & audit Domaine : Suivi & Événements 🟣 «Aggregate Root» OrderTracking 🟢 «Value Objects» TrackingNumber, Carrier, DomainEvent Invariants : Étapes ordonnées chronologiquement Statuts : PREPARATION ➔ SHIPPED ➔ DELIVERED Historique immuable d'événements TrackingResult • trackingNumber: string • carrier: "DHL" | "Chronopost" • status: "IN_TRANSIT" • estimatedDelivery: ISO8601 • checkpoints: EventDto[] Timeline de livraison complète

🔒 3. Gestion de la concurrence et transactions

Dans une application à forte charge (ex: fortes ventes de produits limités ou mises à jour concurrentes d'un panier/commande), la conception détaillée doit anticiper les conflits d'accès et garantir la cohérence des données.

🛡️ Le Verrouillage Optimiste (Optimistic Locking) avec numéro de version

Le verrouillage optimiste part du principe que les conflits concurrents sont rares, évitant ainsi de poser des verrous bloquants en base (comme un SELECT FOR UPDATE qui dégrade fortement le débit).

1. Principe de fonctionnement

  1. Lecture initiale : Chaque agrégat métier (ex: Order, Cart, InventoryItem) possède un attribut version: number persisté en base de données (initialisé à 1). Lorsque la requête charge l'agrégat, elle lit l'état actuel et capture sa version (ex: version = 1).
  2. Mutation en mémoire : Le Use Case applique les règles métier et incrémente la version attendue (version = 2).
  3. Écriture atomique conditionnelle : Lors de la sauvegarde (UPDATE), une clause conditionnelle vérifie strictement que la version en base n'a pas bougé depuis la lecture :
    UPDATE orders 
    SET status = 'PAID', version = version + 1 
    WHERE id = :id AND version = 1;
    
  4. Détection du conflit :
    • Requête gagnante (Requête A) : La ligne est trouvée (row_count = 1), l'écriture est validée, la version passe à 2.
    • Requête concurrente (Requête B) : La version en base valant désormais 2, la condition WHERE version = 1 ne correspond à aucun enregistrement (row_count = 0). La base rejette l'écriture sans bloquer les autres transactions.
  5. Gestion de l'exception métier : L'ORM ou le Repository lève une exception dédiée (ex: OptimisticLockException). Le système peut alors au choix :
    • Rejouer automatiquement le Use Case (Retry) avec les nouvelles données fraîches.
    • Retourner une réponse HTTP 409 Conflict au client avec l'état à jour.

2. Schéma séquentiel du Verrouillage Optimiste

Séquence du Verrouillage Optimiste : Détection de Conflit via champ "version" 👤 Client 1 (Requête A) 🗄️ Base de données (DB) 👤 Client 2 (Requête B) 1. SELECT id, total, version FROM orders Retour : Order { version: 1 } 2. SELECT id, total, version FROM orders Retour : Order { version: 1 } Calcul métier A order.pay() Calcul métier B order.cancel() 3. UPDATE orders SET ..., version=2 WHERE v=1 ✅ Succès (1 row) Version en base = 2 HTTP 200 OK (Commande validée) 4. UPDATE orders SET ..., version=2 WHERE v=1 ❌ Conflit (0 row) v=1 introuvable (v=2) Rollback immédiat OptimisticLockException (0 ligne affectée) Réponse HTTP 409 Conflict ou Retry automatique avec rechargement

🔑 L'Idempotence (Idempotency Key) et déduplication des requêtes

Dans les architectures web et distribuées, les défaillances réseau (timeouts, déconnexions) ou les doubles clics utilisateurs entraînent fréquemment la réémission d'une même commande de modification (POST /v1/orders, POST /v1/payments). Sans idempotence, le client risquerait d'être débité deux fois ou d'engendrer deux commandes identiques.

L'idempotence garantit qu'exécuter une opération une ou plusieurs fois produit exactement le même état métier sur le système.

1. Principe de fonctionnement

  1. Émission de la clé unique : Le client génère un UUID aléatoire et l'envoie dans un en-tête HTTP dédié : Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d.
  2. Verrou atomique d'enregistrement : Dès réception, un middleware ou l'intercepteur applicatif tente d'insérer cette clé dans une table ou un store dédié (idempotency_keys) de manière atomique :
    INSERT INTO idempotency_records (key, request_hash, status, created_at)
    VALUES (:idempotency_key, :hash, 'IN_PROGRESS', NOW())
    ON CONFLICT (key) DO NOTHING;
    
  3. Arbitrage selon l'état de la clé :
    • Premier appel (IN_PROGRESS) : La ligne est insérée avec succès. Le système sait qu'il s'agit du traitement légitime et exécute le Use Case métier (réservation stock, débit bancaire).
    • Appel concurrent en cours : Si une requête avec la même clé arrive alors que le statut est toujours IN_PROGRESS, le serveur renvoie immédiatement une erreur 409 Conflict (ou 425 Too Early) indiquant qu'un traitement identique est déjà en cours.
    • Appel rejoué après succès (COMPLETED) : Si la clé existe déjà avec le statut COMPLETED, le moteur applicatif court-circuite complètement le Use Case : aucun recalcul ni prélèvement n'est relancé. Il lit la réponse mise en cache (response_code, response_body) et la restitue à l'identique.
  4. Finalisation transactionnelle : Une fois la commande persistée avec succès, le statut passe à COMPLETED avec son corps de réponse JSON et un délai d'expiration (TTL de 24h à 7 jours).

2. Schéma séquentiel de l'Idempotence

Séquence de Déduplication par Clé d'Idempotence (Idempotency-Key) 📱 Client (App / Browser) 🛡️ Intercepteur & Store Idempotence ⚙️ Use Case & DB Métier POST /v1/orders (Idempotency-Key: K1) Lock atomique clé K1 Status = 'IN_PROGRESS' Exécute PlaceOrderUseCase() Création Order #CMD-42 Débit bancaire Stripe OK OrderCreated { id: 42, total: 99€ } Mise en cache du payload Status = 'COMPLETED', HTTP 201 HTTP 201 Created (Commande validée) ⚡ Réémission de la même requête (Perte de réseau, timeout ou double-clic) POST /v1/orders (Idempotency-Key: K1) 🔍 Clé K1 déjà présente ! Status: COMPLETED (Cache Hit) ⛔ COURT-CIRCUIT DU DOMAINE ❌ Zéro réexécution métier HTTP 201 Created (Réponse originale rejouée depuis le cache) Résultat strictement identique, aucun doublon créé ni second débit

📮 Le Pattern Outbox Transactionnelle (Transactional Outbox)

Dans une architecture distribuée ou orientée événements (Event-Driven), un cas classique de défaillance est le problème de double écriture non atomique (Dual-Write Problem).

1. Le problème de la double écriture (Dual-Write Problem)

Lorsqu'un Use Case valide une commande, deux opérations doivent obligatoirement réussir de concert :

  1. Écrire en base de données locale : Sauvegarder la commande (INSERT INTO orders).
  2. Publier un événement métier : Notifier Kafka, RabbitMQ ou AWS SQS (publisher.send(new OrderPaidEvent())).

Si le serveur plante ou le réseau coupe après le commit SQL mais avant la publication du message, l'événement est définitivement perdu et les systèmes avals (facturation, expédition, notification client) ne seront jamais déclenchés. À l'inverse, si l'on publie le message avant et que le commit SQL échoue, les services consommateurs traitent un événement fantôme qui n'existe pas en base.

Le Pattern Outbox Transactionnelle résout ce dilemme en transformant la publication d'événement en une écriture locale dans la même transaction ACID.

2. Principe de fonctionnement étape par étape

  1. Écriture atomique combinée : Dans une unique transaction SQL, le Use Case insère à la fois l'agrégat métier dans la table orders et l'événement correspondant dans une table locale outbox_events :
    BEGIN TRANSACTION;
      INSERT INTO orders (id, customer_id, total, status) 
      VALUES ('CMD-42', 'CUST-1', 99.00, 'PAID');
    
      INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload, status, created_at)
      VALUES (gen_random_uuid(), 'Order', 'CMD-42', 'OrderPaidEvent', '{"orderId":"CMD-42","amount":99.00}', 'PENDING', NOW());
    COMMIT;
    
    Garantie ACID : Soit la commande et l'événement sont tous les deux persistés, soit aucun des deux ne l'est.
  2. Découplage de la publication (Outbox Relayer / Message Relay) : Un processus d'arrière-plan asynchrone (worker dédié via polling périodique ou moteur de CDC comme Debezium lisant le transaction log de la base) scrute les lignes PENDING de la table outbox_events.
  3. Publication sur le Message Broker : Le relayer envoie le message vers le broker (Kafka, RabbitMQ, EventBridge).
  4. Acquittement ou archivage : Dès réception de l'accusé de réception (ACK) du broker, le worker met à jour la ligne outbox à PUBLISHED (ou supprime la ligne) pour garantir une sémantique de livraison au moins une fois (At-Least-Once Delivery).

3. Schéma séquentiel du Pattern Outbox Transactionnelle

Séquence du Pattern Outbox Transactionnelle : Élimination du problème de Double Écriture ⚙️ Use Case (Application) 🗄️ DB Locale (Orders & Outbox) 🔄 Outbox Relayer (Worker / CDC) 📬 Message Broker (Kafka) PHASE 1 : TRANSACTION UNIQUE ACID LOCALE (ZÉRO DÉPENDANCE RÉSEAU EXTERNE) 1. BEGIN TRANSACTION 2. INSERT INTO orders (status: 'PAID') 3. INSERT INTO outbox_events ('OrderPaid', 'PENDING') 4. COMMIT TRANSACTION Transaction commitée ✅ Commande & Event persistés Succès HTTP 200/201 au Client (Instantané) PHASE 2 : PUBLICATION ASYNCHRONE ET RÉSISTANCE AUX PANNES BROKER 5. SELECT WHERE status='PENDING' (ou log CDC) Retourne : [ Event #E1 (OrderPaidEvent) ] 6. producer.send("orders.events", E1) Message répliqué Offset validé 7. Broker ACK (Accusé de réception) 8. UPDATE outbox_events SET status='PUBLISHED' WHERE id=E1 Garantie At-Least-Once : Aucun message perdu même en cas de crash serveur ou broker indisponible