Ports and Adapters

Comprendre le rôle des ports entrants et sortants, et comment les adapters relient le domaine au monde extérieur sans le contaminer.

← Retour au concept parent

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
Ports et adapters dans l'architecture hexagonale

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 :

  • CreateSubscriptionUseCase
  • CancelOrderUseCase
  • GenerateInvoiceUseCase

Adapter entrant

Traduit une entrée réelle vers ce port.

Exemples :

  • CreateSubscriptionController
  • CancelOrderMessageHandler
  • GenerateInvoiceCliHandler

Port sortant

Définit un besoin du cœur.

Exemples :

  • SubscriptionRepository
  • BillingGateway
  • InvoicePublisher

Adapter sortant

Implémente ce besoin avec une technologie réelle.

Exemples :

  • PgSubscriptionRepository
  • StripeBillingGateway
  • KafkaInvoicePublisher

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 :

  • Request
  • Response
  • 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.