Architecture Hexagonale
L'architecture hexagonale (Ports et Adaptateurs) 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 en périphérie.
Quand l'utiliser
- Construire de nouvelles fonctionnalités où la maintenabilité et la testabilité à long terme sont importantes.
- Refactoriser un code fortement couplé aux frameworks 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 l'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 limites, une conception centrée domaine, le refactoring de services fortement couplés ou le découplage de la logique applicative de bibliothèques spécifiques.
Concepts Clés
- Modèle de domaine : Règles métier et entités/objets valeur. Aucun import de framework.
- Cas d'utilisation (couche applicative) : Orchestrent le comportement du domaine et les étapes des workflows.
- 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 files d'attente, wrappers SDK).
- Point de composition racine (composition root) : Emplacement de câblage unique où les adaptateurs concrets sont liés aux cas d'utilisation.
Les interfaces des ports sortants vivent généralement dans la couche applicative (ou dans le domaine uniquement lorsque l'abstraction est véritablement au niveau 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 ports (contrats entrants/sortants)
- Domaine -> abstractions uniquement domaine (aucune dépendance framework ou infrastructure)
- Domaine -> rien d'externe
Comment ça fonctionne
Étape 1 : Modéliser une limite de cas d'utilisation
Définissez un cas d'utilisation unique avec un DTO d'entrée et de sortie clair. Gardez les détails de transport (Express req, GraphQL context, wrappers de charge utile de tâche) en dehors de cette limite.
Étape 2 : Définir d'abord les ports sortants
Identifiez chaque effet de bord comme un port :
- Persistance (
UserRepositoryPort) - Appels externes (
BillingGatewayPort) - Transversal (
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/arguments. Elle valide les invariants au niveau applicatif, coordonne les règles du domaine et retourne des structures de données simples.
Étape 4 : Construire les adaptateurs en périphérie
- Adaptateur entrant : Convertit l'entrée du protocole en entrée du cas d'utilisation.
- Adaptateur sortant : Mappe les contrats de l'application aux API/ORM/constructeurs de requêtes concrets.
- Le mapping reste dans les adaptateurs, pas à l'intérieur des cas d'utilisation.
Étape 5 : Câbler le tout dans un point de composition racine
Instanciez les adaptateurs, puis injectez-les dans les cas d'utilisation. Gardez ce câblage centralisé pour éviter tout comportement de service locator caché.
Étape 6 : Tester par limite
- Test unitaire des cas d'utilisation avec des ports factices.
- Test d'intégration des adaptateurs avec de vraies dépendances d'infrastructure.
- Test de bout en bout des 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 Applicative)"]
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 module suggérée
Utilisez une organisation par fonctionnalité avec des limites 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 de 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 retourne une nouvelle instance de 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 racine
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
Utilisez les mêmes règles de limite 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 par constructeur/arguments.
- Adaptateurs :
adapters/inbound/*,adapters/outbound/*. - Composition : module factory/container explicite (pas de globaux cachés).
- 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 ; gardez le câblage hors des classes domaine/cas d'utilisation.
- Packages :
- Kotlin
- Modules/packages reflètent la séparation Java (
domain,application.port,application.usecase,adapter). - Ports : interfaces Kotlin.
- Cas d'utilisation : classes avec injection par constructeur (Koin/Dagger/Spring/manuelle).
- Composition : définitions de modules ou fonctions de composition dédiées ; évitez les patterns de service locator.
- Modules/packages reflètent la séparation 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 explicites
New.... - Composition : câblage dans
cmd/<app>/main.go(ou package de câblage dédié), gardez les constructeurs explicites.
- Packages :
Anti-patterns à é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 dans
req,resou les métadonnées de file d'attente. - Retourner des lignes de base de données directement depuis les cas d'utilisation sans mapping domaine/applicatif.
- 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 sur de nombreux fichiers avec des singletons globaux cachés.
Playbook de migration
- Choisissez une tranche verticale (un point de terminaison/tâche) avec une douleur de changement fréquent.
- Extrayez une limite de cas d'utilisation avec des types d'entrée/sortie explicites.
- Introduisez des ports sortants autour des appels d'infrastructure existants.
- Déplacez la logique d'orchestration des contrôleurs/services vers le cas d'utilisation.
- Gardez les anciens adaptateurs, mais faites-leur déléguer au nouveau cas d'utilisation.
- Ajoutez des tests autour de la nouvelle limite (unitaire + intégration adaptateur).
- Répétez tranche par tranche ; évitez les réécritures complètes.
Refactoring de systèmes existants
- Approche étrangleur (strangler) : conservez les points de terminaison actuels, acheminez un cas d'utilisation à la fois via les nouveaux ports/adaptateurs.
- Pas de réécritures big-bang : migrez par tranche fonctionnelle et préservez le comportement avec des tests de caractérisation.
- Façade d'abord : enveloppez les services legacy derrière des ports sortants avant de remplacer les internes.
- Geler la composition : centralisez 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 : priorisez les flux à forte vélocité de changement et à faible rayon d'impact en premier.
- Chemin de retour : conservez un basculement réversible ou un commutateur de route par tranche migrée jusqu'à ce que le comportement en production soit vérifié.
Conseils de test (Mêmes limites hexagonales)
- Tests de domaine : testez les entités/objets valeur comme des règles métier pures (pas de mocks, pas de configuration framework).
- Tests unitaires de cas d'utilisation : testez l'orchestration avec des fakes/stubs pour les ports sortants ; vérifiez les résultats métier et les interactions avec les ports.
- Tests de contrat des adaptateurs sortants : définissez des suites de contrats partagées au niveau du port et exécutez-les contre chaque implémentation d'adaptateur.
- Tests d'adaptateurs entrants : vérifiez le mapping de protocole (charge utile HTTP/CLI/file d'attente vers entrée du cas d'utilisation et mapping de sortie/erreur vers le protocole).
- Tests d'intégration des adaptateurs : exécutez contre une vraie infrastructure (BDD/API/file d'attente) pour la sérialisation, le comportement des schémas/requêtes, les tentatives et les timeouts.
- Tests de bout en bout : couvrez les parcours utilisateur critiques via adaptateur entrant -> cas d'utilisation -> adaptateur sortant.
- Sécurité du refactoring : ajoutez des tests de caractérisation avant l'extraction ; conservez-les jusqu'à ce que le comportement à la nouvelle limite 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 se produit aux limites (adaptateur entrant + invariants du cas d'utilisation).
- Utilisez des transformations immuables (retournez de nouvelles valeurs/entités au lieu de muter un état partagé).
- Les erreurs sont traduites à travers les limites (erreurs d'infrastructure -> erreurs applicatives/domaine).
- Le point de composition racine est explicite et facile à auditer.
- Les cas d'utilisation sont testables avec de simples fakes 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.