Cas avances¶
DDD, CQRS, event sourcing et refactoring legacy — des patterns pour les systèmes complexes qui ont prouve leur utilité à grande échelle.
Complexité accidentelle
Ces patterns résolvent des problèmes réels — mais seulement quand ces problèmes existent. Introduire DDD dans une application CRUD de 10 tables ou CQRS dans un système avec 100 requêtes/jour ajoute de la complexité sans valeur. Lisez, comprenez, puis attendez le bon moment.
Domain-Driven Design (DDD)¶
DDD (Eric Evans, 2003) est une approche de conception centree sur le domaine métier. Son objectif : que le code parle le même langage que les experts métier.
Langage ubiquitaire¶
Tout le monde — développeurs, product owners, experts métier — utilise le même vocabulaire. Ce vocabulaire se retrouve dans le code.
# Mauvais : vocabulaire technique, pas metier
class Data:
def process(self): ...
class Manager:
def handle(self, data): ...
# Bon : vocabulaire du domaine
class Loan:
def approve(self, underwriter: Underwriter): ...
def disburse(self, account: BankAccount): ...
class RiskAssessment:
def evaluate(self, application: LoanApplication) -> RiskScore: ...
Bounded Contexts¶
Un grand système est divise en contextes bornes — chacun avec son propre modèle cohérent.
graph LR
subgraph Catalog["Catalog Context"]
P1["Product\n(name, description, price)"]
end
subgraph Orders["Orders Context"]
P2["Product\n(product_id, unit_price)"]
end
subgraph Shipping["Shipping Context"]
P3["Package\n(weight, dimensions)"]
end
Catalog -- "Anti-Corruption Layer" --> Orders
Orders -- "Domain Event" --> Shipping Le même concept "Produit" a une représentation différente dans chaque contexte. C'est normal et souhaitable — forcer un modèle unifie crée un compromis qui sert mal tous les contextes.
Agregats¶
Un agregat est un cluster d'objets traites comme une unite pour les modifications. Il a une racine d'agregat — le seul objet accessible depuis l'extérieur.
# Order est la racine d'agregat — OrderItem ne s'acces que via Order
class Order:
def __init__(self, id: str, customer_id: str):
self.id = id
self.customer_id = customer_id
self._items: list[OrderItem] = []
self._version = 0
def add_item(self, product_id: str, qty: int, price: float):
if self.status != "draft":
raise DomainError("Impossible d'ajouter un article a une commande confirmee")
# invariant verifie a l'interieur de l'agregat
self._items.append(OrderItem(product_id, qty, price))
def confirm(self):
if not self._items:
raise DomainError("Commande vide")
if self.total() <= 0:
raise DomainError("Total invalide")
self.status = "confirmed"
return [OrderConfirmed(order_id=self.id, total=self.total())]
CQRS — Command Query Responsibility Segregation¶
Séparer les opérations de lecture (Query) des opérations d'écriture (Command).
graph LR
Client --> CMD["Command\n(create, update, delete)"]
Client --> QRY["Query\n(get, list, search)"]
CMD --> WM["Write Model\nDomain Objects"]
QRY --> RM["Read Model\nDenormalized Views"]
WM --> DB1["Write DB"]
RM --> DB2["Read DB / Cache"] # Commands — modifient l'etat, retournent des evenements
@dataclass
class CreateOrderCommand:
customer_id: str
items: list[dict]
class CreateOrderHandler:
def __init__(self, repo: OrderRepository, bus: EventBus):
self._repo = repo
self._bus = bus
def handle(self, cmd: CreateOrderCommand) -> str:
order = Order.create(cmd.customer_id, cmd.items)
events = order.confirm()
self._repo.save(order)
for event in events:
self._bus.publish(event)
return order.id
# Queries — lisent l'etat, ne modifient rien
@dataclass
class GetOrderSummaryQuery:
order_id: str
class GetOrderSummaryHandler:
def __init__(self, read_db):
self._db = read_db
def handle(self, query: GetOrderSummaryQuery) -> dict:
# requete directe sur une vue denormalisee — pas de domain objects
return self._db.fetch_one(
"SELECT * FROM order_summaries WHERE id = ?",
(query.order_id,)
)
Quand CQRS est justifie :
- Modèles de lecture et d'écriture très différents
- Scalabilité asymetrique (beaucoup plus de lectures que d'écritures)
- Besoin de plusieurs projections du même état
Quand l'éviter : applications CRUD simples, équipes petites, pas de divergence lecture/écriture.
Event Sourcing¶
Plutôt que de stocker l'état courant, on stocke la sequence d'événements qui ont produit cet état.
# Sans event sourcing — on stocke l'etat final
orders table:
id=1, status="shipped", total=99.90, updated_at=...
# Avec event sourcing — on stocke les evenements
events table:
order_id=1, type="OrderCreated", data={...}, ts=T1
order_id=1, type="ItemAdded", data={...}, ts=T2
order_id=1, type="OrderConfirmed", data={...}, ts=T3
order_id=1, type="OrderShipped", data={...}, ts=T4
class Order:
def __init__(self):
self._events: list = []
self.status = None
self.items = []
@classmethod
def reconstitute(cls, events: list) -> "Order":
order = cls()
for event in events:
order._apply(event)
return order
def _apply(self, event):
handlers = {
"OrderCreated": self._on_created,
"ItemAdded": self._on_item_added,
"OrderConfirmed": self._on_confirmed,
}
handlers[event["type"]](event["data"])
def _on_created(self, data):
self.id = data["id"]
self.status = "draft"
def _on_confirmed(self, data):
self.status = "confirmed"
Avantages : audit log complet, capacité a rejouer l'historique, debugging facilite, possibilite de construire de nouvelles projections.
Complexité : gestion des schémas d'événements, eventual consistency, snapshots nécessaires sur longues sequences.
Refactoring legacy — patterns de migration¶
Strangler Fig Pattern¶
Remplacer incrementalement un système legacy sans big bang.
graph TD
C["Client"] --> F["Facade / Proxy"]
F -- "nouvelles features" --> N["Nouveau systeme"]
F -- "features non migrees" --> L["Legacy"]
N --> L2["Nouvelle DB"] - Créer une façade qui route vers le legacy
- Migrer feature par feature vers le nouveau système
- Quand tout est migré, supprimer le legacy
Anti-Corruption Layer¶
Traduire entre le modèle du nouveau système et celui du legacy pour les isoler.
# Le legacy a un modele pollution dans votre nouveau code
# L'ACL traduit sans contaminer
class LegacyOrderAdapter:
"""Anti-Corruption Layer : isole notre domaine du modele legacy."""
def __init__(self, legacy_client):
self._legacy = legacy_client
def get_order(self, order_id: str) -> Order:
# le legacy retourne un dict avec des champs cryptiques
raw = self._legacy.fetch_order_data(order_id)
# on traduit vers notre modele propre
return Order(
id=raw["ORD_NUM"],
customer_id=raw["CUST_REF"],
status=self._map_status(raw["STAT_CODE"]),
)
def _map_status(self, legacy_code: str) -> str:
mapping = {"01": "draft", "02": "confirmed", "05": "shipped"}
return mapping.get(legacy_code, "unknown")
Tableau de référence¶
| Pattern | Complexité | Benefice principal | Quand adopter |
|---|---|---|---|
| DDD (tacticiel) | Moyenne | Alignement code/métier | Domaine complexe, équipe stable |
| Bounded Contexts | Moyenne | Isolation des modèles | Système avec plusieurs sous-domaines |
| CQRS | Élevée | Scalabilité asymetrique | Lectures >> écritures, vues multiples |
| Event Sourcing | Très élevée | Audit complet, replay | Finance, conformité, debug critique |
| Strangler Fig | Faible | Migration sans risque | Remplacement incremental de legacy |
| Anti-Corruption Layer | Faible | Isolation du legacy | Intégration de systèmes tiers ou anciens |
La complexité se paie
CQRS avec Event Sourcing sur une application de blog est de la complexité accidentelle pure. Ces patterns ont été valides dans des contextes ou la complexité métier etait réelle : systèmes bancaires, e-commerce à grande échelle, applications d'audit. Evaluez le coût avant le benefice.