03 - Les ports : les interfaces du domaine
Ce que tu vas apprendre
- Ce qu'est un port en architecture hexagonale
- La différence entre ports entrants et ports sortants
- Comment définir des ports propres en TypeScript
- Pourquoi le principe d'inversion de dépendance change tout
Prerequisites
Un port, c'est une interface. C'est tout.
Pas besoin de librairie. Pas besoin de decorateur magique. Pas besoin d'un framework d'injection de dépendances. Un port en architecture hexagonale, c'est une interface TypeScript. Deux mots-clés : interface et export.
Toute la puissance de l'architecture hexagonale repose sur cette simplicité. Le domaine definit des contrats. Le monde extérieur les respecte. Si tu comprends les interfaces TypeScript, tu comprends les ports.
Les ports entrants (driving ports)
Les ports entrants definissent ce que le monde extérieur peut demander a ton application. En pratique, ce sont tes cas d'usage. Chaque action disponible correspond a un port entrant.
typescript// ports/inbound/CreateOrderPort.ts
interface CreateOrderCommand {
customerId: string;
items: Array<{
productId: string;
name: string;
quantity: number;
unitPrice: number;
}>;
}
interface CreateOrderResult {
orderId: string;
totalPrice: number;
status: string;
}
interface CreateOrderPort {
execute(command: CreateOrderCommand): Promise<CreateOrderResult>;
}
typescript// ports/inbound/GetOrderPort.ts
interface GetOrderPort {
execute(orderId: string): Promise<OrderView | null>;
}
interface OrderView {
id: string;
customerId: string;
items: ReadonlyArray<{
productId: string;
name: string;
quantity: number;
unitPrice: number;
}>;
totalPrice: number;
status: string;
createdAt: Date;
}
typescript// ports/inbound/CancelOrderPort.ts
interface CancelOrderPort {
execute(orderId: string): Promise<void>;
}
Remarque le pattern : chaque port a une méthode execute avec des types d'entree et de sortie bien définis. Pas de req, pas de res, pas de ctx. Les ports ne savent pas d'ou vient la requête. Un controller Express, une commande CLI, un resolver GraphQL, un test unitaire : tous appellent execute() de la meme facon.
Je déconseillé de mettre plusieurs méthodes dans un seul port. Un port = un cas d'usage. Ca garde les choses simples et ca respecte le principe de responsabilité unique.
Les ports sortants (driven ports)
Les ports sortants definissent ce dont le domaine a besoin du monde extérieur. C'est ici que l'inversion de dépendance entre en jeu.
Dans un code classique, ton service appelle directement Prisma :
typescript// Approche classique (couplee)
import { prisma } from "../db";
async function createOrder(data: CreateOrderData) {
return prisma.order.create({ data });
}
Avec l'architecture hexagonale, le domaine definit un port :
typescript// ports/outbound/OrderRepositoryPort.ts
interface OrderRepositoryPort {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomerId(customerId: string): Promise<Order[]>;
delete(id: string): Promise<void>;
}
typescript// ports/outbound/PaymentGatewayPort.ts
interface PaymentGatewayPort {
charge(params: {
orderId: string;
amount: number;
currency: string;
customerId: string;
}): Promise<PaymentResult>;
}
interface PaymentResult {
success: boolean;
transactionId: string | null;
errorMessage: string | null;
}
typescript// ports/outbound/EmailSenderPort.ts
interface EmailSenderPort {
sendOrderConfirmation(params: {
to: string;
orderId: string;
totalPrice: number;
}): Promise<void>;
}
Le domaine dit "j'ai besoin de sauvegarder une commande" via OrderRepositoryPort. Il dit "j'ai besoin d'encaisser un paiement" via PaymentGatewayPort. Mais il ne dit jamais comment. PostgreSQL, MongoDB, un fichier JSON, de la mémoire vive : le domaine s'en fiche complètement.
L'inversion de dépendance en pratique
Dans un code traditionnel, la flèche de dépendance suit le flux :
Controller --> Service --> Repository --> PostgreSQL
Le service connaît le repository, le repository connaît PostgreSQL. Si tu changes PostgreSQL, tu changes le repository, et potentiellement le service.
Avec les ports, on inverse la flèche cote infrastructure :
Controller --> [Port entrant] <-- UseCase --> [Port sortant] <-- PostgresAdapter
Le use case depend du port (une interface). L'adaptateur PostgreSQL depend aussi du port (il l'implemente). Personne ne depend de PostgreSQL directement sauf l'adaptateur.
Résultat : changer de base de donnees, c'est écrire un nouvel adaptateur. Le use case ne change pas. Les tests ne changent pas. Les controllers ne changent pas.
C'est le meme principe d'inversion de dépendance qu'on retrouve en domain-driven design. Si tu as suivi la serie Domaines et cycles de vie, tu vois le lien direct.
Les erreurs classiques
Mettre trop de détails techniques dans les ports. Si ton OrderRepositoryPort a une méthode executeRawQuery(sql: string), ton port fuit. Il expose un détail d'implementation. Un port doit parler en termes métier.
Faire des ports trop génériques. Un DatabasePort avec query(), insert(), update(), delete() n'est pas un port, c'est un wrapper de base de donnees. Chaque port doit correspondre a un besoin métier precis.
Oublier les types de retour. Un port qui retourne any ou Promise<any> détruit tout l'interet du typage. Definis des types de retour explicites, meme si ca prend 5 lignes de plus.
Sur paltemps.fr, on a une regle simple : si tu dois expliquer ce que fait un port en plus d'une phrase, il est trop complexe. Decoupe-le.
Résumé
- Un port est une interface TypeScript, rien de plus
- Les ports entrants definissent les cas d'usage (ce que le monde peut demander)
- Les ports sortants definissent les besoins du domaine (ce dont il a besoin du monde)
- L'inversion de dépendance fait que le domaine ne depend jamais de l'infrastructure
- Un port = un besoin métier, pas un wrapper technique
Article précédent : 02 - Le domaine
Article suivant : 04 - Les adaptateurs : brancher le monde réel