Architecture hexagonale
Architecture « ports et adaptateurs » (Alistair Cockburn) qui place le domaine métier au centre, isolé de la technique.
Domaine
Cœur de l’application : entités, règles et cas d’usage métier, écrits en Java pur sans dépendance au framework.
Port entrant
Interface par laquelle l’extérieur déclenche un cas d’usage du domaine (ex. passer une commande).
Port sortant
Interface dont le domaine a besoin pour accéder au monde extérieur (persistance, paiement, messagerie).
Adaptateur
Implémentation technique d’un port : contrôleur REST, repository JPA, client HTTP, producteur Kafka.
Cas d’usage
Service applicatif qui orchestre une action métier en s’appuyant sur les ports.
Inversion des dépendances
Les dépendances pointent vers le domaine : l’infrastructure implémente les interfaces définies par le métier.
Câblage Spring
Configuration qui relie ports et adaptateurs sous forme de beans, sans annoter le domaine.
Test du domaine
Test unitaire du métier exécuté sans démarrer Spring ni base de données, grâce à des adaptateurs factices.
L’architecture hexagonale Spring Boot consiste à placer le code métier au centre de l’application et à repousser Spring, la base de données et les API externes en périphérie. Le domaine ne dépend de rien ; ce sont les adaptateurs qui dépendent de lui. Résultat : un métier testable en quelques millisecondes et des choix techniques que vous pouvez changer sans réécrire vos règles.
Ce guide montre comment organiser un projet Spring Boot en ports et adaptateurs, avec un exemple Java complet : passer une commande, la payer et l’enregistrer.
L’architecture hexagonale en deux phrases
Proposée par Alistair Cockburn sous le nom Ports and Adapters, l’architecture hexagonale sépare l’application en trois zones :
- le domaine : les entités et les règles métier, en Java pur ;
- les ports : des interfaces qui décrivent ce que le domaine offre (ports entrants) et ce dont il a besoin (ports sortants) ;
- les adaptateurs : le code technique qui branche le monde extérieur sur ces ports (contrôleur REST, repository JPA, client HTTP, consumer Kafka).
La règle qui fait tout tenir : les dépendances pointent toujours vers le domaine. C’est l’inversion des dépendances appliquée à l’échelle de l’application.
Pourquoi l’appliquer à un projet Spring Boot ?
Dans un projet Spring Boot classique en couches (controller, service, repository), le service métier dépend directement des entités JPA et des repositories Spring Data. Le métier hérite alors des contraintes de la base : annotations @Entity, chargement paresseux, transactions. Tester une règle demande de démarrer un contexte Spring.
Avec une architecture hexagonale :
- les tests du domaine tournent sans Spring, sans base, sans conteneur ;
- remplacer PostgreSQL par MongoDB, ou un appel REST par un message Kafka, ne touche qu’un adaptateur ;
- le code métier se lit sans bruit technique, ce qui facilite la revue de code.
L’arborescence d’un projet hexagonal
Une organisation simple, par package, suffit pour commencer :
com.shop.order
├── domain
│ ├── model // Order, OrderLine, Money
│ └── service // OrderService (implémente le port entrant)
├── application
│ └── port
│ ├── in // PlaceOrderUseCase
│ └── out // OrderRepository, PaymentGateway
└── adapter
├── in.web // OrderController (REST)
└── out
├── persistence // JpaOrderRepositoryAdapter, OrderEntity
└── payment // StripePaymentAdapter
Les packages domain et application n’importent aucune classe de Spring ni de JPA. Seul adapter le fait.
Exemple : passer une commande
1. Le modèle métier
public record Money(BigDecimal amount) {
public Money {
if (amount.signum() < 0) throw new IllegalArgumentException("Montant négatif");
}
public Money add(Money other) { return new Money(amount.add(other.amount)); }
}
public record OrderLine(String sku, int quantity, Money unitPrice) {
public Money total() {
return new Money(unitPrice.amount().multiply(BigDecimal.valueOf(quantity)));
}
}
public final class Order {
private final UUID id;
private final List<OrderLine> lines;
private boolean paid;
public Order(UUID id, List<OrderLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("Commande vide");
this.id = id;
this.lines = List.copyOf(lines);
}
public Money total() {
return lines.stream().map(OrderLine::total).reduce(new Money(BigDecimal.ZERO), Money::add);
}
public void markPaid() { this.paid = true; }
public UUID id() { return id; }
public boolean isPaid() { return paid; }
}
Aucune annotation : la règle « une commande ne peut pas être vide » vit dans le constructeur, là où elle ne peut pas être contournée.
2. Les ports
// Port entrant : ce que l’application sait faire
public interface PlaceOrderUseCase {
UUID placeOrder(List<OrderLine> lines);
}
// Ports sortants : ce dont le domaine a besoin
public interface OrderRepository {
void save(Order order);
}
public interface PaymentGateway {
boolean charge(UUID orderId, Money amount);
}
3. Le service métier
public class OrderService implements PlaceOrderUseCase {
private final OrderRepository orders;
private final PaymentGateway payments;
public OrderService(OrderRepository orders, PaymentGateway payments) {
this.orders = orders;
this.payments = payments;
}
@Override
public UUID placeOrder(List<OrderLine> lines) {
Order order = new Order(UUID.randomUUID(), lines);
if (!payments.charge(order.id(), order.total())) {
throw new PaymentRefusedException(order.id());
}
order.markPaid();
orders.save(order);
return order.id();
}
}
Le service ne connaît ni Stripe ni JPA : uniquement les interfaces qu’il a lui-même définies.
4. Les adaptateurs
@RestController
@RequestMapping("/orders")
class OrderController {
private final PlaceOrderUseCase placeOrder;
OrderController(PlaceOrderUseCase placeOrder) { this.placeOrder = placeOrder; }
@PostMapping
ResponseEntity<UUID> create(@Valid @RequestBody CreateOrderRequest request) {
UUID id = placeOrder.placeOrder(request.toLines());
return ResponseEntity.status(HttpStatus.CREATED).body(id);
}
}
@Component
class JpaOrderRepositoryAdapter implements OrderRepository {
private final SpringDataOrderRepository jpa;
JpaOrderRepositoryAdapter(SpringDataOrderRepository jpa) { this.jpa = jpa; }
@Override
public void save(Order order) {
jpa.save(OrderEntity.from(order)); // mapping domaine -> entité JPA
}
}
L’entité JPA OrderEntity reste dans l’adaptateur de persistance. Le mapping a un coût, mais il protège le domaine des contraintes de la base.
Câbler le tout avec Spring
Pour garder le domaine libre de toute annotation, déclarez le service dans une classe de configuration, côté infrastructure :
@Configuration
class OrderConfiguration {
@Bean
PlaceOrderUseCase placeOrderUseCase(OrderRepository orders, PaymentGateway payments) {
return new OrderService(orders, payments);
}
}
Tester le domaine sans démarrer Spring
C’est le gain le plus visible. Des doublures en mémoire remplacent les adaptateurs :
class OrderServiceTest {
private final List<Order> saved = new ArrayList<>();
private final OrderRepository repo = saved::add;
@Test
void refuse_la_commande_si_le_paiement_echoue() {
PaymentGateway refuse = (id, amount) -> false;
var service = new OrderService(repo, refuse);
var lines = List.of(new OrderLine("BOOK-1", 1, new Money(new BigDecimal("20"))));
assertThrows(PaymentRefusedException.class, () -> service.placeOrder(lines));
assertTrue(saved.isEmpty());
}
}
Pas de @SpringBootTest, pas de base : le test s’exécute en quelques millisecondes. Les adaptateurs, eux, se testent à part, avec des tests d’intégration (@DataJpaTest, Testcontainers).
Les erreurs fréquentes
- Mettre
@Entitysur le modèle du domaine pour éviter le mapping : le domaine redevient dépendant de JPA. - Un port par méthode : regroupez les opérations par cas d’usage pour éviter l’explosion d’interfaces.
- Un domaine anémique : si toutes les règles restent dans le service et que les objets ne sont que des getters, l’hexagone n’apporte presque rien.
- Faire dépendre un adaptateur d’un autre : chaque adaptateur ne parle qu’à un port.
Quand ne pas l’utiliser
Pour un CRUD sans règle métier, ou un microservice qui ne fait que transformer des données, l’architecture hexagonale ajoute des interfaces et du mapping sans bénéfice réel. Une structure en couches bien tenue suffit. Réservez l’hexagone aux domaines qui ont de vraies règles et une durée de vie longue.
À l’inverse, dans une architecture microservices, chaque service porte un domaine métier riche : c’est là que la structure hexagonale apporte le plus. Le principe d’inversion des dépendances qui la sous-tend est expliqué dans SOLID en JavaScript.
Pour aller plus loin
- La leçon gratuite Structure d’un projet Spring Boot pose les bases du découpage en packages.
- La formation Spring Boot construit une API complète : validation, sécurité JWT, tests, Kafka et Docker.
- Le principe qui rend l’hexagone possible est détaillé dans la leçon Inversion des dépendances (DIP) de la formation SOLID.




