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
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 interfaceICommandeRepository {
sauvegarder(commande: Commande): Promise<void>;
trouverParId(id: CommandeId): Promise<Commande | null>;
trouverParClient(clientId: ClientId): Promise<Commande[]>;
}
export interfaceIPaiementService {
debiter(montant: Montant, client: Client): Promise<TransactionId>;
rembourser(transactionId: TransactionId): Promise<void>;
}
export interfaceINotificationService {
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.
// AUCUN import. TypeScript pur. Instanciable avec new Commande().export classCommande {
private statut: StatutCommande;
private readonly lignes: LigneCommande[];
private constructor(
private readonly id: CommandeId,
private readonly clientId: ClientId,
lignes: LigneCommande[]
) {
if (lignes.length === 0)
throw newError('Une commande ne peut pas être vide');
this.lignes = lignes;
this.statut = StatutCommande.EN_ATTENTE;
}
// Factory method — règles de création centraliséesstaticcreer(clientId: ClientId, lignes: LigneCommande[]): Commande {
return newCommande(CommandeId.nouveau(), clientId, lignes);
}
// Reconstitution depuis la persistance (sans valider à nouveau)staticreconstituer(id: string, clientId: string, statut: string, lignes: LigneCommande[]): Commande {
const c = newCommande(newCommandeId(id), newClientId(clientId), lignes);
c.statut = StatutCommande[statut askeyof typeofStatutCommande];
return c;
}
// ── Méthodes métier riches — impossibles avec @Entity() TypeORM ──payer(montant: Montant): void {
if (this.statut !== StatutCommande.EN_ATTENTE)
throw newCommandeDejaTraiteeError(this.id);
if (!montant.equals(this.totalTTC()))
throw newMontantIncorrectError(montant, this.totalTTC());
this.statut = StatutCommande.PAYEE;
}
annuler(): void {
if (this.statut === StatutCommande.PAYEE)
throw newError('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 classPasserCommandeUseCase {
constructor(
private readonly repo: ICommandeRepository, // ← interface, pas PostgreSQLprivate readonly paiement: IPaiementService, // ← interface, pas Stripeprivate readonly notif: INotificationService// ← interface, pas SMTP
) {}
asyncexecuter(cmd: PasserCommandeCommand): Promise<CommandeId> {
// 1. Récupérer les entités du domaine via le portconst client = await this.repo.trouverClientParId(cmd.clientId);
if (!client) throw newError('Client introuvable');
// 2. Créer l'entité domaine — règle métier pureconst 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 statutawait 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();
}
}
// Traduit HTTP → langage du domaine. Zéro logique métier ici.@Controller('/commandes')
export classCommandeController {
constructor(private readonly useCase: PasserCommandeUseCase) {}
@Post()
@UsePipes(newValidationPipe())
asyncpasserCommande(@Body() dto: PasserCommandeDTO) {
// 1. Traduire DTO HTTP → Command du domaineconst command = newPasserCommandeCommand(
newClientId(dto.clientId),
dto.lignes.map(l => newLigneCommande(l.produitId, l.quantite, l.prixUnitaire))
);
// 2. Déléguer au UseCase — le seul rôle de ce controllerconst commandeId = await this.useCase.executer(command);
// 3. Traduire résultat domaine → réponse HTTPreturn { 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.
// Le seul endroit qui connaît les deux modèles.// Si tu changes de schéma DB, tu ne modifies que ce fichier.export classCommandeMapper {
staticversORM(commande: Commande): CommandeORM {
const orm = newCommandeORM();
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;
}
staticversDomaine(row: CommandeORM): Commande {
const lignes = row.lignes.map(l =>
LigneCommande.reconstituer(l.produitId, l.quantite, l.prixUnitaire)
);
returnCommande.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 NestJS
Placement
Type
Note
@Controller
infrastructure/http/
DRIVING ADAPTER
Traduit HTTP → Command du domaine
@Injectable() Service
usecases/
USE CASE
Acceptable si @Injectable() est le seul décorateur NestJS
@Entity() TypeORM
infrastructure/persistence/orm/
ORM ONLY
Jamais dans domain/ — modèle de persistance uniquement
Entity domaine
domain/
DOMAIN
TypeScript pur, zero import, méthodes métier riches
Repository interface
usecases/ports/
PORT
Défini par le domaine, implémenté par l’infra
Repository impl
infrastructure/persistence/
DRIVEN ADAPTER
implements ICommandeRepository
@Module()
infrastructure/config/
DI WIRING
Relie interfaces → implémentations concrètes
Mapper ORM ↔ Domaine
infrastructure/persistence/mapper/
TRANSLATOR
Seul 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 classCommandeModule {}
// 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 domaineconst repo = newInMemoryCommandeRepository();
const paiement = newMockPaiementService();
const notif = newMockNotificationService();
// Instanciation SANS NestJS — preuve que le UseCase est agnostiqueconst useCase = newPasserCommandeUseCase(repo, paiement, notif);
// Actconst commandeId = await useCase.executer({
clientId: ClientId.de('client-42'),
lignes: [{ produitId: 'p1', quantite: 2, prixUnitaire: 50 }]
});
// Assert — on teste le comportement, pas l'implémentationexpect(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 directexpect(() => 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.
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.