10 — Patterns de création et structurels appliqués#

Les design patterns sont souvent enseignés comme une liste à mémoriser. Cette approche manque l’essentiel : chaque pattern répond à une force architecturale précise — un type de couplage, une source de rigidité, un risque d’explosion combinatoire. Ce chapitre se concentre sur six patterns qui ont un impact architectural réel et quotidien, en les ancrant dans des exemples de production.

Builder — construction d’objets complexes#

Intention#

Le Builder résout le problème du constructeur télescopique : quand un objet nécessite de nombreux paramètres optionnels, les combinaisons d’arguments deviennent ingérables. Il sépare la construction d’un objet complexe de sa représentation finale.

Diagramme UML simplifié#

Director ──────────► Builder (interface)
                          │
                          ├── ConcreteBuilderA
                          └── ConcreteBuilderB
                                    │
                                    ▼
                                 Product

Quand l’utiliser#

  • Objets avec plus de 4 paramètres optionnels

  • Objets immuables dont la construction est complexe

  • Configuration fluente lisible (builder.with_timeout(30).with_retries(3).build())

  • Quand l’ordre de construction a de l’importance

Implémentation Python : QueryBuilder#

from dataclasses import dataclass, field
from typing import List, Optional, Tuple

@dataclass
class Query:
    table: str
    columns: List[str]
    conditions: List[str]
    order_by: Optional[str]
    limit: Optional[int]
    offset: Optional[int]
    joins: List[str]

    def to_sql(self) -> str:
        cols = ", ".join(self.columns) if self.columns else "*"
        sql = f"SELECT {cols} FROM {self.table}"
        for join in self.joins:
            sql += f" {join}"
        if self.conditions:
            sql += " WHERE " + " AND ".join(self.conditions)
        if self.order_by:
            sql += f" ORDER BY {self.order_by}"
        if self.limit:
            sql += f" LIMIT {self.limit}"
        if self.offset:
            sql += f" OFFSET {self.offset}"
        return sql


class QueryBuilder:
    def __init__(self, table: str):
        self._table = table
        self._columns: List[str] = []
        self._conditions: List[str] = []
        self._order_by: Optional[str] = None
        self._limit: Optional[int] = None
        self._offset: Optional[int] = None
        self._joins: List[str] = []

    def select(self, *columns: str) -> "QueryBuilder":
        self._columns.extend(columns)
        return self

    def where(self, condition: str) -> "QueryBuilder":
        self._conditions.append(condition)
        return self

    def order_by(self, column: str, direction: str = "ASC") -> "QueryBuilder":
        self._order_by = f"{column} {direction}"
        return self

    def limit(self, n: int) -> "QueryBuilder":
        self._limit = n
        return self

    def offset(self, n: int) -> "QueryBuilder":
        self._offset = n
        return self

    def join(self, table: str, on: str, kind: str = "INNER") -> "QueryBuilder":
        self._joins.append(f"{kind} JOIN {table} ON {on}")
        return self

    def build(self) -> Query:
        return Query(
            table=self._table,
            columns=self._columns,
            conditions=self._conditions,
            order_by=self._order_by,
            limit=self._limit,
            offset=self._offset,
            joins=self._joins,
        )

Usage :

query = (
    QueryBuilder("orders")
    .select("orders.id", "orders.total", "users.email")
    .join("users", "orders.user_id = users.id")
    .where("orders.status = 'confirmed'")
    .where("orders.total > 100")
    .order_by("orders.created_at", "DESC")
    .limit(20)
    .offset(40)
    .build()
)

print(query.to_sql())

Quand l’éviter#

Ne pas utiliser le Builder pour des objets simples (moins de 4 paramètres) — c’est de la sur-ingénierie. Préférer dataclass avec valeurs par défaut ou TypedDict pour des configurations simples.


Factory Method — extensibilité sans modification#

Intention#

Le Factory Method permet de déléguer l’instanciation à des sous-classes. Le code client travaille avec une interface abstraite et n’a pas besoin de connaître la classe concrète. C’est la mise en œuvre directe du principe ouvert/fermé : on étend le comportement en ajoutant de nouvelles classes, pas en modifiant l’existant.

Différence avec Abstract Factory#

Factory Method

Abstract Factory

Une méthode de création

Une famille de méthodes de création

Héritage

Composition

Crée un type de produit

Crée des familles de produits cohérents

Ex : create_notification()

Ex : create_button(), create_dialog() (UI cohérente par OS)

Implémentation Python : NotificationFactory#

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Dict, Any

@dataclass
class Notification:
    recipient: str
    subject: str
    body: str
    channel: str

    def __str__(self):
        return f"[{self.channel.upper()}] → {self.recipient}: {self.subject}"


class NotificationSender(ABC):
    @abstractmethod
    def send(self, notification: Notification) -> bool:
        ...

    def create_notification(self, recipient: str, subject: str, body: str) -> Notification:
        # Factory Method — chaque sous-classe fixe le channel
        return Notification(
            recipient=recipient,
            subject=subject,
            body=body,
            channel=self.channel_name(),
        )

    @abstractmethod
    def channel_name(self) -> str:
        ...


class EmailSender(NotificationSender):
    def channel_name(self) -> str:
        return "email"

    def send(self, notification: Notification) -> bool:
        print(f"  Envoi email à {notification.recipient}: {notification.subject}")
        return True


class SmsSender(NotificationSender):
    def channel_name(self) -> str:
        return "sms"

    def send(self, notification: Notification) -> bool:
        # SMS : corps tronqué à 160 caractères
        body = notification.body[:160]
        print(f"  Envoi SMS à {notification.recipient}: {body}")
        return True


class PushSender(NotificationSender):
    def channel_name(self) -> str:
        return "push"

    def send(self, notification: Notification) -> bool:
        print(f"  Envoi push à {notification.recipient}: {notification.subject}")
        return True


class NotificationFactory:
    _registry: Dict[str, type] = {
        "email": EmailSender,
        "sms": SmsSender,
        "push": PushSender,
    }

    @classmethod
    def register(cls, channel: str, sender_class: type) -> None:
        """Extension sans modification — on enregistre un nouveau channel."""
        cls._registry[channel] = sender_class

    @classmethod
    def create(cls, channel: str) -> NotificationSender:
        sender_class = cls._registry.get(channel)
        if not sender_class:
            raise ValueError(f"Canal inconnu : {channel}")
        return sender_class()

Le point clé : NotificationFactory.register() permet d’ajouter un nouveau canal (Slack, webhook) sans toucher au code existant.


Decorator — ajout de comportement sans héritage#

Intention#

Le Decorator enveloppe un objet pour lui ajouter du comportement dynamiquement, sans modifier sa classe ni créer une explosion d’héritages. C’est la base des pipelines de middleware.

Diagramme UML simplifié#

Component (interface)
    ├── ConcreteComponent
    └── BaseDecorator ──► wraps ──► Component
            ├── LoggingDecorator
            ├── CachingDecorator
            └── RetryDecorator

Implémentation Python : pipeline de middleware sur un service#

import time
import functools
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Callable

class UserService(ABC):
    @abstractmethod
    def get_user(self, user_id: int) -> Dict[str, Any]:
        ...

    @abstractmethod
    def update_user(self, user_id: int, data: Dict) -> bool:
        ...


class UserServiceImpl(UserService):
    def get_user(self, user_id: int) -> Dict[str, Any]:
        # Simule une base de données
        return {"id": user_id, "name": f"User_{user_id}", "email": f"user{user_id}@example.com"}

    def update_user(self, user_id: int, data: Dict) -> bool:
        print(f"  [DB] Mise à jour user {user_id}: {data}")
        return True


class ServiceDecorator(UserService):
    def __init__(self, wrapped: UserService):
        self._wrapped = wrapped

    def get_user(self, user_id: int) -> Dict[str, Any]:
        return self._wrapped.get_user(user_id)

    def update_user(self, user_id: int, data: Dict) -> bool:
        return self._wrapped.update_user(user_id, data)


class LoggingDecorator(ServiceDecorator):
    def get_user(self, user_id: int) -> Dict[str, Any]:
        print(f"[LOG] get_user({user_id})")
        result = self._wrapped.get_user(user_id)
        print(f"[LOG] get_user({user_id}) → {result}")
        return result

    def update_user(self, user_id: int, data: Dict) -> bool:
        print(f"[LOG] update_user({user_id}, {data})")
        result = self._wrapped.update_user(user_id, data)
        print(f"[LOG] update_user({user_id}) → {result}")
        return result


class CachingDecorator(ServiceDecorator):
    def __init__(self, wrapped: UserService):
        super().__init__(wrapped)
        self._cache: Dict[int, Dict] = {}

    def get_user(self, user_id: int) -> Dict[str, Any]:
        if user_id in self._cache:
            print(f"[CACHE] HIT pour user {user_id}")
            return self._cache[user_id]
        result = self._wrapped.get_user(user_id)
        self._cache[user_id] = result
        print(f"[CACHE] MISS pour user {user_id} — mis en cache")
        return result

    def update_user(self, user_id: int, data: Dict) -> bool:
        # Invalide le cache à l'écriture
        self._cache.pop(user_id, None)
        return self._wrapped.update_user(user_id, data)


class RetryDecorator(ServiceDecorator):
    def __init__(self, wrapped: UserService, max_retries: int = 3):
        super().__init__(wrapped)
        self._max_retries = max_retries

    def _with_retry(self, fn: Callable, *args, **kwargs) -> Any:
        last_error = None
        for attempt in range(1, self._max_retries + 1):
            try:
                return fn(*args, **kwargs)
            except Exception as e:
                last_error = e
                print(f"[RETRY] Tentative {attempt}/{self._max_retries} échouée : {e}")
        raise RuntimeError(f"Échec après {self._max_retries} tentatives") from last_error

    def get_user(self, user_id: int) -> Dict[str, Any]:
        return self._with_retry(self._wrapped.get_user, user_id)

    def update_user(self, user_id: int, data: Dict) -> bool:
        return self._with_retry(self._wrapped.update_user, user_id, data)

Composition du pipeline :

service = UserServiceImpl()
service = CachingDecorator(service)
service = LoggingDecorator(service)
service = RetryDecorator(service, max_retries=2)

L’ordre des décorateurs est crucial : ici, les logs enveloppent le cache, donc on logue même les hits cache. Inverser les deux changerait le comportement.

Quand l’éviter#

Éviter le Decorator quand la logique de composition devient trop complexe ou quand les décorateurs doivent se connaître entre eux — cela crée des couplages subtils. Dans ce cas, un pipeline explicite (liste de middlewares) est plus lisible.


Proxy — contrôle d’accès et lazy loading#

Intention#

Le Proxy s’interpose entre le client et l’objet réel pour contrôler l’accès. Contrairement au Decorator qui ajoute des fonctionnalités, le Proxy garde la même interface pour des raisons de contrôle : sécurité, performance, accès distant.

Types de Proxy#

  • Virtual Proxy : lazy loading, initialisation différée

  • Protection Proxy : contrôle d’accès et autorisation

  • Remote Proxy : représente un objet distant (RPC, gRPC stub)

  • Caching Proxy : met en cache les résultats coûteux

  • Logging/Monitoring Proxy : observabilité sans modifier l’objet

Différence Proxy vs Decorator#

Proxy

Decorator

Contrôle l”accès à l’objet

Ajoute des fonctionnalités

Souvent instancié par le framework

Composé par le code client

L’objet réel peut ne pas exister encore

L’objet réel existe toujours

Intention : protection/performance

Intention : enrichissement

Implémentation Python : CachingProxy et LazyProxy#

from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
import time

class DataService(ABC):
    @abstractmethod
    def fetch(self, key: str) -> Dict[str, Any]:
        ...


class SlowDataService(DataService):
    """Service réel qui simule une requête coûteuse."""
    def fetch(self, key: str) -> Dict[str, Any]:
        time.sleep(0.1)  # Simule latence réseau/DB
        return {"key": key, "value": f"data_for_{key}", "timestamp": time.time()}


class LazyProxy(DataService):
    """Instancie le service réel seulement à la première utilisation."""
    def __init__(self, service_factory):
        self._factory = service_factory
        self._service: Optional[DataService] = None

    def _get_service(self) -> DataService:
        if self._service is None:
            print("[LAZY] Initialisation du service réel...")
            self._service = self._factory()
        return self._service

    def fetch(self, key: str) -> Dict[str, Any]:
        return self._get_service().fetch(key)


class CachingProxy(DataService):
    """Met en cache les résultats avec TTL."""
    def __init__(self, service: DataService, ttl_seconds: float = 60.0):
        self._service = service
        self._ttl = ttl_seconds
        self._cache: Dict[str, tuple] = {}  # key → (value, expiry)

    def fetch(self, key: str) -> Dict[str, Any]:
        now = time.time()
        if key in self._cache:
            value, expiry = self._cache[key]
            if now < expiry:
                print(f"[CACHE] HIT {key}")
                return value
        result = self._service.fetch(key)
        self._cache[key] = (result, now + self._ttl)
        print(f"[CACHE] MISS {key} — mis en cache pour {self._ttl}s")
        return result

Composite — traitement uniforme des arbres#

Intention#

Le Composite permet de traiter uniformément des objets individuels et des compositions. Il modélise des structures arborescentes où chaque nœud peut être une feuille ou un groupe.

Cas d’usage#

  • Arbres de permissions (permission individuelle vs groupe de permissions)

  • Pipelines de validation (règle unique vs groupe de règles AND/OR)

  • Systèmes de fichiers (fichier vs répertoire)

  • UI components (widget vs container)

Implémentation : arbre de validations#

from abc import ABC, abstractmethod
from typing import List, Tuple

class Validator(ABC):
    @abstractmethod
    def validate(self, value: Any) -> Tuple[bool, str]:
        ...

class NotEmptyValidator(Validator):
    def validate(self, value: Any) -> Tuple[bool, str]:
        if not value:
            return False, "La valeur ne peut pas être vide"
        return True, ""

class MinLengthValidator(Validator):
    def __init__(self, min_len: int):
        self._min = min_len

    def validate(self, value: str) -> Tuple[bool, str]:
        if len(value) < self._min:
            return False, f"Longueur minimale : {self._min} caractères"
        return True, ""

class AndValidator(Validator):
    """Composite : toutes les règles doivent passer."""
    def __init__(self, *validators: Validator):
        self._validators = validators

    def validate(self, value: Any) -> Tuple[bool, str]:
        for v in self._validators:
            ok, msg = v.validate(value)
            if not ok:
                return False, msg
        return True, ""

class OrValidator(Validator):
    """Composite : au moins une règle doit passer."""
    def __init__(self, *validators: Validator):
        self._validators = validators

    def validate(self, value: Any) -> Tuple[bool, str]:
        errors = []
        for v in self._validators:
            ok, msg = v.validate(value)
            if ok:
                return True, ""
            errors.append(msg)
        return False, " ou ".join(errors)

Adapter — intégration de code legacy et APIs tierces#

Intention#

L’Adapter traduit l’interface d’une classe existante vers l’interface attendue par le client. C’est le pattern d’intégration par excellence : on ne modifie pas le code legacy ou l’API tierce, on crée une couche de traduction.

Implémentation : adapter pour une API de paiement externe#

from dataclasses import dataclass
from typing import Optional
from abc import ABC, abstractmethod

# Interface attendue par notre domaine
@dataclass
class PaymentResult:
    success: bool
    transaction_id: Optional[str]
    error_message: Optional[str]

class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount_cents: int, currency: str, token: str) -> PaymentResult:
        ...

    @abstractmethod
    def refund(self, transaction_id: str, amount_cents: int) -> PaymentResult:
        ...


# API tierce (Stripe-like) — interface que l'on ne contrôle pas
class StripeClient:
    def create_charge(self, amount: int, currency: str, source: str) -> dict:
        # Simule la réponse Stripe
        return {
            "id": f"ch_{hash(source) % 100000:05d}",
            "status": "succeeded",
            "amount": amount,
            "currency": currency,
        }

    def create_refund(self, charge_id: str, amount: int) -> dict:
        return {
            "id": f"re_{hash(charge_id) % 100000:05d}",
            "status": "succeeded",
            "charge": charge_id,
        }


# L'Adapter fait le pont
class StripeAdapter(PaymentGateway):
    def __init__(self, stripe_client: StripeClient):
        self._stripe = stripe_client

    def charge(self, amount_cents: int, currency: str, token: str) -> PaymentResult:
        try:
            response = self._stripe.create_charge(
                amount=amount_cents,
                currency=currency.lower(),
                source=token,
            )
            return PaymentResult(
                success=response["status"] == "succeeded",
                transaction_id=response.get("id"),
                error_message=None,
            )
        except Exception as e:
            return PaymentResult(success=False, transaction_id=None, error_message=str(e))

    def refund(self, transaction_id: str, amount_cents: int) -> PaymentResult:
        try:
            response = self._stripe.create_refund(
                charge_id=transaction_id,
                amount=amount_cents,
            )
            return PaymentResult(
                success=response["status"] == "succeeded",
                transaction_id=response.get("id"),
                error_message=None,
            )
        except Exception as e:
            return PaymentResult(success=False, transaction_id=None, error_message=str(e))

Demain, si l’on change de prestataire (Braintree, PayPal), on écrit un nouvel Adapter sans toucher au code métier.


Démonstrations exécutables#

Démo 1 — Builder : construction d’une requête SQL complexe#

from dataclasses import dataclass, field
from typing import List, Optional

@dataclass
class Query:
    table: str
    columns: List[str] = field(default_factory=list)
    conditions: List[str] = field(default_factory=list)
    order_by: Optional[str] = None
    limit: Optional[int] = None
    offset: Optional[int] = None
    joins: List[str] = field(default_factory=list)

    def to_sql(self) -> str:
        cols = ", ".join(self.columns) if self.columns else "*"
        sql = f"SELECT {cols}\nFROM {self.table}"
        for join in self.joins:
            sql += f"\n{join}"
        if self.conditions:
            sql += "\nWHERE " + "\n  AND ".join(self.conditions)
        if self.order_by:
            sql += f"\nORDER BY {self.order_by}"
        if self.limit:
            sql += f"\nLIMIT {self.limit}"
        if self.offset:
            sql += f"\nOFFSET {self.offset}"
        return sql


class QueryBuilder:
    def __init__(self, table: str):
        self._table = table
        self._columns: List[str] = []
        self._conditions: List[str] = []
        self._order_by: Optional[str] = None
        self._limit: Optional[int] = None
        self._offset: Optional[int] = None
        self._joins: List[str] = []

    def select(self, *columns: str) -> "QueryBuilder":
        self._columns.extend(columns)
        return self

    def where(self, condition: str) -> "QueryBuilder":
        self._conditions.append(condition)
        return self

    def order_by(self, column: str, direction: str = "ASC") -> "QueryBuilder":
        self._order_by = f"{column} {direction}"
        return self

    def limit(self, n: int) -> "QueryBuilder":
        self._limit = n
        return self

    def offset(self, n: int) -> "QueryBuilder":
        self._offset = n
        return self

    def join(self, table: str, on: str, kind: str = "INNER") -> "QueryBuilder":
        self._joins.append(f"{kind} JOIN {table} ON {on}")
        return self

    def build(self) -> Query:
        return Query(
            table=self._table,
            columns=list(self._columns),
            conditions=list(self._conditions),
            order_by=self._order_by,
            limit=self._limit,
            offset=self._offset,
            joins=list(self._joins),
        )


# Démo : requête complexe pour un tableau de bord e-commerce
query = (
    QueryBuilder("orders")
    .select("orders.id", "orders.total", "orders.created_at", "users.email", "products.name")
    .join("users", "orders.user_id = users.id")
    .join("order_items", "orders.id = order_items.order_id")
    .join("products", "order_items.product_id = products.id", kind="LEFT")
    .where("orders.status = 'confirmed'")
    .where("orders.total > 50")
    .where("orders.created_at > '2024-01-01'")
    .order_by("orders.created_at", "DESC")
    .limit(25)
    .offset(0)
    .build()
)

print("=== Requête générée par le Builder ===")
print(query.to_sql())
print()

# Comparer avec une requête simple
simple_query = QueryBuilder("users").select("id", "email").where("active = true").build()
print("=== Requête simple ===")
print(simple_query.to_sql())
=== Requête générée par le Builder ===
SELECT orders.id, orders.total, orders.created_at, users.email, products.name
FROM orders
INNER JOIN users ON orders.user_id = users.id
INNER JOIN order_items ON orders.id = order_items.order_id
LEFT JOIN products ON order_items.product_id = products.id
WHERE orders.status = 'confirmed'
  AND orders.total > 50
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 25

=== Requête simple ===
SELECT id, email
FROM users
WHERE active = true

Démo 2 — Decorator : pipeline middleware avec logging, cache et retry#

import time
from abc import ABC, abstractmethod
from typing import Any, Dict, Callable, Optional

class UserService(ABC):
    @abstractmethod
    def get_user(self, user_id: int) -> Dict[str, Any]:
        ...

class UserServiceImpl(UserService):
    _db = {
        1: {"id": 1, "name": "Alice Martin", "role": "admin"},
        2: {"id": 2, "name": "Bob Dupont", "role": "user"},
        3: {"id": 3, "name": "Claire Lefèvre", "role": "user"},
    }
    def get_user(self, user_id: int) -> Dict[str, Any]:
        if user_id not in self._db:
            raise ValueError(f"Utilisateur {user_id} introuvable")
        return dict(self._db[user_id])

class ServiceDecorator(UserService):
    def __init__(self, wrapped: UserService):
        self._wrapped = wrapped
    def get_user(self, user_id: int) -> Dict[str, Any]:
        return self._wrapped.get_user(user_id)

class LoggingDecorator(ServiceDecorator):
    def __init__(self, wrapped: UserService):
        super().__init__(wrapped)
        self._calls = []

    def get_user(self, user_id: int) -> Dict[str, Any]:
        start = time.perf_counter()
        try:
            result = self._wrapped.get_user(user_id)
            elapsed = (time.perf_counter() - start) * 1000
            entry = f"[LOG] get_user({user_id}) → OK ({elapsed:.2f}ms)"
            self._calls.append(entry)
            print(entry)
            return result
        except Exception as e:
            entry = f"[LOG] get_user({user_id}) → ERREUR: {e}"
            self._calls.append(entry)
            print(entry)
            raise

class CachingDecorator(ServiceDecorator):
    def __init__(self, wrapped: UserService):
        super().__init__(wrapped)
        self._cache: Dict[int, Any] = {}
        self.hits = 0
        self.misses = 0

    def get_user(self, user_id: int) -> Dict[str, Any]:
        if user_id in self._cache:
            self.hits += 1
            print(f"[CACHE] HIT user_id={user_id}")
            return dict(self._cache[user_id])
        result = self._wrapped.get_user(user_id)
        self._cache[user_id] = result
        self.misses += 1
        print(f"[CACHE] MISS user_id={user_id} — stocké")
        return dict(result)

class RetryDecorator(ServiceDecorator):
    def __init__(self, wrapped: UserService, max_retries: int = 2):
        super().__init__(wrapped)
        self._max = max_retries

    def get_user(self, user_id: int) -> Dict[str, Any]:
        last_err = None
        for attempt in range(1, self._max + 1):
            try:
                return self._wrapped.get_user(user_id)
            except ValueError:
                raise  # Pas la peine de réessayer une erreur logique
            except Exception as e:
                last_err = e
                print(f"[RETRY] Tentative {attempt}/{self._max}: {e}")
        raise RuntimeError("Échec après retries") from last_err


# Construction du pipeline
base_service = UserServiceImpl()
cached_service = CachingDecorator(base_service)
logged_service = LoggingDecorator(cached_service)
final_service = RetryDecorator(logged_service)

print("=== Premier appel (cache vide) ===")
user = final_service.get_user(1)
print(f"Résultat : {user}\n")

print("=== Deuxième appel (cache chaud) ===")
user = final_service.get_user(1)
print(f"Résultat : {user}\n")

print("=== Appel pour un autre utilisateur ===")
user = final_service.get_user(2)
print(f"Résultat : {user}\n")

print(f"=== Statistiques cache : {cached_service.hits} hits, {cached_service.misses} misses ===")
=== Premier appel (cache vide) ===
[CACHE] MISS user_id=1 — stocké
[LOG] get_user(1) → OK (0.04ms)
Résultat : {'id': 1, 'name': 'Alice Martin', 'role': 'admin'}

=== Deuxième appel (cache chaud) ===
[CACHE] HIT user_id=1
[LOG] get_user(1) → OK (0.03ms)
Résultat : {'id': 1, 'name': 'Alice Martin', 'role': 'admin'}

=== Appel pour un autre utilisateur ===
[CACHE] MISS user_id=2 — stocké
[LOG] get_user(2) → OK (0.05ms)
Résultat : {'id': 2, 'name': 'Bob Dupont', 'role': 'user'}

=== Statistiques cache : 1 hits, 2 misses ===

Démo 3 — Diagramme comparatif Proxy / Decorator / Adapter#

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.1)

fig, ax = plt.subplots(figsize=(13, 6))
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis("off")

patterns = ["Proxy", "Decorator", "Adapter"]
colors = ["#4C72B0", "#DD8452", "#55A868"]

# En-têtes
headers = ["Pattern", "Intention", "Interface\nchangée ?", "Crée\nl'objet ?", "Cas d'usage typique"]
col_positions = [0.5, 2.3, 5.6, 6.8, 8.1]
col_widths = [1.6, 3.1, 1.0, 1.0, 1.7]

rows = [
    ["Proxy",     "Contrôler l'accès\nà un objet",           "Non",  "Parfois", "Cache, auth,\nlazy loading"],
    ["Decorator", "Ajouter du\ncomportement",                "Non",  "Non",     "Logging, retry,\ncompression"],
    ["Adapter",   "Traduire une\ninterface incompatible",    "Oui",  "Non",     "Intégration\nAPI tierce"],
]

# Fond de l'en-tête
header_bg = mpatches.FancyBboxPatch((0.1, 8.5), 9.8, 1.1,
    boxstyle="round,pad=0.05", fc="#2C3E50", ec="none")
ax.add_patch(header_bg)

for i, (header, x) in enumerate(zip(headers, col_positions)):
    ax.text(x, 9.1, header, ha="center", va="center",
            fontsize=9.5, fontweight="bold", color="white")

# Lignes de données
for row_idx, (row, color) in enumerate(zip(rows, colors)):
    y = 7.2 - row_idx * 2.2
    bg = mpatches.FancyBboxPatch((0.1, y - 0.85), 9.8, 1.8,
        boxstyle="round,pad=0.05", fc=color, ec="none", alpha=0.15)
    ax.add_patch(bg)

    # Cellule pattern (colorée)
    cell_bg = mpatches.FancyBboxPatch((0.1, y - 0.85), 1.6, 1.8,
        boxstyle="round,pad=0.05", fc=color, ec="none", alpha=0.6)
    ax.add_patch(cell_bg)

    for i, (cell, x) in enumerate(zip(row, col_positions)):
        fw = "bold" if i == 0 else "normal"
        fc = "white" if i == 0 else "#2C3E50"
        ax.text(x, y + 0.05, cell, ha="center", va="center",
                fontsize=9, fontweight=fw, color=fc)

ax.set_title("Proxy vs Decorator vs Adapter — comparaison structurelle",
             fontsize=13, fontweight="bold", pad=12, color="#2C3E50")
plt.savefig("proxy_decorator_adapter.png", dpi=120, bbox_inches="tight")
plt.show()
_images/ab4e76da5ba8a8b4699035e1a33baa61807b770a334b2479d9359c34958506d4.png

Démo 4 — Graphe des patterns structurels avec networkx#

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import networkx as nx

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.0)

G = nx.DiGraph()

patterns = ["Builder", "Factory\nMethod", "Decorator", "Proxy", "Composite", "Adapter"]
G.add_nodes_from(patterns)

edges = [
    ("Builder",       "Factory\nMethod",  "crée via"),
    ("Decorator",     "Proxy",            "similaire à\n(intention diff.)"),
    ("Composite",     "Decorator",        "structure\narborescente"),
    ("Adapter",       "Proxy",            "wrapping\ndifférent"),
]

for src, dst, label in edges:
    G.add_edge(src, dst, label=label)

pos = {
    "Builder":       (0, 2),
    "Factory\nMethod": (2, 3),
    "Decorator":     (4, 2),
    "Proxy":         (4, 0),
    "Composite":     (2, 1),
    "Adapter":       (0, 0),
}

node_colors = {
    "Builder":       "#4C72B0",
    "Factory\nMethod": "#4C72B0",
    "Decorator":     "#DD8452",
    "Proxy":         "#DD8452",
    "Composite":     "#55A868",
    "Adapter":       "#C44E52",
}

categories = {
    "Création":    (["Builder", "Factory\nMethod"], "#4C72B0"),
    "Structurels": (["Decorator", "Proxy", "Composite", "Adapter"], "#DD8452"),
}

fig, ax = plt.subplots(figsize=(11, 7))
ax.set_facecolor("#F8F9FA")

for node, (x, y) in pos.items():
    color = node_colors[node]
    circle = plt.Circle((x, y), 0.55, color=color, alpha=0.85, zorder=3)
    ax.add_patch(circle)
    ax.text(x, y, node, ha="center", va="center",
            fontsize=9, fontweight="bold", color="white", zorder=4)

for src, dst, label in edges:
    x1, y1 = pos[src]
    x2, y2 = pos[dst]
    dx, dy = x2 - x1, y2 - y1
    norm = (dx**2 + dy**2) ** 0.5
    x1s = x1 + 0.55 * dx / norm
    y1s = y1 + 0.55 * dy / norm
    x2e = x2 - 0.6 * dx / norm
    y2e = y2 - 0.6 * dy / norm
    ax.annotate("", xy=(x2e, y2e), xytext=(x1s, y1s),
                arrowprops=dict(arrowstyle="->", color="#555", lw=1.5))
    mx, my = (x1 + x2) / 2, (y1 + y2) / 2
    ax.text(mx, my + 0.12, label, ha="center", va="bottom",
            fontsize=7.5, color="#555", style="italic")

legend_elements = [
    mpatches.Patch(facecolor="#4C72B0", label="Patterns de création"),
    mpatches.Patch(facecolor="#DD8452", label="Patterns structurels (comportement)"),
    mpatches.Patch(facecolor="#55A868", label="Patterns structurels (arbre)"),
    mpatches.Patch(facecolor="#C44E52", label="Patterns d'intégration"),
]
ax.legend(handles=legend_elements, loc="lower right", fontsize=9)

ax.set_xlim(-1, 5.5)
ax.set_ylim(-1, 4.2)
ax.axis("off")
ax.set_title("Relations entre patterns de création et structurels", fontsize=13,
             fontweight="bold", pad=12, color="#2C3E50")
plt.savefig("patterns_graph.png", dpi=120, bbox_inches="tight")
plt.show()
_images/0c5710d6fc976e0b9f85cb3b925aeca694bf0a3192c089a86c1d3a93d67e5b8b.png

Résumé#

Les six patterns de ce chapitre couvrent deux grandes familles de préoccupations architecturales :

Patterns de création (Builder, Factory Method) — ils répondent à la question « comment instancier des objets sans créer de couplages rigides ? »

  • Le Builder élimine l’enfer du constructeur télescopique et rend la construction d’objets complexes lisible et testable.

  • Le Factory Method externalise la décision d’instanciation, rendant le code extensible sans modification.

Patterns structurels (Decorator, Proxy, Composite, Adapter) — ils répondent à la question « comment organiser les objets pour obtenir de nouvelles fonctionnalités sans héritages fragiles ? »

  • Le Decorator compose des comportements en pipeline ; c’est le fondement des systèmes de middleware.

  • Le Proxy contrôle l’accès à un objet (cache, auth, lazy loading) en restant transparent pour le client.

  • Le Composite modélise des structures arborescentes avec un traitement uniforme feuilles/nœuds.

  • L”Adapter isole notre code des interfaces tierces, rendant les migrations et intégrations chirurgicales.

La décision d’utiliser un pattern doit être guidée par un problème concret, pas par une volonté d’appliquer des patterns pour leur propre sake. Le signe qu’un pattern est justifié : il réduit la complexité globale du code, pas seulement la déplace.

À retenir

Proxy et Decorator partagent la même structure (un objet qui en enveloppe un autre avec la même interface), mais leur intention est radicalement différente. Le Proxy contrôle l’accès ; le Decorator enrichit le comportement. Cette distinction guide le choix du bon pattern dans une situation donnée.

Piège courant

L’abus du pattern Decorator crée des « matriochkas » impossibles à déboguer — un objet enveloppé dans 5 couches dont l’ordre importe. Quand la composition devient complexe, un pipeline explicite (liste ordonnée de handlers) est souvent plus lisible et testable.

Bonne pratique

Pour choisir entre Proxy, Decorator et Adapter : demandez-vous si l’interface change (Adapter), si l’accès est contrôlé (Proxy), ou si des comportements sont ajoutés (Decorator). La réponse doit être immédiate — si elle ne l’est pas, votre cas d’usage est peut-être trop ambigu pour justifier un pattern formel.