Le problème
Beaucoup d’équipes comprennent l’idée générale de l’architecture hexagonale, mais bloquent sur la partie la plus concrète :
- qu’est-ce qu’un port exactement
- qu’est-ce qu’un adapter exactement
- où placer les interfaces
- qui possède les contrats
- quelle logique doit vivre dans chaque partie
Sans réponse claire, on tombe vite dans deux erreurs :
- tout devient “port” ou “adapter”, donc les mots ne veulent plus rien dire
- les règles métier restent quand même collées au framework, à la base ou au transport
Résultat : on croit avoir une architecture hexagonale, mais on a juste ajouté des interfaces autour d’un code toujours couplé à la technique.
Le but de ce sujet est de clarifier le mécanisme central.
L’idée simple
Un port est un contrat qui définit une interaction avec le cœur du système.
Un adapter est une implémentation technique qui branche cette interaction sur le monde réel.
En pratique :
- le cœur métier ou la couche application définit les ports
- les adapters traduisent les détails techniques vers ces ports ou depuis ces ports
Autrement dit :
- les ports définissent ce qu’on veut faire
- les adapters gèrent comment ça passe concrètement
C’est ce découplage qui permet d’isoler le métier.
Comment ça fonctionne
Pour que le modèle reste simple, il faut distinguer deux directions.
Ports entrants
Un port entrant décrit ce que le système sait faire.
C’est l’entrée fonctionnelle du système.
Exemples :
- créer une commande
- annuler un abonnement
- calculer un devis
- valider un paiement
Un port entrant est souvent représenté par :
- une interface de use case
- une commande d’entrée
- un résultat métier ou applicatif
Ce port est appelé depuis un adapter entrant.
Adapters entrants
Un adapter entrant prend une interaction externe et la traduit vers le port entrant.
Exemples courants :
- controller HTTP
- resolver GraphQL
- handler CLI
- consumer de message
- job scheduler
Son rôle :
- lire les données externes
- les transformer dans un format attendu par le use case
- appeler le port entrant
- transformer le résultat vers un format de sortie
Ce qu’il ne doit pas faire :
- porter les règles métier
- décider des invariants
- gérer les transitions métier importantes
Ports sortants
Un port sortant décrit ce dont le cœur a besoin pour faire son travail.
Exemples :
- charger un agrégat
- sauvegarder une commande
- débiter un paiement
- publier un événement
- appeler un service externe
Le cœur ne parle pas directement à PostgreSQL, Stripe ou Kafka. Il parle à des contrats qui expriment le besoin métier ou applicatif.
Adapters sortants
Un adapter sortant implémente un port sortant avec une technologie réelle.
Exemples :
- repository PostgreSQL
- client Stripe
- publisher Kafka
- adaptateur S3
- client Redis
Son rôle :
- parler au système externe
- gérer les formats, protocoles, erreurs techniques, retries si nécessaire
- mapper les données externes vers des objets exploitables par le domaine
Ce qu’il ne doit pas faire :
- réécrire la logique métier
- imposer son modèle technique au domaine
Ce schéma donne la lecture correcte :
- les entrées passent par des adapters entrants
- le cœur exécute la logique utile
- les sorties passent par des ports sortants puis des adapters sortants
Exemple concret
Prenons un cas simple : créer un abonnement payant.
Le système doit :
- recevoir une demande de création
- vérifier que l’offre existe
- créer l’abonnement
- enregistrer l’abonnement
- déclencher la facturation
Port entrant
export interface CreateSubscriptionUseCase {
execute(command: CreateSubscriptionCommand): Promise<CreateSubscriptionResult>;
}
export type CreateSubscriptionCommand = {
customerId: string;
planCode: string;
};
export type CreateSubscriptionResult = {
subscriptionId: string;
status: "active";
};
Ici, le port entrant décrit une capacité du système : créer un abonnement.
Ports sortants
export interface PlanCatalog {
findByCode(planCode: string): Promise<Plan | null>;
}
export interface SubscriptionRepository {
save(subscription: Subscription): Promise<void>;
}
export interface BillingGateway {
startSubscriptionBilling(subscription: Subscription): Promise<void>;
}
Ici, les ports sortants décrivent les dépendances nécessaires au use case :
- accéder au catalogue
- persister l’abonnement
- déclencher la facturation
Implémentation du use case
export class CreateSubscriptionService implements CreateSubscriptionUseCase {
constructor(
private readonly planCatalog: PlanCatalog,
private readonly subscriptionRepository: SubscriptionRepository,
private readonly billingGateway: BillingGateway
) {}
async execute(
command: CreateSubscriptionCommand
): Promise<CreateSubscriptionResult> {
const plan = await this.planCatalog.findByCode(command.planCode);
if (!plan) {
throw new Error("Plan not found");
}
const subscription = Subscription.create({
customerId: command.customerId,
planCode: plan.code,
});
await this.subscriptionRepository.save(subscription);
await this.billingGateway.startSubscriptionBilling(subscription);
return {
subscriptionId: subscription.id,
status: "active",
};
}
}
Ici, le use case dépend uniquement des ports. Il ne connaît ni Express, ni PostgreSQL, ni Stripe.
Adapter entrant HTTP
import type { Request, Response } from "express";
export class CreateSubscriptionController {
constructor(
private readonly createSubscriptionUseCase: CreateSubscriptionUseCase
) {}
async handle(req: Request, res: Response): Promise<void> {
const result = await this.createSubscriptionUseCase.execute({
customerId: req.body.customerId,
planCode: req.body.planCode,
});
res.status(201).json(result);
}
}
Le controller :
- lit la requête HTTP
- construit la commande attendue
- appelle le use case
- formate la réponse
Il ne décide pas de la logique d’abonnement.
Adapter sortant PostgreSQL
export class PgSubscriptionRepository implements SubscriptionRepository {
async save(subscription: Subscription): Promise<void> {
// Mapping domaine -> persistence
// INSERT ou UPDATE SQL
}
}
Adapter sortant Stripe
export class StripeBillingGateway implements BillingGateway {
async startSubscriptionBilling(subscription: Subscription): Promise<void> {
// Appel API Stripe
}
}
Ces classes implémentent les ports sortants. Elles savent parler à une technologie concrète, mais le domaine n’en dépend pas.
Ports entrants et ports sortants
La confusion la plus fréquente vient du fait qu’on met tout dans le même sac.
Voici la distinction utile :
Port entrant
Définit une capacité offerte par le système.
Exemples :
CreateSubscriptionUseCaseCancelOrderUseCaseGenerateInvoiceUseCase
Adapter entrant
Traduit une entrée réelle vers ce port.
Exemples :
CreateSubscriptionControllerCancelOrderMessageHandlerGenerateInvoiceCliHandler
Port sortant
Définit un besoin du cœur.
Exemples :
SubscriptionRepositoryBillingGatewayInvoicePublisher
Adapter sortant
Implémente ce besoin avec une technologie réelle.
Exemples :
PgSubscriptionRepositoryStripeBillingGatewayKafkaInvoicePublisher
Cette distinction suffit dans la majorité des projets.
Où placer les ports
Règle simple :
- les ports entrants vivent souvent dans la couche application
- les ports sortants vivent là où le besoin est défini, souvent application ou domaine
- les adapters vivent dans l’infrastructure ou dans les bords du système
Exemple de structure simple :
src/
subscription/
application/
create-subscription/
create-subscription.use-case.ts
create-subscription.command.ts
create-subscription.result.ts
billing-gateway.port.ts
subscription-repository.port.ts
domain/
subscription.ts
plan.ts
infrastructure/
http/
create-subscription.controller.ts
persistence/
pg-subscription-repository.ts
billing/
stripe-billing-gateway.ts
Le point important n’est pas le nom exact des dossiers. Le point important est de garder visible :
- le cœur
- les contrats
- les détails techniques
Points importants
- Un port est un contrat, pas un synonyme d’interface technique générique.
- Un adapter est une traduction, pas un endroit pour loger la logique métier.
- Les ports entrants expriment ce que le système sait faire.
- Les ports sortants expriment ce dont le système a besoin.
- Les adapters permettent de changer de technologie sans redessiner le cœur métier.
- Les ports doivent être introduits sur de vraies frontières, pas partout par réflexe.
Erreurs fréquentes
Mettre les ports dans l’infrastructure
Si un port est défini à côté d’une implémentation PostgreSQL ou Stripe, l’infrastructure finit souvent par dicter le contrat.
Le bon sens est inverse : le besoin vient du cœur, donc le contrat doit vivre près du cœur.
Faire des adapters trop intelligents
Un controller qui valide les règles métier ou un repository qui décide si un abonnement est activable cassent la séparation.
Les adapters traduisent. Le métier décide.
Nommer “adapter” n’importe quelle classe
Une classe n’est pas un adapter juste parce qu’elle appelle une autre classe. Un adapter existe quand il connecte une frontière réelle :
- HTTP
- base de données
- message broker
- service externe
- système de fichiers
Créer des ports sans vraie frontière
Une interface entre deux classes purement internes n’apporte pas forcément de valeur.
Un port devient utile quand :
- il protège le cœur
- il isole une dépendance externe
- il formalise une capacité du système
Exposer les types techniques au cœur
Si le domaine manipule directement :
RequestResponse- entités ORM
- DTO Stripe
- messages Kafka bruts
alors la frontière est déjà percée.
Le cœur doit manipuler ses propres types.
Conclusion
Les ports et adapters sont le mécanisme qui rend l’architecture hexagonale concrète.
Les ports définissent les frontières utiles. Les adapters relient ces frontières au monde réel. Le cœur garde la logique métier. La technique reste au bord.
Le réflexe à retenir est simple :
les ports expriment l’intention, les adapters absorbent la technique.