Architecture hexagonale | Un guide pratique pour les applications modernes
Decouvrez ce qu'est l'architecture hexagonale, pourquoi elle compte et comment l'implementer. Un guide complet avec des exemples concrets pour construire des logiciels maintenables et testables.
L’architecture hexagonale est un pattern de conception logicielle qui isole votre logique metier principale des systemes externes comme les bases de donnees, les API et les interfaces utilisateur. L’idee est simple : votre code domaine ne devrait jamais dependre de l’infrastructure. C’est l’infrastructure qui depend du domaine.
Le pattern a ete introduit par Alistair Cockburn en 2005. Vous pouvez aussi l’entendre sous le nom de “Ports and Adapters.” Les deux noms decrivent le meme concept.
Pourquoi il existe
La plupart des applications commencent de la meme facon. La logique metier se melange aux requetes base de donnees, aux handlers HTTP et aux appels de services tiers. Ca fonctionne au debut. Puis la base de code grandit, et trois choses se produisent :
- Les tests deviennent penibles. Vous ne pouvez pas tester les regles metier sans demarrer une base de donnees ou mocker la moitie du framework.
- Changer l’infrastructure est risque. Changer de fournisseur de paiement ou migrer de REST vers GraphQL signifie reecrire la logique metier.
- Le code est difficile a comprendre. Personne ne peut dire ou le domaine finit et ou l’infrastructure commence.
L’architecture hexagonale resout ces trois problemes en appliquant une regle unique : les dependances pointent toujours vers l’interieur.
Les concepts fondamentaux
Pensez a votre application comme trois couches concentriques.
Le domaine (centre)
C’est votre logique metier. Des fonctions pures, des entites, des objets-valeurs et des services de domaine. Il n’a aucune dependance envers les frameworks, les bases de donnees ou les bibliotheques externes. Il parle son propre langage.
Pour un systeme e-commerce, cette couche contient des concepts comme Order, Product, PricingRule et InventoryPolicy. Ces objets ne savent rien de SQL, HTTP ou JSON.
Les ports (couche intermediaire)
Les ports sont des interfaces qui definissent comment le monde exterieur interagit avec le domaine. Ce sont des contrats, pas des implementations.
Il en existe deux types :
- Ports entrants (driving). Definissent ce que l’application peut faire. Pensez a eux comme des cas d’utilisation. “Passer une commande.” “Annuler un abonnement.” “Generer une facture.”
- Ports sortants (driven). Definissent ce dont l’application a besoin du monde exterieur. “Sauvegarder une commande.” “Envoyer une notification.” “Recuperer le taux de change.”
Les ports font partie de la couche domaine. Ils sont ecrits en langage domaine, pas en langage d’infrastructure. Vous ecrivez OrderRepository, pas PostgresOrderDAO.
Les adaptateurs (couche externe)
Les adaptateurs sont les implementations concretes qui connectent le monde reel a vos ports.
- Les adaptateurs entrants traduisent les requetes externes en appels domaine. Un controleur REST, un resolver GraphQL, une commande CLI, un consommateur de file de messages. Tous sont des adaptateurs entrants.
- Les adaptateurs sortants implementent les interfaces des ports sortants. Un repository PostgreSQL, un envoyeur d’email SMTP, une passerelle de paiement Stripe. Tous sont des adaptateurs sortants.
L’insight cle : les adaptateurs dependent des ports. Les ports ne dependent jamais des adaptateurs. C’est ce qui fait fonctionner tout le pattern.
Un exemple concret
Disons que vous construisez une fonctionnalite de passage de commande. Voici comment les couches se decomposent.
Domaine (entites et regles) :
// domain/Order.ts
class Order {
readonly id: string;
readonly items: OrderItem[];
readonly status: OrderStatus;
calculateTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero()
);
}
confirm(): Order {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
return new Order({ ...this, status: "confirmed" });
}
}
Port entrant (interface du cas d’utilisation) :
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Ports sortants (ce dont le cas d’utilisation a besoin) :
// ports/outbound/OrderRepository.ts
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}
// ports/outbound/NotificationSender.ts
interface NotificationSender {
sendOrderConfirmation(order: Order): Promise<void>;
}
Service applicatif (implemente le port entrant) :
// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway,
private notifications: NotificationSender
) {}
async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
const order = Order.create(command.items);
const confirmed = order.confirm();
const payment = await this.payments.charge(
confirmed.calculateTotal(),
command.paymentMethod
);
if (!payment.successful) {
throw new PaymentFailedError(payment.reason);
}
await this.orders.save(confirmed);
await this.notifications.sendOrderConfirmation(confirmed);
return OrderConfirmation.from(confirmed, payment);
}
}
Adaptateurs (infrastructure) :
// adapters/inbound/OrderController.ts
class OrderController {
constructor(private placeOrder: PlaceOrder) {}
async handlePost(req: Request): Promise<Response> {
const command = PlaceOrderCommand.fromRequest(req.body);
const confirmation = await this.placeOrder.execute(command);
return Response.json(confirmation.toResponse(), { status: 201 });
}
}
// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await this.db.query(
"INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
[order.id, JSON.stringify(order.items), order.status]
);
}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
return row ? Order.fromPersistence(row) : null;
}
}
Remarquez que PlaceOrderService ne mentionne jamais PostgreSQL, HTTP ou un quelconque framework. Il fonctionne purement avec des concepts du domaine et des interfaces de ports.
La regle de dependance
C’est la regle la plus importante. Si vous ne retenez qu’une chose de cet article, retenez ceci :
Les dependances du code source doivent toujours pointer vers l’interieur, vers le domaine.
- Les adaptateurs dependent des ports. Jamais l’inverse.
- Les ports dependent des entites du domaine. Jamais des adaptateurs.
- Le domaine ne depend de rien d’externe.
Ceci est applique via l’injection de dependances. Votre racine de composition (le point d’entree de l’application) connecte tout :
// main.ts (racine de composition)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);
const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);
La couche domaine n’importe jamais depuis la couche adaptateurs. Si vous voyez un import depuis adapters/ dans domain/ ou ports/, quelque chose ne va pas.
Structure du projet
Une structure de dossiers propre rend les frontieres visibles :
src/
domain/
Order.ts
OrderItem.ts
Money.ts
errors/
EmptyOrderError.ts
PaymentFailedError.ts
ports/
inbound/
PlaceOrder.ts
CancelOrder.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
NotificationSender.ts
application/
PlaceOrderService.ts
CancelOrderService.ts
adapters/
inbound/
http/
OrderController.ts
cli/
PlaceOrderCommand.ts
outbound/
persistence/
PostgresOrderRepository.ts
payment/
StripePaymentGateway.ts
notification/
EmailNotificationSender.ts
main.ts
Chaque developpeur de l’equipe peut regarder cet arbre et comprendre ou placer du nouveau code.
Les benefices pour les tests
C’est la ou l’architecture hexagonale paye vraiment.
Tests unitaires du domaine :
test("order calculates total correctly", () => {
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 3 },
{ product: "Gadget", price: Money.eur(25), quantity: 1 },
]);
expect(order.calculateTotal()).toEqual(Money.eur(55));
});
Pas de base de donnees. Pas de serveur HTTP. Pas de framework de mocking. De la logique pure, des tests purs.
Tester les cas d’utilisation avec de faux adaptateurs :
test("place order charges payment and saves", async () => {
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway({ alwaysSucceeds: true });
const notifications = new FakeNotificationSender();
const service = new PlaceOrderService(orders, payments, notifications);
const result = await service.execute({
items: [{ product: "Widget", price: 10, quantity: 2 }],
paymentMethod: { type: "card", token: "tok_test" },
});
expect(result.status).toBe("confirmed");
expect(orders.savedOrders).toHaveLength(1);
expect(payments.charges).toHaveLength(1);
expect(notifications.sent).toHaveLength(1);
});
Vous testez le cas d’utilisation entier sans toucher a une vraie infrastructure. Les faux adaptateurs implementent les memes interfaces de ports, ils sont donc interchangeables avec les vrais.
Tests d’integration uniquement pour les adaptateurs :
test("PostgresOrderRepository saves and retrieves", async () => {
const repo = new PostgresOrderRepository(testDb);
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 1 },
]);
await repo.save(order.confirm());
const found = await repo.findById(order.id);
expect(found).toEqual(order.confirm());
});
Chaque adaptateur a son propre test d’integration. Si l’adaptateur passe son test, et que le cas d’utilisation passe avec les faux adaptateurs, le systeme entier fonctionne.
Quand utiliser l’architecture hexagonale
L’architecture hexagonale ajoute de la structure et de l’indirection. Cela a un cout. Voici quand ca vaut le coup :
- Applications a longue duree de vie. Si le projet sera maintenu pendant des annees, l’investissement est vite rentabilise.
- Regles metier complexes. Si votre domaine a une logique non triviale qui doit etre testee en profondeur.
- Plusieurs points d’entree. Si la meme logique est appelee depuis une API web, un CLI, une file de messages et un cron job.
- L’infrastructure pourrait changer. Si vous n’etes pas sur de garder votre base de donnees, fournisseur cloud ou services tiers actuels.
- Plusieurs developpeurs. Des frontieres claires reduisent les conflits de merge et accelerent les revues de code.
Quand passer son tour
Tous les projets n’ont pas besoin de ce niveau de structure :
- Applications CRUD simples. Si l’app consiste principalement a lire et ecrire des donnees avec une logique metier minimale, l’indirection ajoute du cout sans benefice.
- Prototypes et MVP. La vitesse compte plus que l’architecture quand vous validez une idee. Refactorisez plus tard.
- Petits scripts et outils. Un outil CLI de 200 lignes n’a pas besoin de ports et adaptateurs.
La bonne quantite d’architecture depend de la complexite du probleme. Commencez simple. Introduisez les frontieres hexagonales quand la douleur de ne pas les avoir devient reelle.
Erreurs courantes
Fuites d’infrastructure dans le domaine. Si votre entite Order a un decorateur @Column ou une methode toJSON(), l’infrastructure a fuite. Gardez vos objets domaine propres.
Creer des ports qui imitent l’infrastructure. Un port appele SqlQuery manque l’objectif. Les ports doivent decrire ce dont le domaine a besoin en langage domaine, pas comment l’infrastructure fonctionne.
Sur-abstraction. Tout n’a pas besoin d’un port. Si vous avez une fonction utilitaire qui formate des dates, utilisez-la directement. Reservez les ports aux vraies frontieres d’infrastructure.
Sauter la racine de composition. Sans un endroit clair ou les dependances sont connectees, le pattern s’effondre. Chaque dependance devrait etre injectee, pas importee directement.
Architecture hexagonale vs autres patterns
Vous vous demandez peut-etre comment l’architecture hexagonale se rapporte a d’autres patterns bien connus :
- Clean Architecture (Robert C. Martin) : Tres similaire. Clean Architecture ajoute plus de couches (entites, cas d’utilisation, adaptateurs d’interface, frameworks) mais l’idee centrale est la meme : les dependances pointent vers l’interieur.
- Onion Architecture (Jeffrey Palermo) : Egalement tres similaire. Onion Architecture utilise des anneaux concentriques avec le domaine au centre. La terminologie differe, mais la regle de dependance est identique.
- Architecture en couches (N-tiers) : L’approche traditionnelle ou les couches s’empilent verticalement (presentation, metier, donnees). La difference cruciale : dans l’architecture en couches, la couche metier depend typiquement de la couche donnees. En architecture hexagonale, cette dependance est inversee.
Les trois patterns modernes (hexagonal, clean, onion) partagent le meme principe fondamental. Le domaine ne depend pas de l’infrastructure. Ils different principalement dans les conventions de nommage et le nombre de couches qu’ils prescrivent.
Pour commencer
Si vous voulez appliquer l’architecture hexagonale a un projet existant, n’essayez pas de tout refactoriser d’un coup. Commencez par un contexte limite ou une fonctionnalite :
- Identifiez la logique metier principale de cette fonctionnalite. Extrayez-la dans des fonctions pures ou des classes sans dependances de framework.
- Definissez des ports pour les dependances externes que cette fonctionnalite utilise. Creez des interfaces en langage domaine.
- Deplacez le code d’infrastructure existant dans des classes d’adaptateurs qui implementent ces interfaces.
- Connectez tout dans une racine de composition en utilisant l’injection de dependances.
- Ecrivez des tests pour le domaine et les cas d’utilisation en utilisant de faux adaptateurs.
Repetez pour la fonctionnalite suivante. Avec le temps, les frontieres hexagonales se repandront naturellement dans la base de code.
Reflexions finales
L’architecture hexagonale n’est pas une question de suivre un template rigide. C’est une idee simple : proteger votre logique metier du chaos du monde exterieur. Les bases de donnees changent. Les frameworks passent de mode. Les API sont depreciees. Vos regles metier devraient survivre a tout cela.
Le pattern fonctionne parce qu’il s’aligne avec la facon dont les logiciels evoluent reellement. Les exigences changent constamment. L’infrastructure change periodiquement. Mais les regles fondamentales de votre metier tendent a etre la partie la plus stable du systeme. Construisez autour de cette stabilite.
Si vous construisez un produit qui doit durer, l’architecture hexagonale est l’un des meilleurs investissements que vous puissiez faire dans votre base de code.
Besoin d’aide pour concevoir ou refactoriser l’architecture de votre application ? Contactez-nous. Nous aidons les equipes a construire des logiciels qui restent maintenables au fil de leur croissance.