L'architecture hexagonale en pratique : pourquoi j'ai abandonné les contrôleurs MVC
Après 15 ans de développement, j'ai définitivement abandonné l'architecture MVC traditionnelle pour l'architecture hexagonale. Voici pourquoi cette approche révolutionne la maintenabilité et les tests, avec des exemples concrets de mise en œuvre.
L'architecture hexagonale en pratique : pourquoi j'ai abandonné les contrôleurs MVC
L'architecture MVC m'a longtemps satisfait. Jusqu'au jour où j'ai dû modifier un système de facturation connecté à 4 APIs externes différentes. Chaque changement cassait quelque chose ailleurs. Les tests étaient un cauchemar. Le code métier était éparpillé entre les contrôleurs, les services et les modèles.
C'est là que j'ai découvert l'architecture hexagonale. Aujourd'hui, après l'avoir implémentée sur une dizaine de projets, je ne reviens plus en arrière.
Le problème fondamental du MVC traditionnel
Le MVC souffre d'un défaut majeur : il mélange les responsabilités. Votre logique métier finit inévitablement dans vos contrôleurs. Vos modèles deviennent des anémiques sacs de données. Vos services grossissent jusqu'à devenir ingérables.
J'ai vécu cette spirale infernale sur un projet de gestion de commandes. Le contrôleur OrderController faisait 300 lignes. Il gérait à la fois :
- La validation des données d'entrée
- Les appels à l'API de paiement Stripe
- L'envoi d'emails de confirmation
- La mise à jour du stock
- Les logs d'audit
Résultat : impossible de tester la logique métier sans mocker 5 services externes. Impossible de changer de provider de paiement sans réécrire le contrôleur.
L'architecture hexagonale : séparer le quoi du comment
L'architecture hexagonale résout ce problème en isolant complètement la logique métier. Au centre : votre domaine. Autour : les ports (interfaces) et les adaptateurs (implémentations).
Concrètement, voici comment je structure mes projets :
src/
├── domain/
│ ├── entities/
│ ├── use-cases/
│ └── ports/
├── infrastructure/
│ ├── adapters/
│ └── config/
└── presentation/
├── controllers/
└── middleware/
Le domaine ne connaît que les interfaces (ports). Les adaptateurs implémentent ces interfaces. La présentation orchestre le tout.
Un exemple concret : système de commandes
Prenons un cas réel. Dans l'architecture MVC classique, votre contrôleur ressemblerait à ça :
// Approche MVC - À éviter
class OrderController {
async createOrder(req, res) {
// Validation
const { customerId, items } = req.body;
if (!customerId || !items) {
return res.status(400).json({ error: 'Missing data' });
}
// Logique métier mélangée
const customer = await Customer.findById(customerId);
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// Appel externe direct
const payment = await stripe.charges.create({
amount: total * 100,
currency: 'eur',
customer: customer.stripeId
});
// Persistance
const order = await Order.create({
customerId,
items,
total,
paymentId: payment.id
});
// Email
await emailService.sendConfirmation(customer.email, order);
res.json(order);
}
}
Avec l'architecture hexagonale, je sépare tout :
// Domain - Use Case
class CreateOrderUseCase {
constructor(orderRepository, paymentGateway, emailService) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.emailService = emailService;
}
async execute(orderData) {
const order = new Order(orderData);
const payment = await this.paymentGateway.processPayment(order.total);
order.confirmPayment(payment.id);
const savedOrder = await this.orderRepository.save(order);
await this.emailService.sendConfirmation(order.customerEmail, savedOrder);
return savedOrder;
}
}
// Infrastructure - Adapter
class StripePaymentGateway {
async processPayment(amount) {
return await stripe.charges.create({
amount: amount * 100,
currency: 'eur'
});
}
}
// Presentation - Controller
class OrderController {
constructor(createOrderUseCase) {
this.createOrderUseCase = createOrderUseCase;
}
async createOrder(req, res) {
try {
const order = await this.createOrderUseCase.execute(req.body);
res.json(order);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
}
Les bénéfices concrets que j'observe
Tests simplifiés
Avec l'architecture hexagonale, je teste chaque couche indépendamment. Mon use case devient trivial à tester :
describe('CreateOrderUseCase', () => {
it('should create order with payment', async () => {
const mockPaymentGateway = { processPayment: jest.fn().mockResolvedValue({ id: 'pay_123' }) };
const mockRepository = { save: jest.fn().mockResolvedValue(orderMock) };
const mockEmailService = { sendConfirmation: jest.fn() };
const useCase = new CreateOrderUseCase(mockRepository, mockPaymentGateway, mockEmailService);
const result = await useCase.execute(orderData);
expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith(100);
expect(result.paymentId).toBe('pay_123');
});
});
Pas de base de données. Pas d'API externe. Juste la logique pure.
Changements d'infrastructure sans impact
Quand j'ai dû passer de Stripe à PayPal sur un projet, j'ai juste créé un nouvel adaptateur :
class PayPalPaymentGateway {
async processPayment(amount) {
return await paypal.payment.create({
amount,
currency: 'EUR'
});
}
}
Le use case n'a pas bougé. Les tests non plus.
Réutilisabilité maximale
Mes use cases deviennent réutilisables. Le même CreateOrderUseCase fonctionne dans une API REST, une interface GraphQL, ou même un worker background.
Mise en pratique avec l'injection de dépendances
L'architecture hexagonale nécessite une bonne gestion des dépendances. J'utilise un container IoC simple :
// container.js
class Container {
constructor() {
this.dependencies = new Map();
}
register(name, factory) {
this.dependencies.set(name, factory);
}
resolve(name) {
const factory = this.dependencies.get(name);
return factory(this);
}
}
// Configuration
const container = new Container();
container.register('paymentGateway', () => new StripePaymentGateway());
container.register('orderRepository', () => new PostgreSQLOrderRepository());
container.register('emailService', () => new SendGridEmailService());
container.register('createOrderUseCase', (c) =>
new CreateOrderUseCase(
c.resolve('orderRepository'),
c.resolve('paymentGateway'),
c.resolve('emailService')
)
);
Les pièges à éviter
L'over-engineering
N'implémentez pas l'architecture hexagonale sur un CRUD simple. Elle brille sur les projets avec de la logique métier complexe et des intégrations multiples.
Les ports trop génériques
Créez des interfaces spécifiques à votre domaine, pas des abstractions génériques. PaymentGateway plutôt que ExternalService.
L'oubli de la validation
La validation des données d'entrée reste dans la couche présentation. Ne polluez pas votre domaine avec des détails d'implémentation.
L'architecture hexagonale avec l'IA
L'IA change la donne pour l'architecture hexagonale. Quand je demande à Claude de générer un adaptateur pour une nouvelle API, il comprend immédiatement le pattern grâce aux interfaces bien définies.
// Prompt : "Crée un adaptateur Discord pour EmailService"
class DiscordEmailService {
constructor(webhookUrl) {
this.webhookUrl = webhookUrl;
}
async sendConfirmation(email, order) {
await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: `Nouvelle commande #${order.id} pour ${email} - ${order.total}€`
})
});
}
}
L'IA génère du code qui respecte parfaitement le contrat défini par le port.
Ma recommandation
L'architecture hexagonale n'est pas un dogme. C'est un outil. Sur des projets simples, MVC suffit largement. Mais dès que vous avez :
- Des intégrations multiples
- De la logique métier complexe
- Des besoins de testabilité élevés
- Des changements fréquents d'infrastructure
L'architecture hexagonale devient un investissement rentable.
Je l'applique systématiquement sur mes missions de Fractional CTO. Les équipes comprennent rapidement les bénéfices. Le code devient plus prévisible. Les bugs diminuent. Les nouvelles fonctionnalités se développent plus vite.
L'architecture, c'est comme l'assurance : on ne voit sa valeur qu'au moment où on en a besoin. Avec l'architecture hexagonale, ce moment arrive plus tard et fait moins mal.
Vous voulez discuter architecture sur votre projet ? Contactez-moi pour un audit technique.