L'Architecture Hexagonale — aussi appelée Ports & Adapters — a été introduite en 2005 par Alistair Cockburn. Son objectif principal : isoler totalement la logique métier de tout détail technique (base de données, framework, interface utilisateur, API externe…). Résultat : un code testable à 100%, maintenable, et indépendant de l'infrastructure.

L'architecture hexagonale est la base des architectures DDD (Domain-Driven Design), Clean Architecture (Robert C. Martin) et Onion Architecture. Comprendre la hexagonale, c'est comprendre toutes les autres.

1. Le problème — l'architecture en couches classique

L'architecture traditionnelle en couches (Controller → Service → Repository → Database) fonctionne bien au départ, mais crée une dépendance descendante fatale : le domaine métier dépend de l'infrastructure.

Architecture en couches — le problème
// PROBLÈME : le service métier dépend directement de JPA
@Service
public class CommandeService {

    private final CommandeRepository repo; // couplé à Spring Data / JPA

    public Commande passerCommande(CommandeRequest req) {
        // Logique métier mélangée avec des détails JPA
        CommandeEntity entity = new CommandeEntity();
        entity.setMontant(req.getMontant());
        entity.setStatut("EN_ATTENTE");
        return repo.save(entity); // retourne une Entity JPA, pas un objet métier
    }
}

// Conséquences :
// - Impossible de tester sans base de données
// - Changer de BDD = modifier le service métier
// - La logique métier "fuit" dans les entities JPA

L'architecture hexagonale inverse cette dépendance : l'infrastructure dépend du domaine, jamais l'inverse.

2. La structure — trois zones distinctes

L'hexagone représente le domaine métier. Autour de lui se trouvent les ports (interfaces) et les adapters (implémentations concrètes).

Structure du projet hexagonal
src/
├── domain/                        ← Cœur métier (0 dépendance externe)
│   ├── model/
│   │   ├── Commande.java          ← Entité métier pure
│   │   └── Produit.java
│   ├── port/
│   │   ├── in/                    ← Ports entrants (use cases)
│   │   │   ├── PasserCommandeUseCase.java
│   │   │   └── ConsulterCommandeUseCase.java
│   │   └── out/                   ← Ports sortants (contrats infra)
│   │       ├── CommandeRepository.java
│   │       └── NotificationService.java
│   └── service/
│       └── CommandeService.java    ← Implémente les use cases
│
├── adapter/                       ← Adapteurs (détails techniques)
│   ├── in/                        ← Adapteurs entrants (pilotent le domaine)
│   │   ├── web/
│   │   │   └── CommandeController.java
│   │   └── cli/
│   │       └── CommandeCLI.java
│   └── out/                       ← Adapteurs sortants (branchés sur l'infra)
│       ├── persistence/
│       │   └── CommandeJpaAdapter.java
│       └── notification/
│           └── EmailNotificationAdapter.java
│
└── infrastructure/               ← Config Spring, beans, etc.
    └── BeanConfiguration.java

3. Le domaine — zéro dépendance externe

Les entités métier et les ports sont de pur Java, sans annotation de framework, sans import de Spring ou JPA.

Java — Entité métier pure
// domain/model/Commande.java — aucun import de framework
public class Commande {

    private final CommandeId id;
    private final List<LigneCommande> lignes;
    private StatutCommande statut;

    public Commande(CommandeId id, List<LigneCommande> lignes) {
        this.id     = id;
        this.lignes = List.copyOf(lignes);
        this.statut = StatutCommande.EN_ATTENTE;
    }

    // Règles métier dans l'entité — jamais dans le service ni le contrôleur
    public void valider() {
        if (lignes.isEmpty())
            throw new CommandeVideException("Impossible de valider une commande vide");
        if (statut != StatutCommande.EN_ATTENTE)
            throw new StatutInvalideException("Seules les commandes EN_ATTENTE peuvent être validées");
        this.statut = StatutCommande.VALIDEE;
    }

    public BigDecimal calculerTotal() {
        return lignes.stream()
            .map(LigneCommande::sousTotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

4. Les ports entrants — les Use Cases

Un port entrant est une interface qui expose ce que l'application sait faire — ses cas d'usage. C'est le contrat entre le monde extérieur et le domaine.

Java — Ports entrants (Use Cases)
// domain/port/in/PasserCommandeUseCase.java
public interface PasserCommandeUseCase {

    // Command object — immuable, validation incluse
    record PasserCommandeCommand(
        ClientId            clientId,
        List<LigneRequest>   lignes
    ) {
        public PasserCommandeCommand {
            Objects.requireNonNull(clientId, "clientId requis");
            if (lignes == null || lignes.isEmpty())
                throw new IllegalArgumentException("Au moins une ligne requise");
        }
    }

    CommandeId passerCommande(PasserCommandeCommand command);
}

// domain/port/in/ConsulterCommandeUseCase.java
public interface ConsulterCommandeUseCase {
    Optional<Commande> rechercherParId(CommandeId id);
    List<Commande>    listerParClient(ClientId clientId);
}

5. Les ports sortants — les contrats d'infrastructure

Un port sortant est une interface définie dans le domaine qui décrit ce dont le domaine a besoin de l'extérieur (persister, notifier, appeler une API…). Le domaine n'implémente jamais ces interfaces — c'est l'affaire des adapters.

Java — Ports sortants (contrats infrastructure)
// domain/port/out/CommandeRepository.java
public interface CommandeRepository {          // défini dans le DOMAINE
    void                   sauvegarder(Commande commande);
    Optional<Commande>   rechercherParId(CommandeId id);
    List<Commande>        rechercherParClient(ClientId clientId);
}

// domain/port/out/NotificationPort.java
public interface NotificationPort {
    void notifierCommandeValidee(Commande commande);
}

// domain/port/out/PaiementPort.java
public interface PaiementPort {
    ResultatPaiement debiter(ClientId clientId, BigDecimal montant);
}

Les interfaces des ports sortants sont définies dans le domaine, pas dans l'infrastructure. C'est le Dependency Inversion Principle (le D de SOLID) : le domaine ne dépend jamais des détails techniques — c'est l'inverse.

6. Le service domaine — implémente les Use Cases

Le service domaine implémente les ports entrants et dépend uniquement des ports sortants (jamais des adapters concrets). C'est ici que vit toute la logique applicative.

Java — Service domaine
// domain/service/CommandeService.java
public class CommandeService
        implements PasserCommandeUseCase, ConsulterCommandeUseCase {

    // Dépend uniquement d'interfaces (ports sortants)
    private final CommandeRepository commandeRepository;
    private final PaiementPort       paiementPort;
    private final NotificationPort    notificationPort;

    public CommandeService(
            CommandeRepository commandeRepository,
            PaiementPort       paiementPort,
            NotificationPort   notificationPort) {
        this.commandeRepository = commandeRepository;
        this.paiementPort       = paiementPort;
        this.notificationPort   = notificationPort;
    }

    @Override
    public CommandeId passerCommande(PasserCommandeCommand cmd) {
        // 1. Créer l'entité métier
        var lignes = cmd.lignes().stream()
            .map(this::toLigneCommande)
            .toList();
        var commande = new Commande(CommandeId.generate(), lignes);

        // 2. Valider la commande (règle métier dans l'entité)
        commande.valider();

        // 3. Débiter le client via le port sortant
        var resultat = paiementPort.debiter(cmd.clientId(), commande.calculerTotal());
        if (!resultat.estAccepte())
            throw new PaiementRefuseException(resultat.motif());

        // 4. Persister
        commandeRepository.sauvegarder(commande);

        // 5. Notifier
        notificationPort.notifierCommandeValidee(commande);

        return commande.getId();
    }
}

7. Les adapters entrants — brancher l'extérieur sur le domaine

Un adapter entrant reçoit une sollicitation externe (requête HTTP, message, commande CLI…) et la traduit en appel au port entrant.

Java — Adapter entrant REST (Spring)
// adapter/in/web/CommandeController.java
@RestController
@RequestMapping("/api/commandes")
public class CommandeController {

    // Dépend UNIQUEMENT du port entrant (interface du domaine)
    private final PasserCommandeUseCase    passerCommandeUseCase;
    private final ConsulterCommandeUseCase consulterCommandeUseCase;

    @PostMapping
    public ResponseEntity<CommandeResponse> passer(@RequestBody CommandeRequest req) {
        // Traduire la requête HTTP en command métier
        var command = new PasserCommandeUseCase.PasserCommandeCommand(
            new ClientId(req.getClientId()),
            req.getLignes().stream().map(CommandeRequest.Ligne::toMetier).toList()
        );
        var id = passerCommandeUseCase.passerCommande(command);
        return ResponseEntity.created(locationOf(id)).body(new CommandeResponse(id));
    }

    @GetMapping("/{id}")
    public ResponseEntity<CommandeDetailResponse> consulter(@PathVariable String id) {
        return consulterCommandeUseCase
            .rechercherParId(new CommandeId(id))
            .map(CommandeDetailResponse::from)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

8. Les adapters sortants — implémenter les contrats du domaine

Un adapter sortant implémente un port sortant en utilisant une technologie concrète (JPA, REST, SMTP…). Le domaine ne sait pas quelle implémentation est utilisée.

Java — Adapter sortant JPA
// adapter/out/persistence/CommandeJpaAdapter.java
@Component
public class CommandeJpaAdapter implements CommandeRepository {

    private final CommandeJpaRepository jpaRepository;    // Spring Data JPA
    private final CommandeMapper        mapper;           // Entity ↔ Domain

    @Override
    public void sauvegarder(Commande commande) {
        // Convertit l'objet métier en Entity JPA avant de persister
        jpaRepository.save(mapper.toEntity(commande));
    }

    @Override
    public Optional<Commande> rechercherParId(CommandeId id) {
        return jpaRepository.findById(id.value())
            .map(mapper::toDomain); // reconvertit l'Entity en objet métier
    }
}

// adapter/out/notification/EmailNotificationAdapter.java
@Component
public class EmailNotificationAdapter implements NotificationPort {

    private final JavaMailSender mailSender;

    @Override
    public void notifierCommandeValidee(Commande commande) {
        // Envoi d'email — le domaine n'en sait rien
        var msg = mailSender.createMimeMessage();
        // ... configuration du message ...
        mailSender.send(msg);
    }
}

9. Les tests — le vrai bénéfice

Sans base de données, sans Spring context, sans serveur HTTP : on teste la logique métier en pur Java avec de simples mocks. Les tests sont rapides, stables et lisibles.

Java — Test unitaire du domaine (JUnit 5 + Mockito)
class CommandeServiceTest {

    // Mocks des ports sortants — pas de Spring, pas de BDD
    private final CommandeRepository repo         = mock(CommandeRepository.class);
    private final PaiementPort       paiement     = mock(PaiementPort.class);
    private final NotificationPort   notification = mock(NotificationPort.class);

    private final CommandeService service =
        new CommandeService(repo, paiement, notification); // injection manuelle

    @Test
    void passerCommande_devrait_debiter_et_notifier() {
        // Given
        when(paiement.debiter(any(), any()))
            .thenReturn(ResultatPaiement.accepte());

        var command = new PasserCommandeCommand(
            new ClientId("client-42"),
            List.of(new LigneRequest("prod-1", 2, new BigDecimal("19.99")))
        );

        // When
        var id = service.passerCommande(command);

        // Then
        assertNotNull(id);
        verify(repo).sauvegarder(argThat(c ->
            c.getStatut() == StatutCommande.VALIDEE));
        verify(notification).notifierCommandeValidee(any());
    }

    @Test
    void passerCommande_devrait_echouer_si_paiement_refuse() {
        when(paiement.debiter(any(), any()))
            .thenReturn(ResultatPaiement.refuse("Fonds insuffisants"));

        assertThrows(PaiementRefuseException.class,
            () -> service.passerCommande(uneCommand()));

        verify(repo, never()).sauvegarder(any()); // jamais persisté
    }
}

Les tests d'intégration testent les adapters indépendamment (ex : CommandeJpaAdapterTest avec une base H2 in-memory). Le domaine et l'infrastructure sont testés séparément — chaque chose à sa place.

10. Comparaison — Architecture en couches vs Hexagonale

Critère En couches classique Hexagonale
Testabilité Nécessite une BDD / context Spring Tests unitaires purs, rapides
Couplage Domaine couplé à l'infrastructure Domaine totalement isolé
Changer de BDD Impact sur le service métier Écrire un nouvel adapter uniquement
Ajouter une interface Peut nécessiter de modifier le service Nouvel adapter entrant, domaine intact
Complexité initiale Faible — structure linéaire Moyenne — plus de fichiers / interfaces
Lisibilité métier Noyée dans les détails techniques Use Cases explicites, logique visible

11. Quand l'adopter ?

L'architecture hexagonale n'est pas une silver bullet. Elle apporte de la valeur dans des contextes précis :

Pour un CRUD simple ou une application de petite taille, la hexagonale introduit une complexité inutile. Préférez une architecture plus simple et migrez progressivement quand la complexité métier le justifie.

12. Récapitulatif

🏛️

Domaine isolé

Zéro dépendance externe. La logique métier ne connaît ni Spring, ni JPA, ni HTTP.

🔌

Ports entrants

Interfaces des Use Cases. Définissent ce que l'application sait faire.

🔗

Ports sortants

Contrats d'infrastructure définis dans le domaine. Jamais de dépendance vers le bas.

Adapters

Implémentations concrètes : REST, JPA, SMTP, Kafka… Facilement remplaçables.

🧪

Testabilité totale

Tests unitaires du domaine en pur Java. Rapides, stables, sans infrastructure.

🔄

Evolvabilité

Changer de BDD, ajouter une interface : le domaine reste intacte.