Hexagonal / Clean Architecture

Trois noms circulent pour à peu près la même idée. Ports et adaptateurs, proposé par Alistair Cockburn en 2005 — l’« hexagone » n’a jamais eu six côtés pour une raison particulière, il s’agissait seulement de ne pas dessiner un rectangle avec un haut et un bas. Onion architecture, formulée par Jeffrey Palermo en 2008. Clean architecture, popularisée par Robert C. Martin en 2012. Leurs différences réelles sont traitées en fin de page ; elles sont plus faibles qu’on ne le croit.

Les trois répondent au même défaut de l’architecture en couches : la logique métier y dépend de l’infrastructure, c’est-à-dire que la partie stable dépend de la partie volatile. Et les trois appliquent le même remède, qui n’est rien d’autre que le principe d’inversion des dépendances érigé en règle d’architecture.

La règle de dépendance

Toute dépendance du code source pointe vers l’intérieur. Rien de ce qui est à l’intérieur ne connaît quoi que ce soit de l’extérieur.

                    ┌─────────────────────────────┐
                    │   Adaptateurs entrants      │
                    │   HTTP · CLI · file · cron  │
                    │  ┌───────────────────────┐  │
                    │  │   Ports d'entrée      │  │
                    │  │  ┌─────────────────┐  │  │
                    │  │  │     Domaine     │  │  │
                    │  │  │ entités, règles │  │  │
                    │  │  └─────────────────┘  │  │
                    │  │   Ports de sortie     │  │
                    │  └───────────────────────┘  │
                    │   Adaptateurs sortants      │
                    │   SQL · SMTP · S3 · API     │
                    └─────────────────────────────┘
                         ────►  vers l'intérieur

Le domaine ne contient aucun import d’infrastructure : ni ORM, ni client HTTP, ni annotation de framework. C’est une contrainte vérifiable — et c’est le seul test qui compte vraiment pour savoir si l’architecture est respectée.

Tip

Cette vérification s’automatise, et l’on parle alors de tests d’architecture — ou de fitness functions. ArchUnit en Java, import-linter en Python, dependency-cruiser en TypeScript, ou simplement les frontières de crates et la visibilité pub(crate) en Rust : la règle de dépendance cesse d’être une intention pour devenir un test qui échoue.

Ports et adaptateurs

Un port est une interface définie par le domaine. Un adaptateur est l’implémentation qui la relie au monde réel. Il en existe deux sortes, et les confondre est l’erreur la plus fréquente.

Port d’entrée (driving)Port de sortie (driven)
Question poséeQue sait faire l’application ?De quoi l’application a-t-elle besoin ?
Qui appelleL’adaptateur appelle le domaineLe domaine appelle l’adaptateur
FormeCas d’usage, service applicatifInterface de dépôt, de notification
Adaptateurs typiquesContrôleur HTTP, CLI, consommateur de fileRepository SQL, client SMTP, client d’API
Qui l’implémenteLe domaineL’infrastructure

Les deux sont des interfaces appartenant à l’intérieur. La différence est le sens de l’appel : un port d’entrée est offert par le domaine, un port de sortie est exigé par lui. C’est ce second cas qui réalise l’inversion, et qui explique pourquoi l’interface CommandeRepository est déclarée dans le domaine et non dans la couche de persistance qui l’implémente.

Chaque élément, un par un

C’est la partie qui manque à la plupart des présentations : savoir ce qu’on met dans chaque case, et surtout ce qu’on n’y met jamais.

Le domaine

Le centre. Il contient tout ce qui resterait vrai si l’application changeait entièrement de technologie.

On y metEntités, objets valeur, agrégats, services de domaine, événements de domaine, erreurs métier, interfaces de ports
On n’y met jamaisUn import d’ORM, de client HTTP, de framework web ; une annotation de sérialisation ; un SELECT ; une URL ; un chemin de fichier ; un appel à l’horloge système
Le testLe dossier compile-t-il en supprimant tout le reste du projet ?

Les erreurs métier y ont leur place, et c’est souvent oublié : PlafondDepasse, ReservationVide sont des concepts du domaine, pas des codes HTTP. La traduction en 409 ou 422 est le travail de l’adaptateur entrant.

Les ports d’entrée — les cas d’usage

Un port d’entrée décrit une chose que l’application sait faire. Sa granularité est celle d’une intention utilisateur, pas celle d’une entité.

On y metLe chargement de l’agrégat, un appel de méthode métier dessus, la sauvegarde, la publication des événements
On n’y met jamaisUne règle métier — pas de if métier ; un objet HTTP ; un type de l’ORM
NommageUn verbe à l’infinitif : ValiderCommande, AnnulerReservation. Jamais CommandeService
Le testSi l’on retire toutes les lignes d’orchestration, reste-t-il une décision ? Si oui, elle est au mauvais endroit
Warning

Le piège le plus courant à ce niveau est le cas d’usage par entité : GererCommande avec dix méthodes. On a alors reconstruit la couche service du 3-tier à l’intérieur de l’hexagone, avec les mêmes pathologies et une indirection de plus. Un cas d’usage, une intention, une méthode publique.

Les ports de sortie

Ce dont le domaine a besoin, exprimé dans son vocabulaire. C’est ici que se joue l’inversion, et c’est ici que se trouvent les ports qu’on oublie.

PortRôlePourquoi c’en est un
DépôtCharger et enregistrer un agrégatÉvident
NotificationPrévenir quelqu’unÉvident
PasserelleInterroger un système tiersÉvident
HorlogeDonner l’instant courantUn Date.now() dans le domaine rend toute règle de date intestable
Générateur d’identifiantsProduire une identitéUn identifiant venu de la base impose de sauvegarder pour connaître l’identité
Générateur d’aléatoireTirage, échantillonnageMême raison que l’horloge
Publication d’événementsDiffuser vers l’extérieurLa cible est une décision d’infrastructure

Les trois lignes en gras sont celles que presque personne ne pose au départ, et qui reviennent toujours. Une règle du type « une réservation provisoire expire au bout de vingt minutes » n’est testable de façon déterministe que si l’instant courant arrive par un port.

Tip

Le nom du port doit décrire un besoin métier, jamais une technologie. NotificationClient et non Mailer ; ReferentielTarifaire et non ClientSoapGescom. Le test est simple : si le nom du port changerait en changeant de fournisseur, c’est que le port est mal nommé.

Les adaptateurs entrants

Ils traduisent une sollicitation extérieure en appel de cas d’usage.

On y metLa désérialisation, la validation de forme, l’authentification, la traduction des erreurs métier en codes de retour, la pagination des réponses
On n’y met jamaisUne règle métier ; un accès direct au dépôt ; un objet du domaine sérialisé tel quel
Un par mode d’accèsHTTP, CLI, consommateur de file, tâche planifiée, test d’acceptation
Le testPeut-on ajouter un second adaptateur — une commande CLI — sans dupliquer une ligne de logique ?

C’est la propriété qui justifie à elle seule l’architecture sur beaucoup de projets : le jour où une file de messages doit déclencher le même traitement que l’API, c’est un fichier de plus et rien d’autre.

Les adaptateurs sortants

Ils implémentent les ports de sortie, et absorbent tout l’écart entre le domaine et le monde réel.

On y metLe SQL ou l’ORM, les clients HTTP, la correspondance domaine ↔ persistance, les reprises sur erreur, les délais d’attente, la traduction des erreurs techniques
On n’y met jamaisUne décision métier ; un if sur un statut métier
Le testLes tests de cet adaptateur peuvent-ils être remplacés par un adaptateur en mémoire sans que le domaine s’en aperçoive ?

Les objets de frontière

Trois représentations du même concept coexistent, et c’est volontaire :

   DTO d'entrée  ──►  objet du domaine  ──►  ligne de base
   (adaptateur       (domaine)               (adaptateur
    entrant)                                  sortant)

Le DTO appartient à l’adaptateur entrant, la ligne à l’adaptateur sortant, et aucun des deux n’entre dans le domaine. C’est le coût le plus critiqué de l’approche — traité plus bas — et c’est aussi ce qui permet de renommer une colonne sans casser un client.

La racine de composition

Le seul endroit qui connaît toutes les implémentations concrètes.

On y metLa lecture de la configuration, l’instanciation des adaptateurs, le câblage, le démarrage du serveur
On n’y met jamaisUne règle métier, une traduction
Signal d’alerteUne racine de deux mille lignes signale un domaine découpé trop fin

Le domaine, en code

Aucune dépendance sortante, aucune annotation, aucun type venu d’une bibliothèque.

​
// domaine/commande.ts — entité
export class Commande {
  private constructor(
    readonly id: CommandeId,
    readonly lignes: readonly Ligne[],
  ) {}

  static creer(id: CommandeId, lignes: Ligne[]): Commande {
    if (lignes.length === 0) throw new CommandeVide();
    return new Commande(id, lignes);
  }

  totalTTC(): Montant {
    return this.lignes.reduce((t, l) => t.plus(l.totalTTC()), Montant.zero());
  }
}

// domaine/ports.ts — ce dont le domaine a besoin (ports de sortie)
export interface CommandeRepository {
  enregistrer(commande: Commande): Promise<void>;
  parId(id: CommandeId): Promise<Commande | undefined>;
}

export interface NotificationClient {
  confirmerCommande(commande: Commande): Promise<void>;
}

export interface Horloge {
  maintenant(): Instant;          // Le port qu'on oublie toujours
}

// domaine/valider-commande.ts — ce que l'application sait faire (port d'entrée)
export class ValiderCommande {
  constructor(
    private readonly repo: CommandeRepository,
    private readonly notification: NotificationClient,
  ) {}

  async executer(commande: Commande): Promise<void> {
    await this.repo.enregistrer(commande);
    await this.notification.confirmerCommande(commande);
  }
}
# domaine/commande.py — entité
from dataclasses import dataclass


@dataclass(frozen=True)
class Commande:
    id: CommandeId
    lignes: tuple[Ligne, ...]

    def __post_init__(self) -> None:
        if not self.lignes:
            raise CommandeVide

    def total_ttc(self) -> Montant:
        return sum((l.total_ttc() for l in self.lignes), Montant.zero())


# domaine/ports.py — ce dont le domaine a besoin (ports de sortie)
from typing import Protocol


class CommandeRepository(Protocol):
    def enregistrer(self, commande: Commande) -> None: ...
    def par_id(self, id: CommandeId) -> Commande | None: ...


class NotificationClient(Protocol):
    def confirmer_commande(self, commande: Commande) -> None: ...


class Horloge(Protocol):
    def maintenant(self) -> Instant: ...      # Le port qu'on oublie toujours


# domaine/valider_commande.py — ce que l'application sait faire (port d'entrée)
class ValiderCommande:
    def __init__(
        self,
        repo: CommandeRepository,
        notification: NotificationClient,
    ) -> None:
        self._repo = repo
        self._notification = notification

    def executer(self, commande: Commande) -> None:
        self._repo.enregistrer(commande)
        self._notification.confirmer_commande(commande)
// domaine/commande.rs — entité
pub struct Commande {
    id: CommandeId,
    lignes: Vec<Ligne>,
}

impl Commande {
    pub fn creer(id: CommandeId, lignes: Vec<Ligne>) -> Result<Self, CommandeVide> {
        if lignes.is_empty() {
            return Err(CommandeVide);
        }
        Ok(Self { id, lignes })
    }

    pub fn total_ttc(&self) -> Montant {
        self.lignes.iter().map(Ligne::total_ttc).sum()
    }
}

// domaine/ports.rs — ce dont le domaine a besoin (ports de sortie)
pub trait CommandeRepository {
    fn enregistrer(&self, commande: &Commande) -> Result<(), ErreurTechnique>;
    fn par_id(&self, id: CommandeId) -> Result<Option<Commande>, ErreurTechnique>;
}

pub trait NotificationClient {
    fn confirmer_commande(&self, commande: &Commande) -> Result<(), ErreurTechnique>;
}

pub trait Horloge {
    fn maintenant(&self) -> Instant;      // Le port qu'on oublie toujours
}

// domaine/valider_commande.rs — ce que l'application sait faire (port d'entrée)
pub struct ValiderCommande<R, N> {
    repo: R,
    notification: N,
}

impl<R: CommandeRepository, N: NotificationClient> ValiderCommande<R, N> {
    pub fn executer(&self, commande: &Commande) -> Result<(), ErreurTechnique> {
        self.repo.enregistrer(commande)?;
        self.notification.confirmer_commande(commande)
    }
}

Le constructeur privé — ou le dataclass(frozen=True), ou le Result du constructeur Rust — n’est pas décoratif : il garantit qu’une Commande invalide n’existe pas. C’est la différence entre une entité et une structure de données, et c’est ce qui rend la validation inutile ailleurs.

Les adaptateurs, en code

Ils dépendent du domaine ; le domaine ne les connaît pas. Un adaptateur sortant implémente un port, un adaptateur entrant appelle un cas d’usage.

​
// infrastructure/postgres/commande.repository.ts — adaptateur sortant
import { Commande, CommandeRepository } from '../../domaine/ports';

export class PostgresCommandeRepository implements CommandeRepository {
  constructor(private readonly pool: Pool) {}

  async enregistrer(commande: Commande): Promise<void> {
    await this.pool.query('INSERT INTO commandes (id, mnt_ttc_cts) VALUES ($1, $2)', [
      commande.id.valeur,
      commande.totalTTC().enCentimes(),      // Traduction domaine → schéma
    ]);
  }

  async parId(id: CommandeId): Promise<Commande | undefined> { /* ... */ }
}

// infrastructure/http/commande.controller.ts — adaptateur entrant
export function controleurCommande(cas: ValiderCommande): RequestHandler {
  return async (req, res) => {
    const commande = Commande.creer(CommandeId.nouveau(), versLignes(req.body));
    await cas.executer(commande);            // Traduction HTTP → domaine
    res.status(201).end();
  };
}
# infrastructure/postgres/commande_repository.py — adaptateur sortant
from domaine.commande import Commande


class PostgresCommandeRepository:
    def __init__(self, session: Session) -> None:
        self._session = session

    def enregistrer(self, commande: Commande) -> None:
        self._session.add(
            CommandeORM(
                id=commande.id.valeur,
                mnt_ttc_cts=commande.total_ttc().en_centimes(),  # Traduction
            )                                                    # domaine → schéma
        )
        self._session.commit()

    def par_id(self, id: CommandeId) -> Commande | None: ...


# infrastructure/http/routes_commande.py — adaptateur entrant
def routeur_commandes(cas: ValiderCommande) -> APIRouter:
    routeur = APIRouter()

    @routeur.post("/commandes", status_code=201)
    def creer(dto: CreerCommandeDto) -> None:
        commande = Commande(CommandeId.nouveau(), dto.vers_lignes())
        cas.executer(commande)               # Traduction HTTP → domaine

    return routeur
// infrastructure/postgres.rs — adaptateur sortant
use crate::domaine::ports::CommandeRepository;

pub struct PostgresCommandeRepository {
    pool: PgPool,
}

impl CommandeRepository for PostgresCommandeRepository {
    fn enregistrer(&self, commande: &Commande) -> Result<(), ErreurTechnique> {
        self.pool.execute(
            "INSERT INTO commandes (id, mnt_ttc_cts) VALUES ($1, $2)",
            &[&commande.id().valeur(),
              &commande.total_ttc().en_centimes()],   // Traduction domaine → schéma
        )?;
        Ok(())
    }

    fn par_id(&self, id: CommandeId) -> Result<Option<Commande>, ErreurTechnique> {
        /* ... */
    }
}

// infrastructure/http.rs — adaptateur entrant
pub async fn creer_commande<R: CommandeRepository, N: NotificationClient>(
    State(cas): State<Arc<ValiderCommande<R, N>>>,
    Json(dto): Json<CreerCommandeDto>,
) -> Result<StatusCode, ErreurApi> {
    let commande = Commande::creer(CommandeId::nouveau(), dto.vers_lignes())?;
    cas.executer(&commande)?;                // Traduction HTTP → domaine
    Ok(StatusCode::CREATED)
}

Les commentaires marquent le vrai travail d’un adaptateur : traduire. Le mnt_ttc_cts de la base et le Montant du domaine sont deux représentations distinctes, et c’est l’adaptateur qui absorbe l’écart — c’est un Adapter au sens du GoF, appliqué à l’échelle d’une frontière d’architecture.

La racine de composition, en code

​
// main.ts
const cas = new ValiderCommande(
  new PostgresCommandeRepository(pool),
  new MailerNotificationClient(smtp),
);

const app = express();
app.post('/commandes', controleurCommande(cas));
app.listen(3000);
# main.py
cas = ValiderCommande(
    repo=PostgresCommandeRepository(session),
    notification=MailerNotificationClient(smtp),
)

app = FastAPI()
app.include_router(routeur_commandes(cas))
// main.rs
#[tokio::main]
async fn main() {
    let cas = Arc::new(ValiderCommande::new(
        PostgresCommandeRepository::new(pool),
        MailerNotificationClient::new(smtp),
    ));

    let app = Router::new()
        .route("/commandes", post(creer_commande))
        .with_state(cas);

    axum::serve(listener, app).await.unwrap();
}

C’est le seul fichier qui viole le sens de lecture habituel : il voit tout.

Le bénéfice : le test

Tout ce qui précède se paie en fichiers et en interfaces. Voici la contrepartie.

​
class RepositoryEnMemoire implements CommandeRepository {
  readonly enregistrees: Commande[] = [];

  async enregistrer(commande: Commande): Promise<void> {
    this.enregistrees.push(commande);
  }

  async parId(id: CommandeId): Promise<Commande | undefined> {
    return this.enregistrees.find((c) => c.id.equals(id));
  }
}

it('enregistre puis notifie', async () => {
  const repo = new RepositoryEnMemoire();
  const notification = new NotificationEspion();

  await new ValiderCommande(repo, notification).executer(Commande.exemple());

  expect(repo.enregistrees).toHaveLength(1);
  expect(notification.envoyees).toHaveLength(1);
});
class RepositoryEnMemoire:
    def __init__(self) -> None:
        self.enregistrees: list[Commande] = []

    def enregistrer(self, commande: Commande) -> None:
        self.enregistrees.append(commande)

    def par_id(self, id: CommandeId) -> Commande | None:
        return next((c for c in self.enregistrees if c.id == id), None)


def test_enregistre_puis_notifie() -> None:
    repo = RepositoryEnMemoire()
    notification = NotificationEspion()

    ValiderCommande(repo, notification).executer(Commande.exemple())

    assert len(repo.enregistrees) == 1
    assert len(notification.envoyees) == 1
#[derive(Clone, Default)]
struct RepositoryEnMemoire {
    enregistrees: Rc<RefCell<Vec<Commande>>>,   // Poignée partagée : le test
}                                               // garde une vue sur le contenu

impl CommandeRepository for RepositoryEnMemoire {
    fn enregistrer(&self, commande: &Commande) -> Result<(), ErreurTechnique> {
        self.enregistrees.borrow_mut().push(commande.clone());
        Ok(())
    }

    fn par_id(&self, _: CommandeId) -> Result<Option<Commande>, ErreurTechnique> {
        Ok(self.enregistrees.borrow().first().cloned())
    }
}

#[test]
fn enregistre_puis_notifie() {
    let repo = RepositoryEnMemoire::default();
    let notification = NotificationEspion::default();
    let cas = ValiderCommande::new(repo.clone(), notification.clone());

    cas.executer(&Commande::exemple()).unwrap();

    assert_eq!(repo.enregistrees.borrow().len(), 1);
    assert_eq!(notification.envoyees.borrow().len(), 1);
}

Pas de base de données, pas de serveur SMTP, pas de conteneur, pas de mock framework — et une exécution en millisecondes. Ce test ne vérifie pas un détail technique : il vérifie une règle métier, et il continuera de passer après une migration vers un autre moteur de stockage.

Note

Un adaptateur en mémoire écrit à la main vaut mieux qu’un mock généré. Il oblige à respecter le contrat du port pour tous les cas — un parId qui retrouve réellement ce que enregistrer a écrit — là où un mock configuré appel par appel finit par décrire un comportement qu’aucune implémentation réelle n’a jamais eu.

Les organisations de fichiers

C’est la question la plus concrète et la moins traitée. La règle de dépendance ne dit rien de l’arborescence : plusieurs organisations la respectent, avec des propriétés très différentes.

A — Par couche

L’organisation par défaut, celle de tous les tutoriels.

src/
  domaine/          entités, objets valeur, agrégats, ports
  application/      cas d'usage
  infrastructure/   adaptateurs, groupés par technologie
  main.ts

Simple à expliquer, et elle a un défaut qui apparaît vite : une évolution fonctionnelle touche trois dossiers éloignés, et deux développeurs sur deux fonctionnalités différentes travaillent en permanence dans les mêmes fichiers. Convenable jusqu’à une dizaine de cas d’usage.

B — Par nature de port

Rend l’hexagone littéralement visible dans l’arborescence.

src/
  domaine/
  ports/
    entrants/       interfaces des cas d'usage
    sortants/       dépôts, notifications, horloge
  adaptateurs/
    entrants/http/
    entrants/cli/
    sortants/postgres/
    sortants/smtp/
  main.ts

Pédagogiquement excellente — sur une équipe qui découvre l’approche, elle rend la règle de dépendance évidente. En contrepartie, elle disperse encore plus une fonctionnalité, et elle vieillit mal au-delà d’un contexte.

C — Par fonctionnalité, puis par couche

L’organisation qui passe à l’échelle, et celle que je recommanderais par défaut au-delà d’une dizaine de cas d’usage.

src/
  facturation/
    domaine/
    application/
    infrastructure/
  expedition/
    domaine/
    application/
    infrastructure/
  catalogue/
    domaine/
    application/
    infrastructure/
  partage/            identifiants, objets valeur vraiment universels
  main.ts

Une évolution métier touche un dossier. C’est aussi ce que Robert C. Martin appelle une screaming architecture : ouvrir le dépôt apprend ce que fait le logiciel, et non quel framework il utilise. Et surtout, chaque dossier de premier niveau devient un candidat naturel à l’extraction — en module, puis en service si le besoin apparaît un jour.

D — Modules ou paquets séparés

La même chose que C, mais la frontière est vérifiée par l’outil de construction et non par la discipline.

​
packages/
  facturation-domaine/        package.json sans aucune dépendance runtime
  facturation-application/    dépend de -domaine
  facturation-infra/          dépend de -domaine, + pg, + nodemailer
  app/                        dépend de tout

La frontière tient par les dependencies de chaque package.json, dans un workspace npm, pnpm ou Nx. Un import interdit ne résout tout simplement pas.

src/
  facturation/
    domaine/
    application/
    infrastructure/
setup.cfg / pyproject.toml
.importlinter                 les contrats de frontière

Python n’a pas de mécanisme de visibilité entre paquets : la frontière se vérifie avec import-linter, dont les contrats déclarent que domaine ne peut importer ni infrastructure ni aucune bibliothèque tierce. C’est un fichier de configuration et une étape de CI.

crates/
  facturation-domaine/    Cargo.toml SANS sqlx, SANS axum
  facturation-app/        dépend de -domaine
  facturation-infra/      dépend de -domaine, + sqlx
  app/                    racine de composition

C’est la version la plus robuste des quatre : la crate domaine ne déclare pas ses dépendances interdites, donc le code qui les utiliserait ne compile pas. Aucune configuration, aucun linter : le système de paquets fait le travail.

E — Tranches verticales

Un dossier par cas d’usage, avec ses ports et ses adaptateurs à côté.

src/
  facturation/
    valider-commande/
      valider-commande.ts        le cas d'usage
      ports.ts                   les ports que CE cas d'usage exige
      controleur.ts
    annuler-commande/
    domaine/                     partagé entre les cas d'usage

Radicalement local : tout ce qui concerne un cas d’usage tient sous les yeux. Cela suppose d’accepter une certaine duplication entre tranches, et de savoir résister à la tentation de « factoriser » un dossier commun qui recréerait le couplage. Efficace sur des cas d’usage nombreux et peu semblables entre eux.

Comparaison

Lisibilité d’une fonctionnalitéPassage à l’échelleFrontière vérifiableCoût initial
A — Par coucheFaibleFaibleNonNul
B — Par portFaibleFaibleNonNul
C — Par fonctionnalitéÉlevéeÉlevéeNonFaible
D — Paquets séparésÉlevéeÉlevéeOuiMoyen à élevé
E — Tranches verticalesTrès élevéeÉlevéeNonFaible

La trajectoire la plus courante, et la plus saine : commencer en A pour apprendre, passer en C dès qu’il y a plus d’un domaine fonctionnel, et n’aller en D que sur le cœur de métier, là où la frontière mérite d’être garantie plutôt que promise.

Trois questions qui reviennent toujours

Où mettre les ports ? Trois placements, du plus simple au plus rigoureux : un seul fichier ports.ts par contexte — pratique jusqu’à une dizaine ; un fichier par port dans domaine/ports/ — le meilleur compromis ; ou le port à côté du cas d’usage qui l’exige, ce qui rend visible qu’un port n’existe que parce que quelqu’un en a besoin, et qui est la seule façon de garder des ports petits.

Où mettre les DTO ? Dans l’adaptateur, jamais dans le domaine. Un DTO existe pour une frontière précise : le DTO HTTP appartient à l’adaptateur HTTP. Deux adaptateurs entrants ont deux jeux de DTO, et vouloir les partager est le premier pas vers un objet unique qui traverse tout.

Où mettre la correspondance ? Du côté de la représentation étrangère. La traduction Commande ↔ ligne SQL vit dans l’adaptateur Postgres, pas dans le domaine et pas dans un dossier mappers/ central — qui finirait par dépendre de tout.

À partir de quand c’est pertinent

L’architecture en couches a une propriété d’échelle précise : elle encaisse très bien le nombre d’entités, très mal la densité de règles. L’hexagone a la propriété symétrique, et c’est ce qui détermine quand il est pertinent :

L’hexagone ne rentabilise que la densité de règles. Sur un domaine mince, il ne rend strictement aucun service, et son coût est intégral.

Le bénéfice attendu est un produit de trois facteurs :

   bénéfice ≈ densité de règles × nombre de points d'entrée × durée de vie

Un produit — donc si un seul facteur vaut à peu près un, le résultat est nul. Un CRUD avec une seule API HTTP et deux ans de vie ne tire rien de l’inversion, quel que soit son nombre de lignes.

Les zones

Règles par cas d’usagePoints d’entréeDurée de vieVerdict
Sur-conçu0 – 21< 2 ansN’y allez pas. 3-tier assumé
Zone grise3 – 51, mais un second se profile2 – 5 ansRenforcer le 3-tier d’abord
Pertinent5 – 102 et plus> 5 ansL’hexagone se rembourse
Indispensable> 10, et elles changent3 et plusIndéterminéeCœur de métier — voir DDD

Les trois tests honnêtes

Plutôt que des seuils, trois questions dont la réponse tranche vraiment.

« Nommez trois règles métier qu’il serait pénible de tester avec une base de données. » Si l’exercice est difficile, le domaine est mince et l’inversion ne protège rien. C’est le test le plus discriminant des trois.

« Combien de fois par mois modifiez-vous une règle métier ? » Si la réponse est zéro, la protection n’a aucune valeur : on paie une assurance contre un événement qui n’arrive pas.

« Le même traitement doit-il être déclenché autrement que par l’API ? » Une tâche planifiée, une file, un import, une commande d’exploitation. Si oui, l’hexagone est déjà justifié à lui seul par ce point.

Tip

La décision n’est pas globale. On applique l’hexagone par sous-domaine : le cœur de métier en ports et adaptateurs, le référentiel et le back-office en 3-tier assumé. C’est presque toujours meilleur qu’un choix unique appliqué partout, et c’est la logique de dosage décrite dans Sous-domaines.

Ce que ça coûte

Le passer sous silence rendrait tout le reste suspect. Le coût est réel, et il se compte.

Le nombre de fichiers

Pour un cas d’usage trivial — enregistrer une entité et répondre 201 :

3-tierHexagonal
Fichiers à créer1 à 35 à 7
Fichiers à ouvrir pour lire le flux24 à 5
Interfaces à implémentation unique02 à 3

Sur un CRUD de quarante endpoints, cet écart est un multiplicateur de travail sans contrepartie. Sur cinq cas d’usage denses en règles, il est négligeable devant le temps gagné en test.

La correspondance

C’est la critique la plus fondée, et elle ne se réfute pas. Un même concept existe en trois représentations : le DTO d’entrée, l’objet du domaine, la ligne de base. Ajouter un champ, c’est le déclarer trois fois et écrire deux traductions.

Les réductions possibles sont partielles et toutes imparfaites : générer la correspondance — au prix d’un outil de plus ; réutiliser le DTO comme entrée du domaine — au prix de la contrainte qu’on cherchait à poser ; ou n’appliquer le mapping complet qu’aux agrégats du cœur, en laissant le reste en accès direct. La dernière est la plus raisonnable, et c’est encore un arbitrage par sous-domaine.

L’indirection

Chaque flux traverse deux ou trois interfaces qui n’ont, en production, qu’une seule implémentation. « Aller à la définition » atterrit sur une signature, et il faut une seconde étape pour trouver le code réel. C’est un coût de lecture permanent, payé par tous les développeurs à chaque investigation.

L’apprentissage

Le coût le plus sous-estimé. Il faut compter quelques semaines avant qu’une équipe cesse spontanément de mettre les règles dans les adaptateurs, et le résultat intermédiaire — des ports partout et les règles au mauvais endroit — est pire que le 3-tier de départ, puisqu’il en cumule le couplage et l’indirection.

Warning

La demi-mesure est le pire résultat possible. Des interfaces partout mais un objet ORM qui traverse le domaine, ou une entité annotée @Entity au centre de l’hexagone, cumulent le coût de l’architecture et le couplage des couches. Si le domaine doit dépendre de l’infrastructure, autant assumer une architecture en couches simple et lisible.

Les limites

Distinctes du coût : ce sont des questions auxquelles cette architecture ne répond pas, ou mal.

Elle est conçue pour l’écriture

La limite la plus importante en pratique. Un dépôt d’agrégats est le bon outil pour appliquer des règles ; c’est un mauvais outil pour afficher quelque chose. Une liste paginée, triée, filtrée sur six critères, avec trois jointures d’affichage, ne passe pas proprement par un port de dépôt — on finit soit avec un port à trente méthodes, soit avec des agrégats chargés puis jetés à 95 %.

La réponse honnête est d’assumer deux chemins : les écritures par les ports et les agrégats, les lectures par des requêtes dédiées qui renvoient directement ce qu’affiche l’écran. C’est le point de départ de CQRS, et c’est une décision à prendre tôt.

Elle ne dit rien du découpage

La règle de dépendance est verticale : elle ordonne l’intérieur et l’extérieur. Elle ne dit rien des frontières horizontales entre domaines fonctionnels. Un hexagone unique de deux cents mille lignes reste un monolithe illisible, avec un domaine parfaitement pur.

Hexagonal et contextes délimités répondent à deux questions différentes, et il faut les deux.

Les transactions traversent la frontière

La difficulté technique la plus concrète, et la moins documentée. Une transaction est un concept d’infrastructure qui doit encadrer un cas d’usage, lequel appartient à l’intérieur. Aucune solution n’est propre :

SolutionCe qu’elle coûte
Injecter une unité de travail comme portUn concept d’infrastructure entre dans le domaine, sous un nom neutre
Décorer le cas d’usage à la compositionLa frontière transactionnelle devient invisible dans le code du cas d’usage
Ouvrir la transaction dans l’adaptateur entrantL’adaptateur décide d’une propriété métier — l’atomicité
Un agrégat par transaction, et rien d’autreLa solution DDD ; impose la cohérence différée, ce qui n’est pas toujours acceptable

La dernière est la plus cohérente avec le reste, et c’est celle que retiennent les agrégats. Elle suppose de l’assumer explicitement.

L’ORM résiste

Le chargement paresseux, le suivi automatique des changements et les collections gérées supposent que l’objet appartienne à l’ORM. Un domaine pur interdit tout cela, donc impose une correspondance explicite — et l’on perd des fonctionnalités pour lesquelles on paie déjà.

L’asynchronisme colore les ports

Dès qu’un port est async, tout ce qui l’appelle le devient, jusqu’au cœur du domaine. Le domaine « sans dépendance technique » se retrouve à dépendre du modèle d’exécution. Le contournement — garder les règles pures dans des fonctions synchrones et confiner l’asynchronisme aux cas d’usage — fonctionne, mais demande une vigilance constante.

Elle ne protège pas d’un mauvais modèle

À retenir en dernier parce que c’est le plus important : un domaine anémique derrière des ports reste anémique. L’architecture garantit que les règles ne dépendent de rien ; elle ne garantit pas qu’il y ait des règles, ni qu’elles soient au bon endroit. Un hexagone impeccable autour de classes sans comportement est un coût pur.

C’est pourquoi l’hexagone seul ne suffit pas, et pourquoi il se lit avec le DDD : l’un dit où mettre le domaine, l’autre dit ce qu’il y a dedans.

Ports & adaptateurs, Onion, Clean : ce qui les sépare vraiment

Les trois noms désignent la même idée centrale — la règle de dépendance — et diffèrent sur la quantité de structure qu’ils imposent à l’intérieur de la frontière.

Ports & adaptateursOnionClean
Auteur, dateAlistair Cockburn, 2005Jeffrey Palermo, 2008Robert C. Martin, 2012
MétaphoreUn hexagone, sans haut ni basDes pelures concentriquesQuatre anneaux
Ce qu’il structureLa frontière uniquementLa frontière et l’intérieurLa frontière et l’intérieur
VocabulairePort, adaptateurDomain model, domain services, application servicesEntity, use case, interface adapter, presenter, gateway
Apport propreLa symétrie : entrée et sortie traitées pareilLe découpage interne expliciteLa règle de dépendance énoncée universellement ; la scission entités / cas d’usage
Silencieux surL’organisation interneLes adaptateurs entrantsRien — c’est le plus prescriptif

Ce que chacun apporte réellement

Ports & adaptateurs est le plus minimal et le plus fidèle à l’intention d’origine. Son apport propre est la symétrie : il n’y a pas de « haut » ni de « bas », seulement un dedans et un dehors, et l’application doit pouvoir être pilotée indifféremment par un utilisateur, un autre programme, un test automatisé ou un traitement par lots. C’est exactement ce que la vision en couches empêche de penser. Cockburn a d’ailleurs poussé le nom « ports et adaptateurs » précisément parce que « hexagonal » conduisait ses lecteurs à compter les côtés et à chercher six ports.

Onion ajoute un découpage interne explicite : modèle de domaine au centre, puis services de domaine, puis services applicatifs. Utile à une équipe qui découvre — elle sait où poser chaque fichier — et générateur de cérémonie quand les anneaux sont respectés pour eux-mêmes. En revanche, Onion parle peu des adaptateurs entrants, là où l’hexagone en fait un citoyen de première classe.

Clean est une synthèse, et elle apporte trois choses qui lui sont propres. La règle de dépendance énoncée comme un principe universel, ce qui est la formulation la plus reprise aujourd’hui. La distinction entités / cas d’usage : les entités portent des règles vraies en dehors de cette application, les cas d’usage sont propres au produit — distinction qui ne sert à rien tant qu’une seule application existe, et qui devient précieuse quand un back-office et une API publique partagent un métier. Et les présentateurs : dans la version stricte, un cas d’usage ne retourne rien, il appelle un port de sortie de présentation. C’est la partie la plus ignorée de Clean, et souvent à raison — elle double le nombre d’interfaces pour un bénéfice qui ne se manifeste que sur des interfaces utilisateur très variées.

En pratique

L’usage courant a tranché : « hexagonal » désigne aujourd’hui la famille entière, et personne ne vous reprendra pour avoir dit « hexagonal » en décrivant des anneaux de Clean. Les différences de substance se réduisent à deux questions concrètes :

  • Faut-il séparer entités et cas d’usage ? Oui si plusieurs applications partagent le domaine, non sinon.
  • Faut-il des présentateurs ? Presque jamais, sauf interfaces multiples et très différentes.

Pour le reste, choisissez un vocabulaire et tenez-le. Celui de « ports et adaptateurs » est le plus recommandable : il est le moins ambigu — un port est une interface, un adaptateur l’implémente —, il ne suggère aucun nombre, et il ne prescrit rien qui ne soit justifié.

Comparaison avec l’architecture en couches

En couchesHexagonale
Sens des dépendancesVers le bas, du métier vers la techniqueVers l’intérieur, de la technique vers le métier
Propriétaire des interfacesLa couche qui implémenteLe domaine qui consomme
Test du métierNécessite l’infrastructureAdaptateurs en mémoire
Changer de stockageTouche le métierTouche un adaptateur
Points d’entrée multiplesDuplication ou couche partagéeUn adaptateur de plus
Passe à l’échelle deDu nombre d’entitésDe la densité de règles
Coût d’entréeNulÉlevé : ports, correspondance, composition
Terrain favorableCRUD, domaine mince, durée courteDomaine riche, longue durée de vie

Références