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é
_images/d14051c5e748c07862be871a1a8872f37d903d90d7093e04f731df6e089ee2ab.png

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
_images/8c7b1fea4172c5eb195d09c0f7ae7e0fd663b11c582e769294d7ae2c79e9799c.png

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()
_images/2278807aced5dc6fed9751a00c6fa79aec028f6c6a85cf98cf2d80e4f74bfda6.png

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}")
_images/e3a92f4dea8bbdcad49d84a69a4334108cef70a6fddc50f42f9581489aa54d8d.png
=== 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.