Architecture Hexagonale
L'architecture hexagonale (Ports et Adapters) maintient la logique métier indépendante des frameworks, du transport et des détails de persistance. L'application centrale dépend de ports abstraits, et les adaptateurs implémentent ces ports à la périphérie.
Quand l'utiliser
- Construire de nouvelles fonctionnalités où la maintenabilité à long terme et la testabilité comptent.
- Refactoriser du code en couches ou fortement lié à un framework où la logique métier est mélangée aux préoccupations d'E/S.
- Prendre en charge plusieurs interfaces pour le même cas d'utilisation (HTTP, CLI, workers de file d'attente, tâches cron).
- Remplacer une infrastructure (base de données, API externes, bus de messages) sans réécrire les règles métier.
Utilisez cette compétence lorsque la demande implique des frontières, une conception centrée sur le domaine, un refactoring de services fortement couplés, ou un découplage de la logique applicative de bibliothèques spécifiques.
Concepts fondamentaux
- Modèle de domaine : Règles métier et entités/objets valeur. Aucun import de framework.
- Cas d'utilisation (couche application) : Orchestrent le comportement du domaine et les étapes du workflow.
- Ports entrants : Contrats décrivant ce que l'application peut faire (commandes/requêtes/interfaces de cas d'utilisation).
- Ports sortants : Contrats pour les dépendances dont l'application a besoin (référentiels, passerelles, éditeurs d'événements, horloge, UUID, etc.).
- Adaptateurs : Implémentations d'infrastructure et de livraison des ports (contrôleurs HTTP, référentiels de base de données, consommateurs de file d'attente, wrappers SDK).
- Point de composition : Emplacement unique de câblage où les adaptateurs concrets sont liés aux cas d'utilisation.
Les interfaces des ports sortants résident généralement dans la couche application (ou dans le domaine uniquement lorsque l'abstraction est véritablement au niveau du domaine), tandis que les adaptateurs d'infrastructure les implémentent.
La direction des dépendances est toujours vers l'intérieur :
- Adaptateurs -> application/domaine
- Application -> interfaces de port (contrats entrants/sortants)
- Domaine -> abstractions propres au domaine (aucune dépendance framework ou infrastructure)
- Domaine -> rien d'externe
Comment ça fonctionne
Étape 1 : Modéliser une frontière de cas d'utilisation
Définir un cas d'utilisation unique avec un DTO d'entrée et de sortie clair. Conserver les détails de transport (Express req, GraphQL context, wrappers de charge utile de travail) en dehors de cette frontière.
Étape 2 : Définir les ports sortants en premier
Identifier chaque effet de bord comme un port :
- persistance (
UserRepositoryPort) - appels externes (
BillingGatewayPort) - transversaux (
LoggerPort,ClockPort)
Les ports doivent modéliser des capacités, pas des technologies.
Étape 3 : Implémenter le cas d'utilisation avec une orchestration pure
La classe/fonction du cas d'utilisation reçoit les ports via le constructeur/les arguments. Elle valide les invariants au niveau de l'application, coordonne les règles du domaine et renvoie des structures de données simples.
Étape 4 : Construire les adaptateurs à la périphérie
- L'adaptateur entrant convertit l'entrée du protocole en entrée du cas d'utilisation.
- L'adaptateur sortant mappe les contrats de l'application aux API/ORM/constructeurs de requêtes concrets.
- Le mappage reste dans les adaptateurs, pas à l'intérieur des cas d'utilisation.
Étape 5 : Câbler le tout dans un point de composition
Instancier les adaptateurs, puis les injecter dans les cas d'utilisation. Centraliser ce câblage pour éviter tout comportement de localisateur de services caché.
Étape 6 : Tester par frontière
- Tester unitairement les cas d'utilisation avec des ports factices.
- Tester l'intégration des adaptateurs avec les vraies dépendances d'infrastructure.
- Tester de bout en bout les flux utilisateur via les adaptateurs entrants.
Diagramme d'architecture
flowchart LR
Client["Client (HTTP/CLI/Worker)"] --> InboundAdapter["Adaptateur entrant"]
InboundAdapter -->|"appelle"| UseCase["Cas d'utilisation (Couche application)"]
UseCase -->|"utilise"| OutboundPort["Port sortant (Interface)"]
OutboundAdapter["Adaptateur sortant"] -->|"implémente"| OutboundPort
OutboundAdapter --> ExternalSystem["BDD/API/File d'attente"]
UseCase --> DomainModel["Modèle de domaine"]
Structure de modules suggérée
Utiliser une organisation par fonctionnalité avec des frontières explicites :
src/
features/
orders/
domain/
Order.ts
OrderPolicy.ts
application/
ports/
inbound/
CreateOrder.ts
outbound/
OrderRepositoryPort.ts
PaymentGatewayPort.ts
use-cases/
CreateOrderUseCase.ts
adapters/
inbound/
http/
createOrderRoute.ts
outbound/
postgres/
PostgresOrderRepository.ts
stripe/
StripePaymentGateway.ts
composition/
ordersContainer.ts
Exemple TypeScript
Définitions des ports
export interface OrderRepositoryPort {
save(order: Order): Promise<void>;
findById(orderId: string): Promise<Order | null>;
}
export interface PaymentGatewayPort {
authorize(input: { orderId: string; amountCents: number }): Promise<{ authorizationId: string }>;
}
Cas d'utilisation
type CreateOrderInput = {
orderId: string;
amountCents: number;
};
type CreateOrderOutput = {
orderId: string;
authorizationId: string;
};
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepositoryPort,
private readonly paymentGateway: PaymentGatewayPort
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const order = Order.create({ id: input.orderId, amountCents: input.amountCents });
const auth = await this.paymentGateway.authorize({
orderId: order.id,
amountCents: order.amountCents,
});
// markAuthorized renvoie une nouvelle instance d'Order ; elle ne mute pas sur place.
const authorizedOrder = order.markAuthorized(auth.authorizationId);
await this.orderRepository.save(authorizedOrder);
return {
orderId: order.id,
authorizationId: auth.authorizationId,
};
}
}
Adaptateur sortant
export class PostgresOrderRepository implements OrderRepositoryPort {
constructor(private readonly db: SqlClient) {}
async save(order: Order): Promise<void> {
await this.db.query(
"insert into orders (id, amount_cents, status, authorization_id) values ($1, $2, $3, $4)",
[order.id, order.amountCents, order.status, order.authorizationId]
);
}
async findById(orderId: string): Promise<Order | null> {
const row = await this.db.oneOrNone("select * from orders where id = $1", [orderId]);
return row ? Order.rehydrate(row) : null;
}
}
Point de composition
export const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => {
const orderRepository = new PostgresOrderRepository(deps.db);
const paymentGateway = new StripePaymentGateway(deps.stripe);
return new CreateOrderUseCase(orderRepository, paymentGateway);
};
Mapping multi-langage
Utiliser les mêmes règles de frontière dans tous les écosystèmes ; seuls la syntaxe et le style de câblage changent.
- TypeScript/JavaScript
- Ports :
application/ports/*sous forme d'interfaces/types. - Cas d'utilisation : classes/fonctions avec injection via constructeur/argument.
- Adaptateurs :
adapters/inbound/*,adapters/outbound/*. - Composition : module factory/container explicite (pas de globales cachées).
- Ports :
- Java
- Packages :
domain,application.port.in,application.port.out,application.usecase,adapter.in,adapter.out. - Ports : interfaces dans
application.port.*. - Cas d'utilisation : classes simples (Spring
@Serviceest optionnel, pas obligatoire). - Composition : configuration Spring ou classe de câblage manuelle ; garder le câblage hors des classes de domaine/cas d'utilisation.
- Packages :
- Kotlin
- Modules/packages suivent la division Java (
domain,application.port,application.usecase,adapter). - Ports : interfaces Kotlin.
- Cas d'utilisation : classes avec injection par constructeur (Koin/Dagger/Spring/manuel).
- Composition : définitions de modules ou fonctions de composition dédiées ; éviter les patrons de localisateur de services.
- Modules/packages suivent la division Java (
- Go
- Packages :
internal/<feature>/domain,application,ports,adapters/inbound,adapters/outbound. - Ports : petites interfaces possédées par le package applicatif consommateur.
- Cas d'utilisation : structs avec champs d'interface et constructeurs
New...explicites. - Composition : câblage dans
cmd/<app>/main.go(ou package de câblage dédié), garder les constructeurs explicites.
- Packages :
Anti-patrons à éviter
- Les entités du domaine qui importent des modèles ORM, des types de framework web ou des clients SDK.
- Les cas d'utilisation qui lisent directement
req,resou des métadonnées de file d'attente. - Retourner des lignes de base de données directement depuis les cas d'utilisation sans mappage domaine/application.
- Laisser les adaptateurs s'appeler directement au lieu de passer par les ports des cas d'utilisation.
- Répartir le câblage des dépendances dans de nombreux fichiers avec des singletons globaux cachés.
Playbook de migration
- Choisir une tranche verticale (un seul endpoint/travail) avec une fréquence de changement élevée.
- Extraire une frontière de cas d'utilisation avec des types d'entrée/sortie explicites.
- Introduire des ports sortants autour des appels d'infrastructure existants.
- Déplacer la logique d'orchestration des contrôleurs/services vers le cas d'utilisation.
- Conserver les anciens adaptateurs, mais leur faire déléguer le travail au nouveau cas d'utilisation.
- Ajouter des tests autour de la nouvelle frontière (unitaire + intégration adaptateur).
- Répéter tranche par tranche ; éviter les réécritures complètes.
Refactoring de systèmes existants
- Approche Strangler : conserver les endpoints actuels, router un cas d'utilisation à la fois via les nouveaux ports/adaptateurs.
- Pas de réécriture big-bang : migrer par tranche de fonctionnalité et préserver le comportement avec des tests de caractérisation.
- Façade d'abord : envelopper les services existants derrière des ports sortants avant de remplacer les internes.
- Geler la composition : centraliser le câblage tôt pour que les nouvelles dépendances ne fuient pas dans les couches domaine/cas d'utilisation.
- Règle de sélection des tranches : prioriser les flux à forte vélocité de changement et à faible rayon d'impact.
- Chemin de retour : conserver un interrupteur réversible ou un aiguillage de route par tranche migrée jusqu'à ce que le comportement en production soit vérifié.
Conseils de test (Mêmes frontières hexagonales)
- Tests du domaine : tester les entités/objets valeur comme des règles métier pures (pas de mocks, pas de configuration de framework).
- Tests unitaires des cas d'utilisation : tester l'orchestration avec des fakes/stubs pour les ports sortants ; vérifier les résultats métier et les interactions avec les ports.
- Tests de contrat des adaptateurs sortants : définir des suites de contrat partagées au niveau du port et les exécuter contre chaque implémentation d'adaptateur.
- Tests des adaptateurs entrants : vérifier le mappage de protocole (charge utile HTTP/CLI/file d'attente vers entrée du cas d'utilisation et sortie/erreur mappée vers le protocole).
- Tests d'intégration des adaptateurs : exécuter contre une vraie infrastructure (BDD/API/file d'attente) pour la sérialisation, le comportement schéma/requête, les tentatives et les délais d'attente.
- Tests de bout en bout : couvrir les parcours utilisateur critiques via l'adaptateur entrant -> cas d'utilisation -> adaptateur sortant.
- Sécurité du refactoring : ajouter des tests de caractérisation avant l'extraction ; les conserver jusqu'à ce que le comportement de la nouvelle frontière soit stable et équivalent.
Liste de vérification des bonnes pratiques
- Les couches domaine et cas d'utilisation n'importent que des types internes et des ports.
- Chaque dépendance externe est représentée par un port sortant.
- La validation a lieu aux frontières (adaptateur entrant + invariants du cas d'utilisation).
- Utiliser des transformations immuables (renvoyer de nouvelles valeurs/entités au lieu de muter un état partagé).
- Les erreurs sont traduites à travers les frontières (erreurs d'infrastructure -> erreurs application/domaine).
- Le point de composition est explicite et facile à auditer.
- Les cas d'utilisation sont testables avec des fakes simples en mémoire pour les ports.
- Le refactoring commence par une tranche verticale avec des tests préservant le comportement.
- Les spécificités du langage/framework restent dans les adaptateurs, jamais dans les règles du domaine.