Architecture
Hexagonale
Ports & Adapters — isoler le cœur métier de toute technologie externe. Tester sans base de données, changer de framework sans toucher à la logique, construire du code qui dure.
C'est quoi l'architecture hexagonale ?
L'architecture hexagonale (aussi appelée Ports & Adapters) a été inventée par Alistair Cockburn en 2005. Son objectif : rendre l'application indépendante de tout détail technique (framework, BDD, interface utilisateur).
Ton téléphone (logique métier) ne sait pas d'où vient le courant. Il définit juste son port (prise USB-C). Différents adaptateurs s'y branchent : chargeur mural, voiture, PC, batterie externe.
Changer d'adaptateur ne change pas le téléphone.
Le cœur métier (domaine) ne dépend de rien — ni de Flask, ni de MySQL, ni de HTTP. Ce sont les couches externes qui s'adaptent à lui, jamais l'inverse. Si tu peux retirer Express et remplacer par Flask sans toucher à la logique métier → tu fais de l'hexagonale.
L'architecture hexagonale est à la base de l'Architecture Propre (Clean Architecture) de Robert C. Martin, et de l'Architecture en Oignon (Onion Architecture). Ce sont des variations du même principe.
Les 3 couches
Contient : entités, règles métier, exceptions métier, interfaces (ports).
Exemple : "Un article doit avoir un titre non vide" — cette règle ne dépend pas de Flask ou MySQL.
Contient : services applicatifs, orchestration des entités, validation entrante.
Exemple :
PublierArticleService — vérifie les droits, crée l'article, envoie la notif.
Adapters primaires (IN) : HTTP controller, CLI, tests.
Adapters secondaires (OUT) : repository MySQL, repository MongoDB, service email.
Infrastructure → Application → Domaine
Le Domaine ne dépend de rien.
| Couche | Connaît | Ne connaît PAS |
|---|---|---|
| Domaine | Lui-même uniquement | Flask, MySQL, HTTP, JSON |
| Application | Domaine | Flask, MySQL, Express |
| Infrastructure | Application + Domaine | — |
Si tu peux écrire des tests unitaires sur ta logique métier sans démarrer de serveur, sans connexion BDD, sans réseau → tu respectes la règle de dépendance. C'est le test décisif.
Ports & Adapters — le détail
Ports entrants (Driving / Primary)
Interfaces que le domaine expose — façon dont le monde extérieur peut déclencher un cas d'utilisation.
Ports sortants (Driven / Secondary)
Interfaces que le domaine requiert — ce dont il a besoin pour fonctionner.
from abc import ABC, abstractmethod
# Port sortant — ce que le domaine requiert
# Le domaine définit l'interface, l'infrastructure l'implémente
class ArticleRepository(ABC): # ← Port
@abstractmethod
def sauvegarder(self, article) -> None: ...
@abstractmethod
def trouver_par_id(self, id: int): ...
@abstractmethod
def lister_tous(self): ...
# Port sortant — service email
class ServiceEmail(ABC): # ← Port
@abstractmethod
def envoyer(self, dest: str, sujet: str, corps: str): ...
# ═══════════════════════════════════════════════════
# Adapters — implémentations concrètes
class ArticleRepositoryMySQL(ArticleRepository): # ← Adapter
def __init__(self, db): self.db = db
def sauvegarder(self, article):
self.db.execute('INSERT INTO articles...', [article.titre])
def trouver_par_id(self, id): ...
def lister_tous(self): ...
class ArticleRepositoryMemoire(ArticleRepository): # ← Adapter Tests
def __init__(self): self._articles = []
def sauvegarder(self, article): self._articles.append(article)
def trouver_par_id(self, id): return next((a for a in self._articles if a.id==id), None)
def lister_tous(self): return list(self._articles)
Règle de dépendance — injection
# Domaine — entité pure
from dataclasses import dataclass
@dataclass
class Article:
id: int | None
titre: str
contenu: str
def valider(self):
if not self.titre.strip():
raise ValueError("Titre vide")
if len(self.contenu) < 10:
raise ValueError("Contenu trop court")
# Application — use case
# Le service reçoit les ports via injection — il ne les crée pas
class PublierArticleService:
def __init__(self,
repo: ArticleRepository, # ← Port injecté
email: ServiceEmail): # ← Port injecté
self.repo = repo
self.email = email
def executer(self, titre: str, contenu: str) -> Article:
article = Article(id=None, titre=titre, contenu=contenu)
article.valider() # règle métier
self.repo.sauvegarder(article) # port → adapter MySQL ou mémoire
self.email.envoyer( # port → adapter SMTP ou mock
'admin@site.com', 'Nouvel article', titre
)
return article
# Infrastructure — adapter HTTP (Flask)
from flask import Blueprint, request, jsonify
def creer_router(service: PublierArticleService):
bp = Blueprint('articles', __name__)
@bp.route('/articles', methods=['POST'])
def creer():
data = request.get_json()
try:
article = service.executer(data['titre'], data['contenu'])
return jsonify({'id': article.id}), 201
except ValueError as e:
return jsonify({'erreur': str(e)}), 400
return bp
# app.py — composition root (assemblage final)
from flask import Flask
# Choisir les adapters selon l'environnement
import os
if os.environ.get('TEST'):
repo = ArticleRepositoryMemoire()
email = ServiceEmailMock()
else:
repo = ArticleRepositoryMySQL(db_connection)
email = ServiceEmailSMTP(smtp_config)
service = PublierArticleService(repo, email)
app = Flask(__name__)
app.register_blueprint(creer_router(service))
Python — structure complète
from dataclasses import dataclass, field
from datetime import datetime
# Entité pure — aucun import externe (pas de Flask, SQLAlchemy...)
@dataclass
class Article:
titre: str
contenu: str
auteur_id: int
id: int | None = None
publie: bool = False
created_at: datetime = field(default_factory=datetime.now)
def __post_init__(self):
if not self.titre.strip():
raise ValueError("Le titre ne peut pas être vide")
if len(self.contenu) < 20:
raise ValueError("Contenu trop court (min 20 caractères)")
def publier(self):
if self.publie:
raise ValueError("Article déjà publié")
self.publie = True
Node.js — structure hexagonale
// Domaine — aucun require() externe !
class Article {
constructor({ id = null, titre, contenu, auteurId }) {
this.id = id;
this.titre = titre;
this.contenu = contenu;
this.auteurId = auteurId;
this.publie = false;
this._valider();
}
_valider() {
if (!this.titre?.trim())
throw new Error('TITRE_VIDE');
if (this.contenu.length < 20)
throw new Error('CONTENU_TROP_COURT');
}
publier() {
if (this.publie) throw new Error('DEJA_PUBLIE');
this.publie = true;
}
}
module.exports = Article;
const Article = require('../domain/Article'); // ← domain seulement
class PublierArticleService {
constructor(repo, emailService) {
this.repo = repo; // port injecté
this.emailService = emailService;
}
async executer({ titre, contenu, auteurId }) {
const article = new Article({ titre, contenu, auteurId });
article.publier();
await this.repo.sauvegarder(article); // port
await this.emailService.notifier(auteurId, article.titre);
return article;
}
}
module.exports = PublierArticleService;
class ArticleRepositoryMySQL {
constructor(db) { this.db = db; }
async sauvegarder(article) {
const [r] = await this.db.query(
'INSERT INTO articles (titre,contenu,auteur_id,publie) VALUES (?,?,?,?)',
[article.titre, article.contenu, article.auteurId, article.publie]
);
article.id = r.insertId;
}
async findById(id) { /* ... */ }
async findAll() { /* ... */ }
}
// Adapter de test — en mémoire
class ArticleRepositoryMemoire {
constructor() { this.articles = []; this.nextId = 1; }
async sauvegarder(article) { article.id = this.nextId++; this.articles.push(article); }
async findAll() { return this.articles; }
}
module.exports = { ArticleRepositoryMySQL, ArticleRepositoryMemoire };
Tester sans dépendances — le vrai avantage
# tests/test_publier_article.py
import pytest
from domain.entities.article import Article
from application.services.publier_article import PublierArticleService
from infrastructure.adapters.in_memory import (
ArticleRepositoryMemoire, ServiceEmailMock
)
def test_publier_article_valide():
repo = ArticleRepositoryMemoire()
email = ServiceEmailMock()
service = PublierArticleService(repo, email)
article = service.executer(
titre="Mon article",
contenu="Contenu suffisamment long pour passer la validation",
auteur_id=1
)
assert article.publie == True
assert repo.lister_tous()[0].titre == "Mon article"
assert email.notifications_envoyees == 1
def test_publier_article_titre_vide():
repo = ArticleRepositoryMemoire()
email = ServiceEmailMock()
service = PublierArticleService(repo, email)
with pytest.raises(ValueError, match="Titre"):
service.executer(titre="", contenu="...", auteur_id=1)
assert len(repo.lister_tous()) == 0 # rien sauvegardé
assert email.notifications_envoyees == 0 # pas d'email
// tests/PublierArticle.test.js
const PublierArticleService = require('../application/PublierArticleService');
const { ArticleRepositoryMemoire } = require('../infrastructure/adapters/ArticleRepositoryMySQL');
const emailMock = {
appels: [],
async notifier(id, titre) { this.appels.push({ id, titre }); },
};
test('publier un article valide', async () => {
const repo = new ArticleRepositoryMemoire();
const service = new PublierArticleService(repo, emailMock);
const article = await service.executer({
titre: 'Test',
contenu: 'Contenu suffisamment long pour être valide',
auteurId: 1,
});
expect(article.publie).toBe(true);
expect(await repo.findAll()).toHaveLength(1);
expect(emailMock.appels).toHaveLength(1);
});
test('rejeter un titre vide', async () => {
const repo = new ArticleRepositoryMemoire();
const service = new PublierArticleService(repo, emailMock);
await expect(service.executer({ titre: '', contenu: '...', auteurId: 1 }))
.rejects.toThrow('TITRE_VIDE');
});
Ces tests tournent en millisecondes — aucune connexion réseau, aucune BDD, aucun serveur. C'est le bénéfice principal de l'hexagonale : une suite de tests rapide et fiable.
Hexagonale vs MVC — quand choisir ?
| Critère | MVC | Hexagonale |
|---|---|---|
| Complexité projet | Simple à moyen | Moyen à complexe |
| Nombre de fichiers | Peu | Beaucoup |
| Apprentissage | Facile | Difficile (investissement) |
| Testabilité | Bonne | Excellente |
| Changement de BDD | Difficile | Facile (swap d'adapter) |
| Changement de framework | Très difficile | Facile |
| Longévité du code | Moyenne | Très bonne |
| Projet de fin d'études | ✓ Idéal | ✓ Si ambitieux |
Utiliser MVC quand : CRUD simple, prototype rapide, petite équipe, deadline courte, première application web.
Utiliser l'hexagonale quand : logique métier complexe, plusieurs interfaces (web + CLI + mobile), besoin de tests rapides, application qui doit durer, changements de technologie prévisibles.
L'hexagonale n'est pas meilleure que MVC en absolu — elle est plus adaptée à la complexité. Appliquer l'hexagonale sur un CRUD de 3 tables = over-engineering. Le contexte décide.
Cheat sheet Architecture Hexagonale
🏆 Domaine — contient
| Entités (dataclass, classe) | ✓ |
| Règles métier | ✓ |
| Exceptions métier | ✓ |
| Interfaces (ports) ABC | ✓ |
| Flask, Express, SQLAlchemy | ✗ |
| SQL ou ORM | ✗ |
| HTTP, JSON | ✗ |
⚙️ Application — contient
| Services (Use Cases) | ✓ |
| Orchestration des entités | ✓ |
| Appels aux ports | ✓ |
| Flask, Express | ✗ |
| SQL direct | ✗ |
| req, res HTTP | ✗ |
🔌 Infrastructure — contient
| Controllers HTTP | ✓ |
| Repositories (MySQL, Mongo) | ✓ |
| Services email / SMTP | ✓ |
| Adapters "en mémoire" (tests) | ✓ |
| Configuration app | ✓ |
| Logique métier | ✗ |
🎯 Règles à retenir
| Direction dépendances | → vers le centre |
| Port = interface | ABC en Python |
| Adapter = implémentation | MySQL, mock… |
| Injection | Service reçoit les ports |
| Tests domaine | 0 BDD, 0 serveur |
| Composition root | app.py / index.js |