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.
// 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).
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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
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 :
- Logique métier complexe — multiples règles, workflows, invariants à protéger
- Long cycle de vie — l'application sera maintenue plusieurs années
- Couverture de tests élevée — besoin de tester la logique métier sans infrastructure
- Plusieurs interfaces — REST + CLI + messages + batch sur le même domaine
- Remplacement d'infrastructure — migration de BDD, changement de broker…
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.