// Architecture Hexagonale — Référence complète

L’Architecture
Hexagonale

Ports & Adapters — Alistair Cockburn (2005). Isoler le domaine métier de tout ce qui est technique, remplaçable, périphérique.

🔌

Alistair Cockburn — 2005

« Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases. » L’application doit fonctionner indifféremment pilotée par un humain, un test automatisé ou un script — et communiquer indifféremment avec une vraie base de données, une implémentation en mémoire, ou un fichier CSV.

01

La métaphore : l’ambassade avec des prises universelles

Imagine une ambassade française à Tokyo. À l’intérieur, les lois françaises s’appliquent — c’est le domaine. Les visiteurs japonais ne changent pas les lois de l’ambassade : ils s’adaptent à son protocole. L’ambassade dispose de prises universelles — des ports — où n’importe quel visiteur peut se “brancher”.

C’est exactement l’architecture hexagonale. Le domaine métier est l’ambassade. Les ports sont les prises normalisées. Les adapters sont les convertisseurs qui permettent à n’importe quel équipement étranger (HTTP, PostgreSQL, Stripe, un fichier CSV) de se brancher sur ces prises.

Si demain l’ambassade déménage à Berlin, les prises restent les mêmes — seuls les adaptateurs changent. Le domaine ne bouge pas d’une virgule.

02

Vue d’ensemble — Le schéma fondamental

L’hexagone se divise en deux côtés. À gauche, les driving adapters (pilotants) — ceux qui déclenchent une action. À droite, les driven adapters (pilotés) — ceux que l’application utilise pour communiquer avec le monde extérieur. Au centre : le domaine, entouré de ports.

NoyauEntités
+ Use Cases
PORT D'ENTRÉEPORT DE SORTIE
driving
HTTP Controller
driving
CLI Adapter
driving
Test / JUnit
driven
PostgreSQL Repo
driven
Stripe Service
driven
Email Service
Driving Side
Driven Side
🟠
Driving Side — Côté Gauche
Adapters Pilotants
Ils pilotent l’application. Un Controller HTTP reçoit une requête et appelle un Use Case. Un test JUnit instancie directement le Use Case. Un CLI parse des arguments et délègue.
HTTP ControllerCLITestScheduler
🟢
Driven Side — Côté Droit
Adapters Pilotés
Ils sont pilotés par l’application. Le Use Case appelle un port de sortie, et l’adapter concret se charge de la technique : écrire en base, appeler Stripe, envoyer un email.
PostgreSQL RepoStripeEmailRedis
🔴
Ports — Les Interfaces
Contrats définis par le domaine
Un port est une interface TypeScript définie par le domaine. Le port d’entrée décrit ce que l’application sait faire. Le port de sortie décrit ce dont elle a besoin.
Interface TypeScriptDIPContrat
03

La Dependency Rule — la loi absolue

“Les dépendances ne pointent QUE vers le centre.”
Aucun fichier du domaine ne peut importer quoi que ce soit de l’infrastructure.
L’infrastructure dépend du domaine. Jamais l’inverse.
HTTP
Controller
appelle
IPasserCommande
Port d’entrée
implémente
UseCase
Domaine
utilise
ICommandeRepo
Port de sortie
implémente
PostgresRepo
Driven Adapter

Cette règle est rendue possible grâce à l’inversion de dépendance (DIP). Quand un Use Case a besoin d’une base de données, il ne l’importe pas directement — il définit une interface (un port) que l’adapter viendra implémenter. Le UseCase définit le contrat, l’Adapter s’y conforme.

❌ Sans inversion de dépendance
Le UseCase importe directement PostgresRepo
Changer de DB = modifier le UseCase
Impossible de tester sans lancer une DB
Le domaine métier connaît Stripe, TypeORM…
Couplage fort entre couches
✓ Avec Ports & Adapters
Le UseCase dépend uniquement d’une interface
Swap PostgreSQL → MongoDB : 1 seul fichier modifié
Tests avec un InMemoryRepo sans infrastructure
Le domaine métier ne connaît rien de l’extérieur
Chaque adapter est indépendant et remplaçable
04

Les Ports — contrats propriété du domaine

Un port est une interface TypeScript définie dans le domaine. C’est le domaine qui décide de quoi il a besoin — pas l’infrastructure. PostgreSQL, Stripe, SendGrid… tous se conforment au contrat du port. Si l’un d’eux disparaît, le domaine ne change pas d’une virgule.

Port (Interface) ICommandeRepository
Production PostgresCommandeRepo
Tests unitaires InMemoryRepo
Migration MongoCommandeRepo
Import legacy CsvCommandeRepo
La métaphore de la prise électrique. La norme de prise est définie par les appareils qui en ont besoin, pas par les fabricants de câbles. De même, ICommandeRepository est défini par le UseCase — pas par PostgreSQL. Stripe dépend du portIPaiementService. Jamais l’inverse.
usecases/ports/ — Interfaces (ports)
// Défini PAR le UseCase. PostgreSQL vient se "brancher" ici. // Ce fichier APPARTIENT au domaine — pas à l'infrastructure. export interface ICommandeRepository { sauvegarder(commande: Commande): Promise<void>; trouverParId(id: CommandeId): Promise<Commande | null>; trouverParClient(clientId: ClientId): Promise<Commande[]>; } export interface IPaiementService { debiter(montant: Montant, client: Client): Promise<TransactionId>; rembourser(transactionId: TransactionId): Promise<void>; } export interface INotificationService { envoyer(destinataire: Email, message: string): Promise<void>; }
05

Structure src/ — Feature “Passer une commande”

Voici comment l’hexagone se traduit en une structure de fichiers concrète pour une feature e-commerce, en suivant exactement le pattern Ports & Adapters.

src/ ├── domain/ ← Noyau — ZERO import externe │ ├── Commande.ts // payer() annuler() totalTTC() — règles métier │ ├── Client.ts // peuxCommander() adresseValide() │ ├── LigneCommande.ts // Value Object — prixTTC() │ ├── Montant.ts // Value Object — ajouter() equals() │ └── StatutCommande.ts // Enum domaine├── usecases/ ← Orchestration + ports │ ├── PasserCommandeUseCase.ts // executer(cmd) — séquence applicative │ ├── AnnulerCommandeUseCase.ts │ └── ports/ ← Interfaces APPARTENANT au domaine │ ├── ICommandeRepository.ts // <<interface>> sauvegarder() trouverParId() │ ├── IPaiementService.ts // <<interface>> debiter() rembourser() │ └── INotificationService.ts // <<interface>> envoyer()└── infrastructure/ ← Tout ce qui est concret et remplaçable ├── http/ ← Adapters PILOTANTS │ ├── CommandeController.ts // @Controller NestJS — traduit HTTP → UseCase │ └── dto/PasserCommandeDTO.ts // @IsNotEmpty — validation HTTP uniquement ├── persistence/ ← Adapters PILOTÉS │ ├── PostgresCommandeRepository.ts // implements ICommandeRepository │ ├── orm/CommandeORM.ts // @Entity TypeORM (≠ Entity domaine) │ └── mapper/CommandeMapper.ts // ORM ↔ Domaine — seul traducteur ├── paiement/ │ └── StripeService.ts // implements IPaiementService ├── notification/ │ └── EmailService.ts // implements INotificationService └── config/ └── CommandeModule.ts // @Module — DI : bind interfaces → implémentations
infrastructure/
Infrastructure — Adapters concrets
CommandeController.tsPostgresCommandeRepo.tsStripeService.tsEmailService.ts
usecases/ports/
Ports — Interfaces propriété du domaine
ICommandeRepository.tsIPaiementService.tsINotificationService.ts
domain/
Domaine — Noyau pur, zero import
Commande.tsClient.tsLigneCommande.ts (VO)Montant.ts (VO)
06

Code couche par couche

domain/Commande.ts — Entity domaine pure
// AUCUN import. TypeScript pur. Instanciable avec new Commande(). export class Commande { private statut: StatutCommande; private readonly lignes: LigneCommande[]; private constructor( private readonly id: CommandeId, private readonly clientId: ClientId, lignes: LigneCommande[] ) { if (lignes.length === 0) throw new Error('Une commande ne peut pas être vide'); this.lignes = lignes; this.statut = StatutCommande.EN_ATTENTE; } // Factory method — règles de création centralisées static creer(clientId: ClientId, lignes: LigneCommande[]): Commande { return new Commande(CommandeId.nouveau(), clientId, lignes); } // Reconstitution depuis la persistance (sans valider à nouveau) static reconstituer(id: string, clientId: string, statut: string, lignes: LigneCommande[]): Commande { const c = new Commande(new CommandeId(id), new ClientId(clientId), lignes); c.statut = StatutCommande[statut as keyof typeof StatutCommande]; return c; } // ── Méthodes métier riches — impossibles avec @Entity() TypeORM ── payer(montant: Montant): void { if (this.statut !== StatutCommande.EN_ATTENTE) throw new CommandeDejaTraiteeError(this.id); if (!montant.equals(this.totalTTC())) throw new MontantIncorrectError(montant, this.totalTTC()); this.statut = StatutCommande.PAYEE; } annuler(): void { if (this.statut === StatutCommande.PAYEE) throw new Error('Impossible d\'annuler une commande payée'); this.statut = StatutCommande.ANNULEE; } totalTTC(): Montant { return this.lignes.reduce((acc, l) => acc.ajouter(l.prixTTC()), Montant.zero()); } getId(): CommandeId { return this.id; } getStatut(): StatutCommande { return this.statut; } getLignes(): ReadonlyArray<LigneCommande> { return [...this.lignes]; } }
usecases/PasserCommandeUseCase.ts — Use Case
// Le UseCase ne connaît QUE des interfaces (ports). // Il ignore si la DB est PostgreSQL, MongoDB ou en mémoire. export class PasserCommandeUseCase { constructor( private readonly repo: ICommandeRepository, // ← interface, pas PostgreSQL private readonly paiement: IPaiementService, // ← interface, pas Stripe private readonly notif: INotificationService // ← interface, pas SMTP ) {} async executer(cmd: PasserCommandeCommand): Promise<CommandeId> { // 1. Récupérer les entités du domaine via le port const client = await this.repo.trouverClientParId(cmd.clientId); if (!client) throw new Error('Client introuvable'); // 2. Créer l'entité domaine — règle métier pure const commande = Commande.creer(client.getId(), cmd.lignes); // 3. Persister via le port (≠ TypeORM) await this.repo.sauvegarder(commande); // 4. Paiement via le port (≠ Stripe SDK) await this.paiement.debiter(commande.totalTTC(), client); commande.payer(commande.totalTTC()); // règle métier : transition de statut await this.repo.sauvegarder(commande); // maj du statut // 5. Notification via le port (≠ SMTP) await this.notif.envoyer(client.getEmail(), `Commande ${commande.getId()} confirmée`); return commande.getId(); } }
infrastructure/persistence/PostgresCommandeRepository.ts — Driven Adapter
// SEUL fichier qui connaît TypeORM. Implémente le contrat du domaine. export class PostgresCommandeRepository implements ICommandeRepository { constructor(private readonly repo: Repository<CommandeORM>) {} async sauvegarder(commande: Commande): Promise<void> { const row = CommandeMapper.versORM(commande); // Entity domaine → ligne SQL await this.repo.save(row); } async trouverParId(id: CommandeId): Promise<Commande | null> { const row = await this.repo.findOneBy({ id: id.valeur }); return row ? CommandeMapper.versDomaine(row) : null; // ligne SQL → Entity domaine } }
infrastructure/http/CommandeController.ts — Driving Adapter
// Traduit HTTP → langage du domaine. Zéro logique métier ici. @Controller('/commandes') export class CommandeController { constructor(private readonly useCase: PasserCommandeUseCase) {} @Post() @UsePipes(new ValidationPipe()) async passerCommande(@Body() dto: PasserCommandeDTO) { // 1. Traduire DTO HTTP → Command du domaine const command = new PasserCommandeCommand( new ClientId(dto.clientId), dto.lignes.map(l => new LigneCommande(l.produitId, l.quantite, l.prixUnitaire)) ); // 2. Déléguer au UseCase — le seul rôle de ce controller const commandeId = await this.useCase.executer(command); // 3. Traduire résultat domaine → réponse HTTP return { commandeId: commandeId.valeur, status: 'created' }; } }
07

Entities NestJS/TypeORM vs Entities Domaine

C’est la source de confusion la plus fréquente. Une @Entity() TypeORM n’est pas une Entity au sens hexagonal. Cockburn ne dit pas “n’utilisez aucun framework” — il dit “le domaine ne doit pas dépendre d’un framework”. La solution : deux modèles séparés et un Mapper pour traduire entre les deux.

❌ Anti-pattern — Entity = ORM
Imports depuis TypeORM/NestJS en tête de fichier
Décorateurs @Entity @Column partout
Sac de données — aucune méthode métier
Le schéma DB dicte la structure objet
Impossible de tester sans démarrer une DB
Le domaine fuit dans l’infrastructure
✓ Double modèle — Entity + ORM séparés
Zero import — TypeScript pur
Propriétés privées, encapsulation réelle
Méthodes métier : payer() annuler() totalTTC()
Structure dictée par le métier, pas la DB
new Commande() suffit pour tester
Un Mapper traduit entre les deux mondes
infrastructure/persistence/orm/CommandeORM.ts — Modèle ORM
// Ce fichier VIT dans infrastructure/. Jamais dans domain/. // C'est un sac de données plat pour TypeORM — aucune logique métier. import { Entity, PrimaryColumn, Column, CreateDateColumn } from 'typeorm'; @Entity('commandes') export class CommandeORM { @PrimaryColumn('uuid') id: string; @Column({ name: 'client_id' }) clientId: string; @Column({ type: 'varchar', length: 20 }) statut: string; @Column({ type: 'jsonb' }) lignes: Array<{ produitId: string; quantite: number; prixUnitaire: number }>; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; // Aucune méthode métier. Ce n'est pas son rôle. }
infrastructure/persistence/mapper/CommandeMapper.ts — Traducteur
// Le seul endroit qui connaît les deux modèles. // Si tu changes de schéma DB, tu ne modifies que ce fichier. export class CommandeMapper { static versORM(commande: Commande): CommandeORM { const orm = new CommandeORM(); orm.id = commande.getId().valeur; orm.clientId = commande.getClientId().valeur; orm.statut = commande.getStatut().toString(); orm.lignes = commande.getLignes().map(l => ({ produitId: l.getProduitId(), quantite: l.getQuantite(), prixUnitaire: l.getPrixUnitaire().valeurCentimes })); return orm; } static versDomaine(row: CommandeORM): Commande { const lignes = row.lignes.map(l => LigneCommande.reconstituer(l.produitId, l.quantite, l.prixUnitaire) ); return Commande.reconstituer(row.id, row.clientId, row.statut, lignes); } }
08

NestJS dans l’hexagone — Où placer quoi ?

NestJS est un framework d’infrastructure. Ses décorateurs (@Controller, @Module, @Entity) appartiennent à la couche externe. Voici comment les placer dans l’hexagone sans polluer le domaine.

Élément NestJSPlacementTypeNote
@Controllerinfrastructure/http/DRIVING ADAPTERTraduit HTTP → Command du domaine
@Injectable() Serviceusecases/USE CASEAcceptable si @Injectable() est le seul décorateur NestJS
@Entity() TypeORMinfrastructure/persistence/orm/ORM ONLYJamais dans domain/ — modèle de persistance uniquement
Entity domainedomain/DOMAINTypeScript pur, zero import, méthodes métier riches
Repository interfaceusecases/ports/PORTDéfini par le domaine, implémenté par l’infra
Repository implinfrastructure/persistence/DRIVEN ADAPTERimplements ICommandeRepository
@Module()infrastructure/config/DI WIRINGRelie interfaces → implémentations concrètes
Mapper ORM ↔ Domaineinfrastructure/persistence/mapper/TRANSLATORSeul fichier qui connaît les deux mondes
⚠️

Le compromis @Injectable()

Utiliser @Injectable() sur un Use Case est un compromis acceptable. C’est le seul décorateur NestJS qui touche le domaine. Il n’ajoute aucune dépendance runtime — c’est du metadata pour le container DI. Si cela vous dérange, vous pouvez enregistrer le UseCase manuellement dans le Module via useFactory.

infrastructure/config/CommandeModule.ts — DI Wiring
// Le seul endroit qui sait tout. Relie les interfaces aux implémentations. @Module({ imports: [TypeOrmModule.forFeature([CommandeORM])], providers: [ // Bind interface → implémentation concrète { provide: 'ICommandeRepository', useClass: PostgresCommandeRepository }, { provide: 'IPaiementService', useClass: StripeService }, { provide: 'INotificationService', useClass: EmailService }, PasserCommandeUseCase, AnnulerCommandeUseCase, ], controllers: [CommandeController], }) export class CommandeModule {} // Pour les tests : swap sans toucher au UseCase // { provide: 'ICommandeRepository', useClass: InMemoryCommandeRepository } // { provide: 'IPaiementService', useClass: MockPaiementService }
09

Le test ultime d’un hexagone réussi

Cockburn propose trois questions diagnostiques pour évaluer la qualité d’un hexagone. Si l’une échoue, un port est mal défini ou un adapter fuit dans le domaine.

🧪
Test sans infrastructure
Tester sans infrastructure
Peut-on écrire new PasserCommandeUseCase(fakeRepo, fakePaiement, fakeNotif) et tester une règle métier sans DB, sans NestJS, sans HTTP ? Si oui, l’hexagone est propre.
Jest purZero infra
🔄
Test swap DB
Remplacer la base de données
Si on remplace PostgreSQL par MongoDB, combien de fichiers sont modifiés ? En hexagonale : exactement 1 — l’adapter. Le UseCase et le domaine restent intacts.
1 seul fichierDomain intact
🌐
Test swap framework
Remplacer NestJS par Express
Si on change de framework HTTP, le domaine métier est-il touché ? Non. Seul le driving adapter change. Use Cases, Entities et ports restent identiques.
Framework agnostiqueDomaine stable
Test unitaire pur — zero infrastructure
// Ce test ne nécessite aucune DB, aucun NestJS, aucune connexion réseau. // InMemoryRepo et MockPaiement implémentent les ports du domaine. import { PasserCommandeUseCase } from './usecases/PasserCommandeUseCase'; import { InMemoryCommandeRepository } from './fakes/InMemoryCommandeRepository'; import { MockPaiementService } from './fakes/MockPaiementService'; it('passe une commande et la marque payée', async () => { // Arrange — fakes qui implémentent les ports du domaine const repo = new InMemoryCommandeRepository(); const paiement = new MockPaiementService(); const notif = new MockNotificationService(); // Instanciation SANS NestJS — preuve que le UseCase est agnostique const useCase = new PasserCommandeUseCase(repo, paiement, notif); // Act const commandeId = await useCase.executer({ clientId: ClientId.de('client-42'), lignes: [{ produitId: 'p1', quantite: 2, prixUnitaire: 50 }] }); // Assert — on teste le comportement, pas l'implémentation expect(repo.commandes).toHaveLength(1); expect(repo.commandes[0].getStatut()).toBe(StatutCommande.PAYEE); expect(paiement.debitsEffectues).toHaveLength(1); expect(notif.messagesEnvoyes).toHaveLength(1); }); it('refuse une commande vide', () => { // Tester la règle métier directement sur l'Entity — encore plus direct expect(() => Commande.creer(ClientId.de('c1'), [])) .toThrow('Une commande ne peut pas être vide'); });
Si ce test nécessite docker-compose up — si vous devez lancer PostgreSQL, démarrer NestJS ou appeler Stripe pour tester une règle métier — c’est un signal clair : un adapter a fuité dans le domaine, ou un port est mal défini.
10

Hexagonale, Clean Architecture, Onion — Les correspondances

Ces trois architectures partagent le même ADN : isoler le domaine métier et inverser les dépendances. Elles diffèrent par la terminologie et la granularité des couches, mais le principe fondamental est identique.

Couche extérieure
Infrastructure / Frameworks
Hexagonale : AdaptersClean : Frameworks + AdaptersOnion : Infrastructure
Ports / Interfaces
Contrats entre couches
Hexagonale : PortsClean : Use Case BoundariesOnion : Service Interfaces
Cas d’utilisation
Application Services / Use Cases
Hexagonale : Use CasesClean : InteractorsOnion : Application Services
Noyau
Domaine métier pur
Hexagonale : DomainClean : EntitiesOnion : Domain Model
La spécificité de l’hexagonale est son vocabulaire explicite autour des ports et adapters, et sa distinction claire entre driving (qui pilote) et driven (qui est piloté). Clean Architecture se concentre davantage sur les cercles concentriques et la Dependency Rule. Onion Architecture met l’accent sur les couches de services autour du Domain Model.
11

Résumé — Les règles en une page

1. Les dépendances pointent vers le centre. L’infrastructure dépend du domaine. Jamais l’inverse.

2. Le domaine définit les ports. Les interfaces appartiennent au domaine, pas à l’infrastructure.

3. Les adapters implémentent les ports. Chaque adapter est remplaçable sans toucher au domaine.

4. Driving ≠ Driven. Les adapters pilotants (gauche) déclenchent les Use Cases. Les adaptés (droite) sont appelés par eux.

5. Le test est la preuve. Si votre UseCase fonctionne avec des fakes en mémoire, sans infrastructure, l’hexagone est propre.