---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# 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.

```python
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)
```

```python
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

```python
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

```python
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

```{code-cell} python3
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()
```

### Démo 2 — Bulkhead : deux pools de ressources isolés sous charge

```{code-cell} python3
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()
```

### Démo 3 — BFF vs API Gateway : diagramme comparatif

```{code-cell} python3
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

```{code-cell} python3
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}")
```

---

## 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.

```{admonition} À retenir
:class: important

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.
```

```{admonition} Piège courant
:class: warning

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.
```

```{admonition} Bonne pratique
:class: tip

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.
```
