13 — Patterns avancés#
Les patterns de ce chapitre s’adressent aux architectures distribuées et aux problèmes de migration à grande échelle. Ils ne s’appliquent pas dans un simple monolithe — mais comprendre leur mécanique permet de concevoir des systèmes monolithiques qui pourront évoluer sans refonte.
Saga Pattern — transactions distribuées sans verrou global#
Problème résolu#
Dans un système distribué, une opération métier peut impliquer plusieurs services indépendants : paiement, inventaire, expédition. Un verrou distribué à deux phases (2PC — Two-Phase Commit) crée un couplage fort et un point de défaillance central. La Saga remplace cette transaction globale par une séquence de transactions locales compensables.
Choreography vs Orchestration#
Il existe deux variantes de Saga, avec des compromis très différents :
Choreography |
Orchestration |
|
|---|---|---|
Coordination |
Les services s’écoutent mutuellement via des événements |
Un orchestrateur central émet les commandes |
Couplage |
Faible (bus d’événements) |
Fort (l’orchestrateur connaît tous les services) |
Observabilité |
Difficile (flux distribué) |
Facile (l’orchestrateur trace tout) |
Complexité |
Émerge avec le nombre de services |
Concentrée dans l’orchestrateur |
Idéal pour |
Sagas simples (2-3 étapes) |
Sagas complexes avec conditions |
Compensation events#
Chaque étape de la Saga a une transaction compensatoire (rollback métier) :
Étape Transaction directe Transaction compensatoire
─────────────────────────────────────────────────────────────────────
Réserver stock ReserveStock ReleaseStock
Débiter paiement ChargePayment RefundPayment
Créer expédition CreateShipment CancelShipment
Si l’étape 3 échoue, le Saga exécute CancelShipment (si créée), puis RefundPayment, puis ReleaseStock — dans l’ordre inverse.
Bulkhead — isolation des ressources#
Problème résolu#
Dans un service qui appelle plusieurs backends (API A, API B, base de données), si l’API A est lente et monopolise tous les threads du pool, les appels vers API B et la base de données sont aussi bloqués — même s’ils fonctionnent parfaitement. Le Bulkhead (cloison étanche) isole les ressources par destination pour éviter cette contamination.
Isolation par pool de connexions#
Service
├── Pool API-A (max 10 connexions) ← si saturé, seul l'API A est bloqué
├── Pool API-B (max 10 connexions)
└── Pool DB (max 20 connexions)
Le Bulkhead complète le Circuit Breaker : le Circuit Breaker détecte les pannes, le Bulkhead limite la propagation avant même que la panne soit détectée.
import threading
from typing import Callable, Any
from concurrent.futures import ThreadPoolExecutor, TimeoutError
class BulkheadPool:
def __init__(self, name: str, max_concurrent: int, timeout: float = 5.0):
self.name = name
self._semaphore = threading.Semaphore(max_concurrent)
self._max = max_concurrent
self._timeout = timeout
self._current = 0
self._rejected = 0
self._lock = threading.Lock()
def execute(self, fn: Callable, *args, **kwargs) -> Any:
acquired = self._semaphore.acquire(timeout=self._timeout)
if not acquired:
with self._lock:
self._rejected += 1
raise RuntimeError(f"[BULKHEAD:{self.name}] Capacité maximale atteinte — requête rejetée")
with self._lock:
self._current += 1
try:
return fn(*args, **kwargs)
finally:
self._semaphore.release()
with self._lock:
self._current -= 1
@property
def stats(self) -> dict:
return {
"name": self.name,
"max": self._max,
"current": self._current,
"rejected": self._rejected,
}
Sidecar et Ambassador#
Sidecar#
Le pattern Sidecar déploie un conteneur auxiliaire à côté du conteneur applicatif dans le même pod (Kubernetes). Il prend en charge des préoccupations transverses sans modifier l’application :
Collecte de logs (Fluentd, Filebeat)
Exposition de métriques (Prometheus exporter)
Proxy de service (Envoy, Linkerd)
Renouvellement de certificats TLS
Pod Kubernetes
├── Container App (port 8080) ← ne connaît pas le proxy
└── Container Sidecar (port 15001) ← intercepte tout le trafic réseau
│
▼
Service Mesh (mTLS, retry, circuit breaker, tracing)
L’avantage principal : les capacités réseau (retry, timeout, tracing distribué) sont configurées dans le sidecar et s’appliquent à l’application sans que son code soit modifié.
Ambassador#
L”Ambassador est un sidecar spécialisé dans le proxy sortant : il prend en charge la connexion à des services externes (connexion à une base de données legacy, transformation de protocoles, gestion des credentials).
App Container ──► Ambassador ──► Service externe
(retry, (MongoDB,
TLS, API tierce)
auth)
Différence clé : le Sidecar gère le trafic réseau général (entrant et sortant) ; l’Ambassador est focalisé sur la connexion à un service externe spécifique.
Anti-Corruption Layer (ACL) — isolation des modèles#
Problème résolu#
Intégrer un système legacy ou une API tierce sans l’Anti-Corruption Layer revient à laisser le modèle externe contaminer le modèle de domaine. Au fil du temps, les concepts externes (noms de champs, structures, conventions) s’infiltrent dans le code métier et créent une dépendance profonde difficile à dénouer.
L’ACL traduit dans les deux sens entre le modèle externe et le modèle interne.
Nouveau domaine
└── ACL (Traducteur)
├── → Mapper entrant : externe → interne
├── ← Mapper sortant : interne → externe
└── Service externe (legacy / API tierce)
from dataclasses import dataclass
from typing import Optional
# Modèle legacy (hérité, que l'on ne contrôle pas)
@dataclass
class LegacyCustomer:
cust_id: str
cust_nm: str # champ legacy tronqué
cust_em: str # notation cryptique
cust_stat: str # "A" = actif, "I" = inactif, "S" = suspendu
crd_lmt: float # credit_limit en centimes
# Modèle de domaine (riche, expressif)
@dataclass
class Customer:
id: str
full_name: str
email: str
is_active: bool
credit_limit_euros: float
class CustomerACL:
"""Anti-Corruption Layer : traduit entre legacy et domaine."""
STATUS_MAP = {"A": True, "I": False, "S": False}
def to_domain(self, legacy: LegacyCustomer) -> Customer:
return Customer(
id=legacy.cust_id,
full_name=legacy.cust_nm,
email=legacy.cust_em,
is_active=self.STATUS_MAP.get(legacy.cust_stat, False),
credit_limit_euros=legacy.crd_lmt / 100.0,
)
def to_legacy(self, customer: Customer) -> LegacyCustomer:
status = "A" if customer.is_active else "I"
return LegacyCustomer(
cust_id=customer.id,
cust_nm=customer.full_name,
cust_em=customer.email,
cust_stat=status,
crd_lmt=customer.credit_limit_euros * 100,
)
Strangler Fig Pattern — migration incrémentale#
Le Strangler Fig (figuier étrangleur) est détaillé dans sa version complète ici. La métaphore vient d’une liane tropicale qui pousse autour d’un arbre, prend progressivement sa place, jusqu’à ce que l’arbre disparaisse.
Implémentation avec feature flags#
from enum import Enum, auto
from typing import Dict, Callable, Any, Set
class FeatureFlag(Enum):
LEGACY = auto()
NEW = auto()
class StranglerFacade:
def __init__(self, legacy_system, new_system):
self._legacy = legacy_system
self._new = new_system
self._migrated: Set[str] = set()
self._shadow_mode: Set[str] = set() # Teste les deux, renvoie le legacy
def migrate(self, feature: str) -> None:
self._migrated.add(feature)
self._shadow_mode.discard(feature)
print(f"[STRANGLER] '{feature}' migré vers le nouveau système")
def shadow_test(self, feature: str) -> None:
"""Mode shadow : exécute les deux, compare, renvoie le legacy."""
self._shadow_mode.add(feature)
print(f"[STRANGLER] '{feature}' en mode shadow (test silencieux)")
def handle(self, feature: str, *args, **kwargs) -> Any:
if feature in self._migrated:
return self._new(feature, *args, **kwargs)
if feature in self._shadow_mode:
# En production : comparer les résultats et alerter si divergence
legacy_result = self._legacy(feature, *args, **kwargs)
try:
new_result = self._new(feature, *args, **kwargs)
if legacy_result != new_result:
print(f" [SHADOW] Divergence détectée sur '{feature}'!")
except Exception as e:
print(f" [SHADOW] Erreur sur le nouveau système: {e}")
return legacy_result
return self._legacy(feature, *args, **kwargs)
@property
def progress(self) -> str:
return f"{len(self._migrated)} feature(s) migrée(s)"
Backend for Frontend (BFF)#
Problème résolu#
Une API générique doit servir des clients aux besoins très différents : l’application mobile veut des payloads compacts (économie de bande passante, batterie), l’application web veut des données enrichies, les partenaires tiers veulent une API stable et versionnée. Une API unique ne peut pas satisfaire ces trois contraintes simultanément.
Le BFF crée une API par type de client, taillée exactement pour ses besoins.
Mobile App ──► BFF Mobile ──► Microservices
Web App ──► BFF Web ──► Microservices
Partenaires► BFF Partners ──► Microservices
Responsabilités du BFF#
Agrégation : appeler plusieurs microservices et assembler la réponse en une seule
Transformation : formater les données selon les besoins du client
Authentification : gérer les tokens spécifiques au canal (OAuth mobile vs session web)
Versioning : absorber les changements des microservices sans impacter les clients
from dataclasses import dataclass
from typing import Dict, Any, List
# Données brutes des microservices
ORDERS_SERVICE = {
"ORD-001": {"id": "ORD-001", "user_id": "U1", "items": [{"sku": "P1", "qty": 2, "price": 35.0}], "status": "confirmed", "total": 70.0},
}
USERS_SERVICE = {
"U1": {"id": "U1", "name": "Alice Martin", "email": "alice@example.com", "loyalty_points": 150},
}
PRODUCTS_SERVICE = {
"P1": {"sku": "P1", "name": "Livre Python", "thumbnail": "thumb_p1.jpg", "weight_g": 450},
}
class BFFMobile:
"""BFF mobile : payloads compacts, optimisés pour la bande passante."""
def get_order_summary(self, order_id: str) -> Dict[str, Any]:
order = ORDERS_SERVICE.get(order_id, {})
return {
"id": order.get("id"),
"status": order.get("status"),
"total": order.get("total"),
"item_count": len(order.get("items", [])),
}
class BFFWeb:
"""BFF web : réponses enrichies, agrégées."""
def get_order_detail(self, order_id: str) -> Dict[str, Any]:
order = ORDERS_SERVICE.get(order_id, {})
user = USERS_SERVICE.get(order.get("user_id"), {})
items_enriched = []
for item in order.get("items", []):
product = PRODUCTS_SERVICE.get(item["sku"], {})
items_enriched.append({
**item,
"product_name": product.get("name"),
"thumbnail": product.get("thumbnail"),
})
return {
"order": {**order, "items": items_enriched},
"customer": {"name": user.get("name"), "loyalty_points": user.get("loyalty_points")},
}
API Gateway — routing, auth et transformation#
Différence avec le BFF#
API Gateway |
BFF |
|---|---|
Point d’entrée universel |
Point d’entrée par type de client |
Routing vers les bons services |
Agrégation et transformation pour un client |
Auth, rate limiting, TLS termination |
Logique métier légère de présentation |
Générique (Kong, nginx, AWS API GW) |
Spécifique à un client, souvent custom |
Peu ou pas de logique métier |
Peut contenir de la logique d’agrégation |
En pratique, les deux coexistent : un API Gateway devant des BFF par canal.
Internet
└── API Gateway (TLS, auth, rate limit, routing)
├── BFF Mobile
├── BFF Web
└── BFF Partners
Démonstrations exécutables#
Démo 1 — Saga choreography : commande avec compensations#
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import time
from dataclasses import dataclass, field
from typing import List, Optional, Callable
from enum import Enum, auto
class SagaStepStatus(Enum):
PENDING = "En attente"
SUCCESS = "Succès"
FAILED = "Échoué"
COMPENSATED = "Compensé"
@dataclass
class SagaStep:
name: str
action: Callable
compensation: Callable
status: SagaStepStatus = SagaStepStatus.PENDING
error: Optional[str] = None
class Saga:
def __init__(self, name: str):
self.name = name
self._steps: List[SagaStep] = []
self._executed: List[SagaStep] = []
self._log: List[str] = []
def add_step(self, name: str, action: Callable, compensation: Callable) -> "Saga":
self._steps.append(SagaStep(name=name, action=action, compensation=compensation))
return self
def execute(self) -> bool:
print(f"\n=== Saga '{self.name}' ===")
for step in self._steps:
print(f" → Exécution : {step.name}")
try:
step.action()
step.status = SagaStepStatus.SUCCESS
self._executed.append(step)
self._log.append(f"OK: {step.name}")
print(f" ✓ {step.name} réussi")
except Exception as e:
step.status = SagaStepStatus.FAILED
step.error = str(e)
self._log.append(f"FAIL: {step.name} — {e}")
print(f" ✗ {step.name} ÉCHOUÉ: {e}")
self._compensate()
return False
print(f" ✓ Saga '{self.name}' complétée avec succès")
return True
def _compensate(self) -> None:
print(f"\n ⟳ Compensation en cours (rollback) ...")
for step in reversed(self._executed):
print(f" ← Compensation : {step.name}")
try:
step.compensation()
step.status = SagaStepStatus.COMPENSATED
self._log.append(f"COMPENSATED: {step.name}")
print(f" ✓ {step.name} compensé")
except Exception as e:
self._log.append(f"COMPENSATION_FAIL: {step.name} — {e}")
print(f" ✗ Compensation {step.name} ÉCHOUÉE: {e}")
@property
def steps_summary(self) -> List[dict]:
return [{"name": s.name, "status": s.status.value} for s in self._steps]
# Simulateurs de services
class InventoryService:
def __init__(self, fail: bool = False):
self._reserved = False
self._fail = fail
def reserve(self):
if self._fail:
raise RuntimeError("Stock insuffisant")
self._reserved = True
print(f" [STOCK] Réservation: 2x Livre Python")
def release(self):
self._reserved = False
print(f" [STOCK] Libération: 2x Livre Python")
class PaymentService:
def __init__(self, fail: bool = False):
self._charged = False
self._fail = fail
def charge(self):
if self._fail:
raise RuntimeError("Paiement refusé — fonds insuffisants")
self._charged = True
print(f" [PAYMENT] Débit: 70.00€")
def refund(self):
self._charged = False
print(f" [PAYMENT] Remboursement: 70.00€")
class ShippingService:
def __init__(self, fail: bool = False):
self._created = False
self._fail = fail
def create_shipment(self):
if self._fail:
raise RuntimeError("Aucun transporteur disponible")
self._created = True
print(f" [SHIPPING] Colis créé: EXP-2024-001")
def cancel_shipment(self):
self._created = False
print(f" [SHIPPING] Colis annulé: EXP-2024-001")
# Scénario 1 : tout réussit
print("=" * 55)
print("SCÉNARIO 1 : Saga qui réussit complètement")
print("=" * 55)
inv1 = InventoryService()
pay1 = PaymentService()
ship1 = ShippingService()
saga1 = Saga("PasserCommande")
saga1.add_step("Réserver stock", inv1.reserve, inv1.release)
saga1.add_step("Débiter paiement", pay1.charge, pay1.refund)
saga1.add_step("Créer expédition", ship1.create_shipment, ship1.cancel_shipment)
result1 = saga1.execute()
steps1 = saga1.steps_summary
# Scénario 2 : paiement échoue
print("\n" + "=" * 55)
print("SCÉNARIO 2 : Échec au paiement (compensation déclenchée)")
print("=" * 55)
inv2 = InventoryService()
pay2 = PaymentService(fail=True)
ship2 = ShippingService()
saga2 = Saga("PasserCommande")
saga2.add_step("Réserver stock", inv2.reserve, inv2.release)
saga2.add_step("Débiter paiement", pay2.charge, pay2.refund)
saga2.add_step("Créer expédition", ship2.create_shipment, ship2.cancel_shipment)
result2 = saga2.execute()
steps2 = saga2.steps_summary
# Visualisation des deux scénarios
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.0)
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
status_colors = {
"Succès": "#55A868",
"Échoué": "#C44E52",
"Compensé": "#DD8452",
"En attente": "#A0A0A0",
}
for ax, steps, title, success in [
(axes[0], steps1, "Scénario 1 : saga réussie", True),
(axes[1], steps2, "Scénario 2 : échec + compensation", False),
]:
names = [s["name"] for s in steps]
statuses = [s["status"] for s in steps]
colors = [status_colors.get(s, "#A0A0A0") for s in statuses]
y_pos = range(len(names))
ax.barh(y_pos, [1] * len(names), color=colors, height=0.55)
ax.set_yticks(y_pos)
ax.set_yticklabels(names, fontsize=10)
ax.set_xticks([])
ax.set_title(title, fontsize=11, fontweight="bold",
color="#55A868" if success else "#C44E52")
for i, (stat, col) in enumerate(zip(statuses, colors)):
ax.text(0.5, i, stat, ha="center", va="center",
fontsize=9.5, fontweight="bold", color="white")
legend_elements = [mpatches.Patch(facecolor=c, label=l) for l, c in status_colors.items()]
fig.legend(handles=legend_elements, loc="lower center", ncol=4, fontsize=9, framealpha=0.9)
fig.suptitle("Pattern Saga — état des étapes après exécution", fontsize=13,
fontweight="bold", color="#2C3E50", y=1.02)
plt.savefig("saga_pattern.png", dpi=120, bbox_inches="tight")
plt.show()
=======================================================
SCÉNARIO 1 : Saga qui réussit complètement
=======================================================
=== Saga 'PasserCommande' ===
→ Exécution : Réserver stock
[STOCK] Réservation: 2x Livre Python
✓ Réserver stock réussi
→ Exécution : Débiter paiement
[PAYMENT] Débit: 70.00€
✓ Débiter paiement réussi
→ Exécution : Créer expédition
[SHIPPING] Colis créé: EXP-2024-001
✓ Créer expédition réussi
✓ Saga 'PasserCommande' complétée avec succès
=======================================================
SCÉNARIO 2 : Échec au paiement (compensation déclenchée)
=======================================================
=== Saga 'PasserCommande' ===
→ Exécution : Réserver stock
[STOCK] Réservation: 2x Livre Python
✓ Réserver stock réussi
→ Exécution : Débiter paiement
✗ Débiter paiement ÉCHOUÉ: Paiement refusé — fonds insuffisants
⟳ Compensation en cours (rollback) ...
← Compensation : Réserver stock
[STOCK] Libération: 2x Livre Python
✓ Réserver stock compensé
Démo 2 — Bulkhead : deux pools de ressources isolés sous charge#
import threading
import time
import random
import matplotlib.pyplot as plt
import seaborn as sns
from typing import List, Dict, Tuple
class BulkheadPool:
def __init__(self, name: str, max_concurrent: int, timeout: float = 2.0):
self.name = name
self._semaphore = threading.Semaphore(max_concurrent)
self._max = max_concurrent
self._timeout = timeout
self._lock = threading.Lock()
self._current = 0
self._rejected = 0
self._completed = 0
self._timeline: List[Tuple[float, int, int]] = [] # (t, current, rejected)
self._start_time = time.time()
def execute(self, work_fn, *args, **kwargs):
acquired = self._semaphore.acquire(timeout=self._timeout)
if not acquired:
with self._lock:
self._rejected += 1
self._record()
raise RuntimeError(f"[{self.name}] Rejeté — pool saturé")
with self._lock:
self._current += 1
self._record()
try:
return work_fn(*args, **kwargs)
finally:
with self._lock:
self._current -= 1
self._completed += 1
self._semaphore.release()
self._record()
def _record(self):
with self._lock:
self._timeline.append((
time.time() - self._start_time,
self._current,
self._rejected,
))
@property
def stats(self) -> dict:
return {"name": self.name, "completed": self._completed, "rejected": self._rejected}
# Simulation : deux services, l'un lent (sature son pool), l'autre rapide
pool_slow = BulkheadPool("API-Lente", max_concurrent=3, timeout=0.1)
pool_fast = BulkheadPool("API-Rapide", max_concurrent=3, timeout=0.1)
errors_slow = []
errors_fast = []
def call_slow_api():
def work():
time.sleep(random.uniform(0.3, 0.6)) # API lente
return "OK"
try:
pool_slow.execute(work)
except RuntimeError:
errors_slow.append(1)
def call_fast_api():
def work():
time.sleep(random.uniform(0.01, 0.05)) # API rapide
return "OK"
try:
pool_fast.execute(work)
except RuntimeError:
errors_fast.append(1)
# Lancer 40 threads simultanément pour les deux services
threads = []
random.seed(42)
for _ in range(40):
threads.append(threading.Thread(target=call_slow_api))
threads.append(threading.Thread(target=call_fast_api))
for t in threads:
t.start()
for t in threads:
t.join()
print(f"API-Lente : {pool_slow.stats['completed']} complétées, {pool_slow.stats['rejected']} rejetées")
print(f"API-Rapide : {pool_fast.stats['completed']} complétées, {pool_fast.stats['rejected']} rejetées")
print(f"\nConclusion : la saturation de l'API-Lente n'a PAS impacté l'API-Rapide")
# Visualisation
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
for ax, pool, color, title in [
(axes[0], pool_slow, "#C44E52", "Pool API-Lente (max 3 concurrent)"),
(axes[1], pool_fast, "#55A868", "Pool API-Rapide (max 3 concurrent)"),
]:
if pool._timeline:
times = [t[0] for t in pool._timeline]
currents = [t[1] for t in pool._timeline]
rejecteds_cum = []
cum = 0
for t in pool._timeline:
cum = t[2]
rejecteds_cum.append(cum)
ax.plot(times, currents, color=color, linewidth=2, label="Connexions actives")
ax.axhline(pool._max, color="#555", linestyle="--", linewidth=1.2,
label=f"Limite ({pool._max})")
ax2 = ax.twinx()
ax2.plot(times, rejecteds_cum, color="#DD8452", linewidth=1.5,
linestyle=":", label="Total rejetés")
ax2.set_ylabel("Requêtes rejetées (cumul)", color="#DD8452")
ax2.tick_params(axis='y', labelcolor='#DD8452')
ax.set_title(title, fontsize=11, fontweight="bold")
ax.set_xlabel("Temps (s)")
ax.set_ylabel("Connexions actives")
ax.legend(loc="upper left", fontsize=8)
fig.suptitle("Pattern Bulkhead — isolation des pools de ressources\n"
"La saturation d'un pool n'affecte pas les autres",
fontsize=12, fontweight="bold", color="#2C3E50")
plt.savefig("bulkhead.png", dpi=120, bbox_inches="tight")
plt.show()
API-Lente : 3 complétées, 37 rejetées
API-Rapide : 16 complétées, 24 rejetées
Conclusion : la saturation de l'API-Lente n'a PAS impacté l'API-Rapide
Démo 3 — BFF vs API Gateway : diagramme comparatif#
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.0)
fig, axes = plt.subplots(1, 2, figsize=(14, 8))
fig.patch.set_facecolor("#F8F9FA")
# ── API Gateway ──────────────────────────────────────────────────
ax = axes[0]
ax.set_xlim(0, 8)
ax.set_ylim(0, 10)
ax.axis("off")
ax.set_facecolor("#F8F9FA")
def draw_box(ax, x, y, w, h, label, color, fontsize=9, sublabel=""):
box = mpatches.FancyBboxPatch((x, y), w, h,
boxstyle="round,pad=0.1", fc=color, ec="none", alpha=0.85)
ax.add_patch(box)
ax.text(x + w/2, y + h/2 + (0.15 if sublabel else 0), label,
ha="center", va="center", fontsize=fontsize,
fontweight="bold", color="white")
if sublabel:
ax.text(x + w/2, y + h/2 - 0.25, sublabel,
ha="center", va="center", fontsize=7.5, color="white", alpha=0.9)
def arrow(ax, x1, y1, x2, y2, color="#555"):
ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
arrowprops=dict(arrowstyle="->", color=color, lw=1.5))
# API Gateway diagram
clients_gw = [("Mobile", 0.3), ("Web", 2.7), ("Partenaires", 5.1)]
for label, x in clients_gw:
draw_box(ax, x, 8.0, 1.8, 0.9, label, "#4C72B0", fontsize=9)
arrow(ax, x + 0.9, 8.0, 3.3, 7.2, "#4C72B0")
draw_box(ax, 1.5, 6.0, 5.0, 1.0, "API Gateway", "#2C3E50",
fontsize=10, sublabel="TLS · Auth · Rate Limit · Routing")
services_gw = [("Orders\nService", 0.3), ("Users\nService", 2.7), ("Products\nService", 5.1)]
for label, x in services_gw:
draw_box(ax, x, 4.0, 1.8, 1.2, label, "#DD8452", fontsize=8.5)
arrow(ax, 4.0, 6.0, x + 0.9, 5.2, "#DD8452")
ax.set_title("Architecture API Gateway\n(point d'entrée universel)",
fontsize=11, fontweight="bold", color="#2C3E50", pad=10)
# ── BFF ───────────────────────────────────────────────────────────
ax2 = axes[1]
ax2.set_xlim(0, 8)
ax2.set_ylim(0, 10)
ax2.axis("off")
ax2.set_facecolor("#F8F9FA")
clients_bff = [("Mobile", 0.3, "#4C72B0"), ("Web", 2.7, "#55A868"), ("Partenaires", 5.1, "#C44E52")]
bff_labels = [
("BFF\nMobile", "#4C72B0", "payload compact"),
("BFF\nWeb", "#55A868", "données enrichies"),
("BFF\nPartners", "#C44E52", "API stable v2"),
]
for (c_label, cx, c_color), (b_label, b_color, b_sub) in zip(clients_bff, bff_labels):
draw_box(ax2, cx, 8.0, 1.8, 0.9, c_label, c_color, fontsize=9)
draw_box(ax2, cx, 5.8, 1.8, 1.4, b_label, b_color, fontsize=8.5, sublabel=b_sub)
arrow(ax2, cx + 0.9, 8.0, cx + 0.9, 7.2, c_color)
services_bff = [("Orders\nService", 0.3), ("Users\nService", 2.7), ("Products\nService", 5.1)]
for label, x in services_bff:
draw_box(ax2, x, 3.5, 1.8, 1.2, label, "#DD8452", fontsize=8.5)
for _, bx, _ in clients_bff:
for _, sx in services_bff:
arrow(ax2, bx + 0.9, 5.8, sx + 0.9, 4.7, "#999")
ax2.set_title("Architecture BFF\n(API par type de client)",
fontsize=11, fontweight="bold", color="#2C3E50", pad=10)
fig.suptitle("API Gateway vs Backend for Frontend (BFF)", fontsize=14,
fontweight="bold", color="#2C3E50", y=1.01)
plt.savefig("bff_vs_gateway.png", dpi=120, bbox_inches="tight")
plt.show()
Démo 4 — Comparaison des patterns de résilience#
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import numpy as np
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.0)
patterns = ["Circuit\nBreaker", "Bulkhead", "Retry", "Timeout", "Saga"]
metrics = ["Protection\ncascade", "Isolation\nressources", "Récupération\nauto", "Complexité\nimplémentation", "Coût\nopérationnel"]
scores = np.array([
[9, 4, 7, 5, 4], # Circuit Breaker
[6, 9, 2, 4, 3], # Bulkhead
[5, 2, 8, 2, 1], # Retry
[4, 3, 6, 1, 1], # Timeout
[7, 5, 5, 9, 8], # Saga
])
n = len(metrics)
angles = np.linspace(0, 2 * np.pi, n, endpoint=False).tolist()
angles += angles[:1]
colors = sns.color_palette("muted", len(patterns))
fig, ax = plt.subplots(figsize=(10, 9), subplot_kw=dict(polar=True))
ax.set_facecolor("#F8F9FA")
for i, (pattern, score) in enumerate(zip(patterns, scores)):
vals = score.tolist() + [score[0]]
ax.plot(angles, vals, "o-", linewidth=2.0, color=colors[i],
label=pattern.replace("\n", " "), alpha=0.9)
ax.fill(angles, vals, alpha=0.07, color=colors[i])
ax.set_xticks(angles[:-1])
ax.set_xticklabels(metrics, fontsize=10, fontweight="bold")
ax.set_ylim(0, 10)
ax.set_yticks([2, 4, 6, 8, 10])
ax.set_yticklabels(["2", "4", "6", "8", "10"], fontsize=8)
ax.grid(True, alpha=0.4)
ax.legend(loc="upper right", bbox_to_anchor=(1.4, 1.18), fontsize=9.5, framealpha=0.9)
ax.set_title("Comparaison des patterns de résilience\n"
"Circuit Breaker / Bulkhead / Retry / Timeout / Saga",
fontsize=12, fontweight="bold", pad=22, color="#2C3E50")
plt.savefig("resilience_patterns.png", dpi=120, bbox_inches="tight")
plt.show()
# Tableau récapitulatif
print("\n=== Tableau de sélection ===")
print(f"{'Pattern':<20} {'Problème résolu':<45} {'Quand adopter'}")
print("-" * 95)
guide = [
("Circuit Breaker", "Pannes en cascade sur appels externes", "Dès qu'il y a un service externe"),
("Bulkhead", "Un service lent bloque les autres", "Pools de connexions partagés"),
("Retry", "Erreurs transitoires réseau", "Presque toujours, avec backoff"),
("Timeout", "Appels bloquants sans limite", "Toujours — sans exception"),
("Saga", "Transactions multi-services sans 2PC", "Opérations distribuées critiques"),
]
for pattern, problem, when in guide:
print(f"{pattern:<20} {problem:<45} {when}")
=== Tableau de sélection ===
Pattern Problème résolu Quand adopter
-----------------------------------------------------------------------------------------------
Circuit Breaker Pannes en cascade sur appels externes Dès qu'il y a un service externe
Bulkhead Un service lent bloque les autres Pools de connexions partagés
Retry Erreurs transitoires réseau Presque toujours, avec backoff
Timeout Appels bloquants sans limite Toujours — sans exception
Saga Transactions multi-services sans 2PC Opérations distribuées critiques
Résumé#
Ce chapitre a couvert les patterns qui opèrent aux frontières du système — entre services, entre domaines, entre anciens et nouveaux systèmes.
Saga remplace les transactions distribuées impossibles par une séquence de transactions locales compensables. Le choix choreography/orchestration dépend de la complexité et du besoin d’observabilité.
Bulkhead isole les ressources pour qu’une défaillance partielle reste partielle. Il complète le Circuit Breaker : l’un coupe les circuits défaillants, l’autre empêche les ressources saines d’être monopolisées.
Sidecar et Ambassador délocalisent les préoccupations réseau (TLS, retry, tracing) hors du code applicatif. Ils sont fondamentaux dans un service mesh.
Anti-Corruption Layer protège le modèle de domaine des concepts externes. Sans lui, chaque intégration laisse des traces dans le code métier.
Strangler Fig est la stratégie de migration la moins risquée : jamais de big bang, toujours un repli possible vers le legacy via la façade.
BFF reconnaît que des clients différents ont des besoins fondamentalement différents. Une API universelle est un compromis qui satisfait mal chacun.
API Gateway est le point d’entrée unique, générique : TLS, auth, rate limiting. Il ne connaît pas les spécificités des clients — c’est le rôle des BFF.
À retenir
Retry, Timeout, Circuit Breaker et Bulkhead sont complémentaires et doivent être utilisés ensemble. Le Timeout empêche les blocages indéfinis. Le Retry gère les erreurs transitoires. Le Circuit Breaker évite les cascades. Le Bulkhead isole les partitions de ressources. Seule leur combinaison produit un système réellement résilient.
Piège courant
Le Saga Pattern est souvent décrit comme « simple ». En pratique, les compensations peuvent échouer (idempotence indispensable), l’ordre des compensations peut avoir de l’importance, et les états intermédiaires peuvent être observés par d’autres parties du système. Préférer l’orchestration à la choreography dès que la saga comporte plus de trois étapes ou des branches conditionnelles.
Bonne pratique
Avant d’adopter BFF, API Gateway, ou Strangler Fig, vérifier qu’il existe un problème réel à résoudre. Ces patterns introduisent de la complexité opérationnelle (un service de plus à déployer, surveiller, mettre à jour). La décision doit être justifiée par des contraintes concrètes : audiences différentes, niveaux de stabilité différents, équipes distinctes.