06 — Architecture hexagonale et Clean Architecture#
Le problème des couches classiques#
L’architecture en couches décrite au chapitre précédent représente un progrès réel par rapport au code non structuré. Mais elle souffre d’un défaut fondamental qui n’est pas toujours visible au premier regard : les couches hautes finissent par dépendre des couches basses, et en particulier du framework et de la base de données.
Le couplage au framework#
Dans une application Django ou Flask typique, le modèle de données est défini comme une classe héritant de django.db.models.Model ou de SQLAlchemy.Base. Cette décision paraît naturelle — c’est ainsi que les frameworks fonctionnent. Mais ses conséquences sont profondes :
Pour tester la règle « une commande ne peut être passée que si le client n’a pas de facture impayée », il faut une base de données, une migration, un serveur Django démarré.
Pour changer d’ORM (de SQLAlchemy à Tortoise ORM), il faut modifier les entités métier elles-mêmes.
Pour réutiliser la logique métier dans une tâche batch en ligne de commande, on se retrouve à instancier un contexte web complet.
# Architecture en couches classique — le problème
# domain/order.py — Entité métier polluée par l'ORM
from sqlalchemy import Column, String, Float, Enum
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Order(Base):
__tablename__ = "orders"
id = Column(String, primary_key=True)
customer_id = Column(String)
total = Column(Float)
status = Column(Enum("pending", "confirmed", "cancelled"))
def confirm(self): # Logique métier mélangée avec la persistence
if self.status != "pending":
raise ValueError("...")
self.status = "confirmed"
# → Pour tester confirm(), il faut une base SQLAlchemy configurée
La règle de dépendance violée#
Le problème structurel est que le domaine — le cœur du système, sa raison d’être — dépend de l’infrastructure. Si SQLAlchemy change d’API, si on migre de PostgreSQL à MongoDB, si on abandonne Django : le domaine est touché. C’est l’inversion des priorités.
Architecture hexagonale (Ports & Adapters)#
Alistair Cockburn a proposé en 2005 un pattern qu’il appelle « Ports & Adapters », popularisé sous le nom d”architecture hexagonale. L’idée centrale est radicale dans sa simplicité :
Le cœur de l’application (le domaine) doit pouvoir fonctionner sans framework, sans base de données, sans interface utilisateur.
La structure conceptuelle#
L’application est représentée comme un hexagone (choix graphique arbitraire — cela pourrait être un octogone). À l’intérieur : le domaine et les cas d’usage. À l’extérieur : le monde. Entre les deux : des ports (interfaces) et des adapters (implémentations).
Port primaire (driving port) : interface par laquelle le monde extérieur déclenche l’application. L’adapter primaire est un contrôleur HTTP, un job cron, un consumer de messages.
Port secondaire (driven port) : interface que l’application utilise pour piloter des ressources externes. L’adapter secondaire est un repository SQL, un client SMTP, une file RabbitMQ.
La clé : les ports (interfaces) sont définis dans le domaine. Les adapters (implémentations) vivent en dehors. Le domaine ne connaît que ses propres interfaces.
La règle de dépendance hexagonale#
Adapter primaire → Port primaire → Domaine → Port secondaire ← Adapter secondaire
(Contrôleur HTTP) (UseCase ABC) (logique) (Repository ABC) (SQLRepository)
Le sens des flèches : les dépendances pointent toujours vers le domaine. L’adapter secondaire implémente le port du domaine (inversion de dépendance), pas l’inverse.
Implémentation Python complète#
Prenons le cas d’usage « passer une commande » (e-commerce) pour illustrer l’implémentation complète.
Le domaine — pur Python, zéro dépendance externe#
# domain/order.py — Pure Python, aucun import de framework
from dataclasses import dataclass, field
from typing import List
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
@dataclass
class OrderItem:
product_id: str
quantity: int
unit_price: float
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
@dataclass
class Order:
id: str
customer_id: str
items: List[OrderItem] = field(default_factory=list)
status: OrderStatus = OrderStatus.PENDING
@property
def total(self) -> float:
return sum(item.subtotal for item in self.items)
def confirm(self) -> None:
if self.status != OrderStatus.PENDING:
raise ValueError(f"Commande déjà en état {self.status.value}")
self.status = OrderStatus.CONFIRMED
Les ports secondaires — interfaces définies dans le domaine#
# domain/ports.py — Interfaces (ports) définies dans le domaine
from abc import ABC, abstractmethod
from typing import Optional, List
from .order import Order
class OrderRepository(ABC):
"""Port secondaire : persistence des commandes."""
@abstractmethod
def save(self, order: Order) -> None: ...
@abstractmethod
def find_by_id(self, order_id: str) -> Optional[Order]: ...
class StockRepository(ABC):
"""Port secondaire : gestion du stock."""
@abstractmethod
def is_available(self, product_id: str, quantity: int) -> bool: ...
@abstractmethod
def reserve(self, product_id: str, quantity: int) -> None: ...
class NotificationService(ABC):
"""Port secondaire : notifications."""
@abstractmethod
def send_order_confirmation(self, order: Order) -> None: ...
Le port primaire — cas d’usage#
# application/place_order_use_case.py — Port primaire (use case)
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List
from domain.order import Order, OrderItem
from domain.ports import OrderRepository, StockRepository, NotificationService
@dataclass
class PlaceOrderCommand:
customer_id: str
items: List[dict] # [{"product_id": "P1", "quantity": 2, "unit_price": 29.90}]
class PlaceOrderUseCase(ABC):
"""Port primaire : interface du cas d'usage."""
@abstractmethod
def execute(self, command: PlaceOrderCommand) -> Order: ...
class PlaceOrderUseCaseImpl(PlaceOrderUseCase):
"""Implémentation du cas d'usage — orchestre le domaine."""
def __init__(self,
order_repo: OrderRepository,
stock_repo: StockRepository,
notifier: NotificationService):
self._order_repo = order_repo
self._stock_repo = stock_repo
self._notifier = notifier
def execute(self, command: PlaceOrderCommand) -> Order:
# 1. Créer l'entité domaine
items = [OrderItem(**item) for item in command.items]
order = Order(id=self._generate_id(), customer_id=command.customer_id, items=items)
# 2. Vérifier le stock (via le port secondaire)
for item in order.items:
if not self._stock_repo.is_available(item.product_id, item.quantity):
raise ValueError(f"Stock insuffisant pour {item.product_id}")
# 3. Réserver le stock et confirmer
for item in order.items:
self._stock_repo.reserve(item.product_id, item.quantity)
order.confirm()
# 4. Persister et notifier
self._order_repo.save(order)
self._notifier.send_order_confirmation(order)
return order
@staticmethod
def _generate_id() -> str:
import uuid
return str(uuid.uuid4())
Les adapters — implémentations concrètes en infrastructure#
# infrastructure/adapters/sql_order_repository.py — Adapter secondaire
from domain.ports import OrderRepository
from domain.order import Order
class SqlOrderRepository(OrderRepository):
"""Adapter secondaire : persistence SQL via SQLAlchemy."""
def __init__(self, session):
self._session = session
def save(self, order: Order) -> None:
record = OrderRecord( # Objet ORM séparé de l'entité domaine
id=order.id,
customer_id=order.customer_id,
total=order.total,
status=order.status.value
)
self._session.merge(record)
self._session.commit()
def find_by_id(self, order_id: str) -> Order:
record = self._session.query(OrderRecord).filter_by(id=order_id).first()
if not record:
return None
return self._map_to_domain(record)
def _map_to_domain(self, record) -> Order:
# Conversion ORM → entité domaine
...
# infrastructure/adapters/http_order_controller.py — Adapter primaire
from flask import Blueprint, request, jsonify
from application.place_order_use_case import PlaceOrderUseCaseImpl, PlaceOrderCommand
bp = Blueprint("orders", __name__)
@bp.route("/orders", methods=["POST"])
def place_order():
"""Adapter primaire : reçoit une requête HTTP et appelle le use case."""
data = request.get_json()
command = PlaceOrderCommand(
customer_id=data["customer_id"],
items=data["items"]
)
use_case = _build_use_case()
order = use_case.execute(command)
return jsonify({"order_id": order.id, "total": order.total}), 201
def _build_use_case() -> PlaceOrderUseCaseImpl:
# Assemblage des dépendances (ou via un conteneur IoC)
from infrastructure.adapters.sql_order_repository import SqlOrderRepository
from infrastructure.adapters.smtp_notification import SmtpNotificationService
from infrastructure.adapters.sql_stock_repository import SqlStockRepository
from infrastructure.database import get_session
session = get_session()
return PlaceOrderUseCaseImpl(
order_repo=SqlOrderRepository(session),
stock_repo=SqlStockRepository(session),
notifier=SmtpNotificationService()
)
Tests unitaires — aucune dépendance infrastructure#
# tests/unit/test_place_order.py
# Aucun import de Flask, SQLAlchemy, SMTP — le domaine est testable en isolation
import pytest
from unittest.mock import MagicMock
from domain.order import Order, OrderStatus
from application.place_order_use_case import PlaceOrderUseCaseImpl, PlaceOrderCommand
class InMemoryOrderRepository:
"""Fake adapter pour les tests — stockage mémoire."""
def __init__(self):
self.orders = {}
def save(self, order):
self.orders[order.id] = order
def find_by_id(self, order_id):
return self.orders.get(order_id)
class AlwaysAvailableStockRepository:
"""Fake adapter : le stock est toujours disponible."""
def is_available(self, product_id, quantity): return True
def reserve(self, product_id, quantity): pass
class SilentNotificationService:
"""Fake adapter : ne fait rien."""
def send_order_confirmation(self, order): pass
def test_place_order_confirme_la_commande():
use_case = PlaceOrderUseCaseImpl(
order_repo=InMemoryOrderRepository(),
stock_repo=AlwaysAvailableStockRepository(),
notifier=SilentNotificationService()
)
command = PlaceOrderCommand(
customer_id="C42",
items=[{"product_id": "P1", "quantity": 2, "unit_price": 29.90}]
)
order = use_case.execute(command)
assert order.status == OrderStatus.CONFIRMED
assert order.total == pytest.approx(59.80)
def test_place_order_erreur_si_stock_insuffisant():
class OutOfStockRepository:
def is_available(self, product_id, quantity): return False
def reserve(self, product_id, quantity): pass
use_case = PlaceOrderUseCaseImpl(
order_repo=InMemoryOrderRepository(),
stock_repo=OutOfStockRepository(),
notifier=SilentNotificationService()
)
with pytest.raises(ValueError, match="Stock insuffisant"):
use_case.execute(PlaceOrderCommand("C1", [{"product_id": "P1", "quantity": 1, "unit_price": 10}]))
Clean Architecture#
Robert C. Martin (Uncle Bob) a formalisé en 2012 une architecture qu’il appelle Clean Architecture, représentée sous forme de cercles concentriques. L’idée fondatrice est la même que celle de Cockburn, exprimée différemment :
« La règle de dépendance : les dépendances du code source ne peuvent pointer que vers l’intérieur. »
Les quatre cercles concentriques#
Cercle |
Contenu |
Dépendances |
|---|---|---|
Entities (cœur) |
Règles métier de l’entreprise, objets du domaine |
Aucune — indépendant de tout |
Use Cases |
Règles métier spécifiques à l’application |
Dépend des Entities uniquement |
Interface Adapters |
Contrôleurs, Presenters, Gateways |
Dépend des Use Cases et Entities |
Frameworks & Drivers |
Framework web, BDD, UI, périphériques |
Dépend des Interface Adapters |
La règle de dépendance est stricte : aucun code du cercle N ne peut référencer quoi que ce soit d’un cercle N+1 (cercle plus externe).
Le Dependency Inversion Principle (DIP) en pratique#
Pour que la règle de dépendance soit respectée, les cas d’usage doivent pouvoir appeler des services d’infrastructure (base de données) sans en dépendre. La solution est systématique : les interfaces sont définies dans le cercle intérieur, les implémentations dans le cercle extérieur.
# Clean Architecture — inversion de dépendance
# Cercle Use Cases définit l'interface :
class UserRepository(ABC): # Dans le cercle Use Cases
@abstractmethod
def find_by_email(self, email: str): ...
# Cercle Frameworks implémente :
class PostgresUserRepository(UserRepository): # Dans le cercle Frameworks
def find_by_email(self, email: str):
return self._session.query(UserModel).filter_by(email=email).first()
Différences entre hexagonale et Clean Architecture#
Les deux architectures partagent le même principe fondateur (la règle de dépendance, domaine au centre) et sont souvent présentées comme équivalentes. Leurs différences sont de nuance :
Aspect |
Hexagonale (Cockburn) |
Clean Architecture (Martin) |
|---|---|---|
Métaphore |
Hexagone + Ports/Adapters |
Cercles concentriques |
Vocabulaire |
Port primaire/secondaire, Adapter |
Use Case, Gateway, Presenter, Entity |
Présentation |
2 couches (intérieur/extérieur) |
4 cercles |
Focus |
Testabilité, isolation framework |
Règle de dépendance, maintenabilité |
Formalisme |
Moins formalisé |
Plus prescriptif |
En pratique, une implémentation « hexagonale » respecte la règle de dépendance de la Clean Architecture, et une implémentation « Clean » peut être vue comme une hexagonale avec plus de précision sur les cercles internes.
Les deux sont complémentaires
Beaucoup d’équipes se réfèrent aux deux en même temps, en adoptant la terminologie Ports/Adapters de Cockburn pour la structure des fichiers, et les cercles de Martin comme grille de lecture de la règle de dépendance.
Testabilité#
L’un des bénéfices les plus immédiats de l’architecture hexagonale est la testabilité. Le domaine étant isolé de toute dépendance externe, les tests unitaires sont :
Rapides : pas de base de données à initialiser, pas de serveur à démarrer.
Déterministes : pas de dépendance à l’état d’une BDD externe.
Exhaustifs : chaque règle métier peut être testée indépendamment.
Les adapters sont testés séparément avec des tests d’intégration ciblés : on teste que SqlOrderRepository écrit et lit correctement dans PostgreSQL, mais on ne mélange pas cela avec les tests métier.
Pyramide de tests dans l’hexagonale#
████ Tests E2E (quelques)
████████ Tests d'intégration (adapters)
████████████████ Tests unitaires du domaine (majorité)
Avantages, coûts et quand l’utiliser#
Avantages#
Testabilité maximale : le cœur est testable sans infrastructure.
Évolutivité : changer de BDD, de framework, de protocole de communication ne touche pas le domaine.
Clarté des intentions : les use cases expriment explicitement ce que fait l’application.
Parallélisme de développement : une équipe peut travailler sur les adapters pendant qu’une autre travaille sur le domaine.
Coûts#
Overhead de structure : plus de fichiers, plus d’interfaces à définir. Un projet de 3 développeurs pendant 2 semaines n’a pas besoin de cette rigueur.
Courbe d’apprentissage : le concept d’inversion de dépendance est contre-intuitif pour les développeurs habitués aux frameworks qui dictent leur structure.
Mapping object-objet : transformer une entité domaine en objet ORM et inversement est du code répétitif à maintenir.
Quand l’utiliser#
Critères d’adoption
L’architecture hexagonale est pertinente quand :
Le domaine métier est complexe et contient de la logique qui mérite d’être testée indépendamment.
L’application devra évoluer (changement de BDD, de framework, ajout d’une interface CLI…).
L’équipe est suffisamment grande pour que la séparation des responsabilités apporte plus qu’elle ne coûte (typiquement dès 3-4 développeurs sur un même domaine).
Elle est sur-ingénierie quand :
L’application est essentiellement un CRUD sans logique métier.
Le projet est un prototype ou une preuve de concept.
L’équipe est d’un seul développeur sur un projet de quelques semaines.
Visualisations#
Diagramme hexagonal#
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import RegularPolygon
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, ax = plt.subplots(figsize=(13, 11))
ax.set_xlim(-5.5, 5.5)
ax.set_ylim(-5.5, 5.5)
ax.set_aspect("equal")
# Hexagone central (domaine)
hex_inner = RegularPolygon((0, 0), numVertices=6, radius=1.8,
orientation=np.pi/6,
facecolor="#9b59b6", edgecolor="white",
linewidth=3, alpha=0.9, zorder=3)
ax.add_patch(hex_inner)
ax.text(0, 0.3, "Domaine", ha="center", va="center",
fontsize=13, fontweight="bold", color="white", zorder=4)
ax.text(0, -0.3, "Entités · Règles métier", ha="center", va="center",
fontsize=9, color="white", alpha=0.85, zorder=4)
# Anneau use cases
hex_mid = RegularPolygon((0, 0), numVertices=6, radius=2.7,
orientation=np.pi/6,
facecolor="#2ecc71", edgecolor="white",
linewidth=2, alpha=0.5, zorder=2)
ax.add_patch(hex_mid)
ax.text(0, 2.15, "Use Cases\n(Application)", ha="center", va="center",
fontsize=9, color="#1a6b3a", fontweight="bold", zorder=4)
# Adapters primaires (gauche)
adapters_primaires = [
{"pos": (-4.5, 1.5), "label": "Contrôleur\nHTTP/REST"},
{"pos": (-4.5, -1.5), "label": "CLI\nAdapter"},
{"pos": (-4.2, 0), "label": "Consumer\nMessages"},
]
for ap in adapters_primaires:
rect = mpatches.FancyBboxPatch(
(ap["pos"][0] - 0.85, ap["pos"][1] - 0.45), 1.7, 0.9,
boxstyle="round,pad=0.08",
facecolor="#3498db", edgecolor="white", linewidth=1.5, alpha=0.85, zorder=3
)
ax.add_patch(rect)
ax.text(ap["pos"][0], ap["pos"][1], ap["label"],
ha="center", va="center", fontsize=8, color="white", fontweight="bold", zorder=4)
# Flèche vers l'hexagone
ax.annotate("", xy=(-1.85, ap["pos"][1] * 0.5),
xytext=(ap["pos"][0] + 0.85, ap["pos"][1]),
arrowprops=dict(arrowstyle="-|>", color="#3498db", lw=1.8))
# Adapters secondaires (droite)
adapters_secondaires = [
{"pos": (4.5, 1.5), "label": "SQL\nRepository"},
{"pos": (4.5, -1.5), "label": "SMTP\nAdapter"},
{"pos": (4.2, 0), "label": "Cache\nRedis"},
]
for ap in adapters_secondaires:
rect = mpatches.FancyBboxPatch(
(ap["pos"][0] - 0.85, ap["pos"][1] - 0.45), 1.7, 0.9,
boxstyle="round,pad=0.08",
facecolor="#e67e22", edgecolor="white", linewidth=1.5, alpha=0.85, zorder=3
)
ax.add_patch(rect)
ax.text(ap["pos"][0], ap["pos"][1], ap["label"],
ha="center", va="center", fontsize=8, color="white", fontweight="bold", zorder=4)
# Flèche depuis l'hexagone (interface dans le domaine)
ax.annotate("", xy=(ap["pos"][0] - 0.85, ap["pos"][1]),
xytext=(1.85, ap["pos"][1] * 0.5),
arrowprops=dict(arrowstyle="-|>", color="#e67e22", lw=1.8))
# Légende
patch_primaire = mpatches.Patch(color="#3498db", label="Adapters primaires (driving)")
patch_domaine = mpatches.Patch(color="#9b59b6", label="Domaine + Use Cases")
patch_secondaire = mpatches.Patch(color="#e67e22", label="Adapters secondaires (driven)")
ax.legend(handles=[patch_primaire, patch_domaine, patch_secondaire],
loc="lower center", fontsize=10, ncol=3, bbox_to_anchor=(0.5, -0.08))
ax.text(-4.5, 2.7, "Ports primaires\n← Interface définie\n par le domaine",
ha="center", fontsize=8, color="#3498db", style="italic")
ax.text(4.5, 2.7, "Ports secondaires\n→ Interface définie\n par le domaine",
ha="center", fontsize=8, color="#e67e22", style="italic")
ax.set_title("Architecture hexagonale (Ports & Adapters) — Alistair Cockburn",
fontsize=13, fontweight="bold", pad=15)
ax.axis("off")
plt.savefig("_static/06_hexagonale.png", dpi=150, bbox_inches="tight")
plt.show()
Diagramme Clean Architecture — cercles concentriques#
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, ax = plt.subplots(figsize=(11, 11))
ax.set_xlim(-5.5, 5.5)
ax.set_ylim(-5.5, 5.5)
ax.set_aspect("equal")
circles = [
{"r": 5.0, "color": "#e74c3c", "alpha": 0.25,
"label": "Frameworks & Drivers", "sublabel": "Flask · Django · SQLAlchemy · React"},
{"r": 3.8, "color": "#e67e22", "alpha": 0.35,
"label": "Interface Adapters", "sublabel": "Contrôleurs · Presenters · Gateways"},
{"r": 2.5, "color": "#2ecc71", "alpha": 0.45,
"label": "Use Cases", "sublabel": "PlaceOrder · CancelOrder · GetReport"},
{"r": 1.4, "color": "#9b59b6", "alpha": 0.9,
"label": "Entities", "sublabel": "Order · Customer · Product"},
]
for c in circles:
circle = plt.Circle((0, 0), c["r"], color=c["color"],
alpha=c["alpha"], zorder=2)
ax.add_patch(circle)
circle_border = plt.Circle((0, 0), c["r"], fill=False,
edgecolor=c["color"], linewidth=2.5, zorder=3)
ax.add_patch(circle_border)
# Labels
ax.text(0, 0, "Entities", ha="center", va="center",
fontsize=12, fontweight="bold", color="white", zorder=5)
ax.text(0, -0.55, "Order · Customer\nProduct", ha="center", va="center",
fontsize=8, color="white", alpha=0.9, zorder=5)
ax.text(0, 1.95, "Use Cases", ha="center", va="center",
fontsize=11, fontweight="bold", color="#1a6b3a", zorder=5)
ax.text(0, 1.45, "PlaceOrder · CancelOrder", ha="center", va="center",
fontsize=8, color="#1a6b3a", zorder=5)
ax.text(0, 3.1, "Interface Adapters", ha="center", va="center",
fontsize=11, fontweight="bold", color="#7d4e00", zorder=5)
ax.text(0, 2.65, "Contrôleurs · Presenters · Gateways", ha="center", va="center",
fontsize=8, color="#7d4e00", zorder=5)
ax.text(0, 4.3, "Frameworks & Drivers", ha="center", va="center",
fontsize=11, fontweight="bold", color="#7b0000", zorder=5)
ax.text(0, 3.85, "Flask · SQLAlchemy · React · PostgreSQL", ha="center", va="center",
fontsize=8, color="#7b0000", zorder=5)
# Règle de dépendance — flèche vers l'intérieur
angle = np.deg2rad(-45)
r_start, r_end = 4.8, 0.2
ax.annotate("",
xy=(r_end * np.cos(angle), r_end * np.sin(angle)),
xytext=(r_start * np.cos(angle), r_start * np.sin(angle)),
arrowprops=dict(arrowstyle="-|>", color="#2c3e50", lw=2.5))
ax.text(3.8 * np.cos(angle) + 0.3, 3.8 * np.sin(angle) - 0.4,
"Règle de\ndépendance\n→ vers l'intérieur",
ha="center", fontsize=9, color="#2c3e50", style="italic")
ax.set_title("Clean Architecture — Robert C. Martin\nLa règle de dépendance",
fontsize=13, fontweight="bold", pad=15)
ax.axis("off")
plt.savefig("_static/06_clean_architecture.png", dpi=150, bbox_inches="tight")
plt.show()
Inversion de dépendance avant/après (networkx)#
import networkx as nx
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
# --- Avant : architecture classique (dépendances vers le bas) ---
G_avant = nx.DiGraph()
nodes_avant = {
"Contrôleur\nFlask": (1, 4),
"OrderService": (1, 3),
"Order\n(domaine)": (1, 2),
"SQLAlchemy\nModel": (1, 1),
"PostgreSQL": (1, 0),
}
G_avant.add_nodes_from(nodes_avant.keys())
edges_avant = [
("Contrôleur\nFlask", "OrderService"),
("OrderService", "Order\n(domaine)"),
("Order\n(domaine)", "SQLAlchemy\nModel"), # Domaine dépend de l'ORM !
("SQLAlchemy\nModel", "PostgreSQL"),
]
G_avant.add_edges_from(edges_avant)
colors_avant = ["#3498db", "#2ecc71", "#9b59b6", "#e74c3c", "#c0392b"]
edge_colors_avant = ["#2c3e50", "#2c3e50", "#e74c3c", "#2c3e50"]
nx.draw_networkx(G_avant, nodes_avant, ax=axes[0],
node_color=colors_avant, node_size=2200,
font_size=8, font_color="white", font_weight="bold",
edge_color=edge_colors_avant, width=[2, 2, 3, 2],
arrows=True, arrowsize=20)
axes[0].text(1.7, 1.5, "Dépendance\nindirecte au\nframework !", ha="center",
color="#e74c3c", fontsize=9, style="italic")
axes[0].set_title("Avant : architecture classique\n(domaine couplé à l'ORM)", fontsize=11)
axes[0].axis("off")
# --- Après : hexagonale (inversion de dépendance) ---
G_après = nx.DiGraph()
nodes_après = {
"Contrôleur\nFlask": (0, 4),
"OrderService\n(Use Case)": (1, 3),
"Order\n(domaine)": (1, 2),
"OrderRepository\n(Port/Interface)": (1, 1),
"SqlOrderRepo\n(Adapter)": (1, 0),
"PostgreSQL": (2, -0.5),
}
G_après.add_nodes_from(nodes_après.keys())
edges_après = [
("Contrôleur\nFlask", "OrderService\n(Use Case)"),
("OrderService\n(Use Case)", "Order\n(domaine)"),
("OrderService\n(Use Case)", "OrderRepository\n(Port/Interface)"),
("SqlOrderRepo\n(Adapter)", "OrderRepository\n(Port/Interface)"), # Implémente l'interface
("SqlOrderRepo\n(Adapter)", "PostgreSQL"),
]
G_après.add_edges_from(edges_après)
colors_après = ["#3498db", "#2ecc71", "#9b59b6", "#8e44ad", "#e67e22", "#c0392b"]
edge_styles = ["#2c3e50", "#2c3e50", "#2c3e50", "#2ecc71", "#2c3e50"]
nx.draw_networkx(G_après, nodes_après, ax=axes[1],
node_color=colors_après, node_size=2000,
font_size=8, font_color="white", font_weight="bold",
edge_color=edge_styles, width=[2, 2, 2, 3, 2],
arrows=True, arrowsize=20)
axes[1].text(1.8, 0.3, "SqlOrderRepo\nimplements Port\n(DIP)", ha="center",
color="#2ecc71", fontsize=9, style="italic")
axes[1].set_title("Après : hexagonale\n(domaine isolé via inversion)", fontsize=11)
axes[1].axis("off")
plt.suptitle("Inversion de dépendance — avant et après l'architecture hexagonale",
fontsize=13, fontweight="bold", y=1.01)
plt.savefig("_static/06_inversion_dependance.png", dpi=150, bbox_inches="tight")
plt.show()
Simulation testabilité — couverture atteignable selon l’architecture#
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
architectures = [
"Monolithe\nnon structuré",
"Couches\nclassiques",
"Hexagonale\n(sans clean)",
"Clean\nArchitecture",
]
# Couverture atteignable en tests UNITAIRES (sans infrastructure)
couverture_unitaires = [15, 35, 75, 85]
# Couverture totale (unitaires + intégration)
couverture_totale = [45, 65, 90, 95]
# Temps moyen pour lancer la suite de tests (secondes)
temps_tests = [120, 60, 8, 6]
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
x = np.arange(len(architectures))
width = 0.38
bars1 = axes[0].bar(x - width/2, couverture_unitaires, width,
label="Tests unitaires (sans infra)",
color="#9b59b6", alpha=0.85)
bars2 = axes[0].bar(x + width/2, couverture_totale, width,
label="Couverture totale",
color="#2ecc71", alpha=0.85)
for bar in bars1:
axes[0].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1,
f"{int(bar.get_height())}%", ha="center", va="bottom", fontsize=10)
for bar in bars2:
axes[0].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1,
f"{int(bar.get_height())}%", ha="center", va="bottom", fontsize=10)
axes[0].set_xticks(x)
axes[0].set_xticklabels(architectures, fontsize=9)
axes[0].set_ylabel("Couverture atteignable (%)", fontsize=11)
axes[0].set_ylim(0, 115)
axes[0].set_title("Couverture de tests selon l'architecture", fontsize=12)
axes[0].legend(fontsize=10)
axes[0].axhline(80, color="#e74c3c", linestyle="--", alpha=0.7, label="Seuil recommandé (80%)")
# Temps d'exécution des tests
colors_temps = ["#e74c3c", "#e67e22", "#2ecc71", "#27ae60"]
bars3 = axes[1].bar(x, temps_tests, color=colors_temps, alpha=0.85, width=0.5)
for bar, t in zip(bars3, temps_tests):
axes[1].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 1.5,
f"{t}s", ha="center", va="bottom", fontsize=11, fontweight="bold")
axes[1].set_xticks(x)
axes[1].set_xticklabels(architectures, fontsize=9)
axes[1].set_ylabel("Temps d'exécution (secondes)", fontsize=11)
axes[1].set_title("Temps d'exécution de la suite de tests", fontsize=12)
axes[1].set_ylim(0, 140)
plt.suptitle("Testabilité selon le style architectural", fontsize=14, fontweight="bold")
plt.savefig("_static/06_testabilite.png", dpi=150, bbox_inches="tight")
plt.show()
Résumé#
L’architecture hexagonale et la Clean Architecture partagent une même intuition fondamentale : le code qui exprime les règles métier ne doit jamais dépendre du code qui gère l’infrastructure. Cette inversion de la direction des dépendances — les interfaces définies dans le domaine, les implémentations en périphérie — est ce qu’on appelle le Dependency Inversion Principle appliqué à l’échelle architecturale.
Points clés :
L’architecture en couches classique souffre d’un couplage implicite au framework et à la BDD qui rend le domaine non testable en isolation.
L’architecture hexagonale résout ce problème en définissant des ports (interfaces) dans le domaine et en plaçant les adapters (implémentations) à l’extérieur.
La Clean Architecture de Robert Martin formalise le même principe sous forme de cercles concentriques avec une règle de dépendance stricte : les dépendances ne pointent que vers l’intérieur.
En pratique, l’implémentation implique : entités de domaine pure Python, ports (ABC), use cases (orchestration), adapters primaires (contrôleurs), adapters secondaires (repositories, clients).
Le bénéfice principal est la testabilité : le domaine et les use cases sont testables sans infrastructure, ce qui accélère dramatiquement le feedback loop de développement.
Le coût est un overhead de structure (plus de fichiers, mapping objet-objet) qui se justifie dès que la logique métier est non triviale et que l’équipe compte plusieurs développeurs.
Les deux architectures sont complémentaires et souvent utilisées ensemble : terminologie Ports/Adapters de Cockburn, règle de dépendance de Martin.
La règle d’or
Si vous ne pouvez pas tester votre logique métier sans démarrer une base de données ou un serveur HTTP, votre domaine est couplé à votre infrastructure. L’architecture hexagonale est la prescription pour ce diagnostic.