Index
CraftArchitectureHexagonal ArchitectureJava

Faire Décoller une Architecture Hexagonale : Réserver un Vol, Couche par Couche

Construire une feature de bout en bout en architecture hexagonale, du domaine aux adaptateurs, à travers un exemple concret : la réservation d'un vol.

Thomas Maurier — Fullstack Developer · 6 avril 2026 · 10 min

Introduction

On connaît les principes par cœur : le domaine au centre, les ports qui définissent ce dont il a besoin, les adaptateurs qui branchent le monde extérieur. On a lu les schémas, on a vu les hexagones, on pourrait presque en dessiner un les yeux fermés. Et puis vient le moment d'écrire la toute première feature d'un nouveau projet, et là, page blanche : par où commence-t-on ? Le contrôleur REST d'abord, pour voir tourner quelque chose ? Le domaine, quitte à deviner ce dont il aura besoin ? Un article précédent expliquait comment ArchUnit garantit qu'une architecture hexagonale reste propre une fois en place. Encore faut-il savoir comment la construire la première fois.


Pourquoi construire, pas seulement vérifier

Vérifier une règle d'architecture est, en un sens, la partie facile : la règle existe déjà, il suffit de l'exprimer et de la faire respecter. Construire une feature depuis zéro demande autre chose, un ordre dans lequel les décisions se prennent, et une réponse claire à une question simple : qu'est-ce qui doit dépendre de quoi ?

Pour répondre concrètement, prenons un cas d'usage entier, du besoin métier jusqu'au contrôleur HTTP : réserver une place sur un vol.


Le cas d'usage : réserver un vol

Le besoin tient en une phrase : un passager réserve une place sur un vol identifié par son numéro. La règle métier tient, elle aussi, en une phrase : si le vol est complet, la réservation est refusée. Rien de plus pour l'instant. Le but n'est pas de couvrir tous les cas d'une vraie compagnie aérienne, mais de suivre cette règle unique à travers chaque couche, du domaine jusqu'à la requête HTTP qui déclenche tout.


Le domaine, sans rien d'autre

Le domaine ne connaît ni base de données, ni framework web, ni format d'échange. Il connaît uniquement les règles du métier, exprimées dans le langage du métier. Pour la réservation, cela tient dans une seule classe :

java·

Vol.java

public class Vol {
  private final String numero;
  private int placesDisponibles;

  public Vol(String numero, int placesDisponibles) {
      this.numero = numero;
      this.placesDisponibles = placesDisponibles;
  }

  public Reservation reserver(Passager passager) {
      if (placesDisponibles <= 0) {
          throw new VolCompletException(numero);
      }
      placesDisponibles--;
      return new Reservation(numero, passager);
  }

}

La règle n'est pas vérifiée par un service extérieur qui inspecterait l'état du vol : c'est le vol lui-même qui refuse la réservation s'il n'a plus de place. L'exception qui en résulte appartient au même monde :

java
public class VolCompletException extends RuntimeException {
  public VolCompletException(String numeroVol) {
      super("Le vol " + numeroVol + " est complet");
  }
}

Rien dans ces deux classes ne laisse deviner comment Vol sera un jour chargé depuis une base de données, ni comment cette réservation sera un jour envoyée sur le réseau. C'est précisément le but : à ce stade, ces questions n'ont pas encore de réponse, et le domaine n'a pas à les connaître.


Les ports : ce que le domaine attend du monde extérieur

Le domaine a tout de même besoin de charger un vol existant et d'en sauvegarder l'état après réservation. Il exprime ce besoin sous forme d'interface, qu'il définit lui-même :

java·

VolRepository.java

public interface VolRepository {
  Vol findByNumero(String numero);
  void save(Vol vol);
}

Ce détail change tout : l'interface vit dans le domaine, pas dans la couche de persistance. Ce n'est pas la base de données qui impose sa façon de faire au métier ; c'est le métier qui dicte le contrat que n'importe quel mécanisme de stockage devra respecter. L'inversion de dépendance tient dans ce simple choix d'emplacement.

Un hexagone a rarement une seule sortie. Ici, une réservation confirmée mérite une notification :

java·

NotificationPort.java

public interface NotificationPort {
  void confirmerReservation(Reservation reservation);
}

Le domaine sait qu'une confirmation doit partir. Il ignore complètement si elle prendra la forme d'un e-mail, d'un SMS, ou d'un événement publié sur une file de messages : ce choix appartient à l'adaptateur qui implémentera ce port.


Le cas d'usage applicatif

Entre le domaine et les ports se trouve une pièce qui orchestre les deux, sans contenir elle-même de règle métier :

java·

ReserverVolUseCase.java

public class ReserverVolUseCase {
  private final VolRepository volRepository;
  private final NotificationPort notificationPort;

  public ReserverVolUseCase(VolRepository volRepository, NotificationPort notificationPort) {
      this.volRepository = volRepository;
      this.notificationPort = notificationPort;
  }

  public Reservation execute(String numeroVol, Passager passager) {
      Vol vol = volRepository.findByNumero(numeroVol);
      Reservation reservation = vol.reserver(passager);
      volRepository.save(vol);
      notificationPort.confirmerReservation(reservation);
      return reservation;
  }

}

Ce cas d'usage se lit comme une suite d'étapes métier : charger le vol, le réserver, sauvegarder, notifier. Il ne sait toujours pas ce qu'est une table SQL ni un serveur SMTP. Il connaît seulement les ports, et fait confiance à ce qui les implémentera.


Les adaptateurs : les deux sens de l'hexagone

C'est seulement à ce stade que la technique entre en scène, des deux côtés de l'hexagone.

Côté sortant, un adaptateur relie le port VolRepository à une vraie base de données, ici via Spring Data :

java·

VolJpaRepository.java

@Repository
public class VolJpaRepository implements VolRepository {
  private final SpringDataVolRepository springDataRepository;

  public VolJpaRepository(SpringDataVolRepository springDataRepository) {
      this.springDataRepository = springDataRepository;
  }

  @Override
  public Vol findByNumero(String numero) {
      return springDataRepository.findById(numero)
          .map(this::versLeDomaine)
          .orElseThrow(() -> new VolIntrouvableException(numero));
  }

  @Override
  public void save(Vol vol) {
      springDataRepository.save(versEntiteJpa(vol));
  }

  // versLeDomaine et versEntiteJpa convertissent entre l'entité JPA
  // et l'objet du domaine, dans un sens puis dans l'autre.

}

Toujours côté sortant, un second adaptateur implémente la notification par e-mail :

java·

EmailNotificationAdapter.java

@Component
public class EmailNotificationAdapter implements NotificationPort {
  private final JavaMailSender mailSender;

  public EmailNotificationAdapter(JavaMailSender mailSender) {
      this.mailSender = mailSender;
  }

  @Override
  public void confirmerReservation(Reservation reservation) {
      mailSender.send(emailDeConfirmation(reservation));
  }

}

Côté entrant, un contrôleur REST déclenche le cas d'usage à partir d'une requête HTTP :

java·

ReservationController.java

@RestController
@RequestMapping("/vols")
public class ReservationController {
  private final ReserverVolUseCase reserverVolUseCase;

  public ReservationController(ReserverVolUseCase reserverVolUseCase) {
      this.reserverVolUseCase = reserverVolUseCase;
  }

  @PostMapping("/{numero}/reservations")
  public ResponseEntity<ReservationResponse> reserver(
          @PathVariable String numero,
          @RequestBody ReservationRequest request) {
      Reservation reservation = reserverVolUseCase.execute(numero, request.toPassager());
      return ResponseEntity.ok(ReservationResponse.from(reservation));
  }

}

Trois adaptateurs, trois technologies différentes, et pourtant aucun des deux premiers ne sait qu'un contrôleur REST existe, et le contrôleur ignore tout de JPA ou de l'envoi d'e-mails. Chacun ne connaît que le cas d'usage ou les ports qu'il implémente.


Assembler le tout

Reste à relier les pièces. Avec Spring, les annotations @Repository, @Component et @RestController suffisent à faire remonter chaque adaptateur dans le conteneur d'injection, qui les distribue ensuite là où les interfaces les réclament. ReserverVolUseCase reçoit ses deux ports au constructeur sans jamais savoir quelles implémentations Spring lui a fournies.

Le test de cet assemblage se fait autant à l'œil qu'à l'exécution : ouvrir le paquetage domain et n'y trouver aucun import Spring, aucun import JPA, aucun import lié au web. Si un de ces imports s'y glisse un jour, c'est le signe qu'une décision technique a fui vers l'endroit qui devait justement en rester protégé.


Vérifier que ça tient, avec ArchUnit

C'est exactement le rôle que jouait ArchUnit dans l'article précédent : transformer cette inspection à l'œil en règle automatisée, exécutée à chaque build plutôt que dans la tête d'un relecteur attentif.

java·

ArchitectureTest.java

@ArchTest
static final ArchRule domain_should_not_depend_on_infrastructure =
  noClasses()
      .that().resideInAPackage("..domain..")
      .should().dependOnClassesThat()
      .resideInAnyPackage("..infrastructure..", "..adapter..");

Cette règle ne fait que confirmer, en continu, ce que la structure des paquetages garantit déjà par construction : Vol, Reservation et VolCompletException restent isolés de VolJpaRepository, d'EmailNotificationAdapter et de ReservationController, quoi qu'il arrive dans les prochains sprints.


Pourquoi ce découpage vaut l'effort

Pour une règle aussi simple qu'« un vol complet refuse les réservations », ce découpage en quatre couches peut sembler disproportionné. Il le resterait, en effet, si le projet s'arrêtait là. Mais aucun projet ne s'arrête là : le stockage change, une nouvelle API de paiement s'ajoute, une notification par SMS vient compléter l'e-mail. Dans une architecture hexagonale, chacun de ces changements se traduit par un nouvel adaptateur, pas par une réécriture du domaine. La règle métier, elle, ne bouge pas d'une ligne, parce qu'elle n'a jamais été mélangée avec la façon dont elle est stockée, exposée ou notifiée.


Retour à la page blanche

Le développeur qui hésitait entre commencer par le contrôleur ou par le domaine a maintenant une réponse : ni l'un ni l'autre en premier, mais le domaine et sa règle, exprimés sans rien d'autre autour. Les ports suivent, dictés par ce dont cette règle a besoin. Les adaptateurs viennent en dernier, une fois que le contrat qu'ils doivent remplir est clair. Ce n'est plus une page blanche : c'est un ordre à suivre, une fois, puis à répéter pour chaque nouvelle feature qui suivra.

Continuer la lecture