« Le contrôleur implémente le port d’entrée ? » « L’interface du repository, je la mets où ? » Quand on découvre l’architecture hexagonale, ou quand on vient d’une application en couches, on se perd vite dans le sens des flèches.
Cet article explique les notions une par une, sur un seul exemple :
créer une commande avec Spring Boot. À chaque étape, une question sert
de repère : ici, qui appelle ? qui implémente ? Le code complet est
dans le dépôt public
hexagonal-architecture-demo
(tag v1). Pour un cas plus réel, avec Kafka et plusieurs cas
d’usage, voir
Architecture hexagonale avec Spring Boot : un exemple simple.
Le problème
Dans une application en couches, chaque couche appelle celle du dessous : le contrôleur appelle le service, le service appelle le repository, le repository parle à la base. Les dépendances descendent. Le service métier dépend donc de JPA : pour le tester, il faut une base, ou beaucoup de mocks.
L’architecture hexagonale retourne une partie des flèches. Au centre, le cœur : le domaine et les cas d’usage, en Java pur. Autour, les adaptateurs : REST, JPA, Kafka… Toutes les dépendances pointent vers le cœur. Le cœur ne connaît aucun adaptateur.
Le nom vient d’Alistair Cockburn (2005), qui l’appelle aussi « Ports and Adapters ». Son but : « Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases. » La forme d’hexagone n’a pas de sens précis : « The hexagon is not a hexagon because the number six is important ».
Ici : en couches, chaque couche appelle celle du dessous et en dépend. En hexagonal, les adaptateurs dépendent du cœur, jamais l’inverse.
La vue d’ensemble
Voici tout l’exemple sur un seul schéma. Deux sortes de flèches : trait plein = « appelle », trait pointillé = « implémente ».
Tout tient en deux règles :
- côté entrant : un adaptateur (ici le contrôleur REST) appelle un port entrant ; le cœur l’implémente ;
- côté sortant : le cœur appelle un port sortant ; un adaptateur (ici JPA) l’implémente.
Un port est une interface qui appartient au cœur. Cockburn le définit ainsi : « A port identifies a purposeful conversation. » Un adaptateur traduit entre ce port et une technologie. Cockburn parle aussi de « driving adapters and driven adapters » : les adaptateurs qui pilotent l’application (entrants), et ceux qu’elle pilote (sortants).
Dans le dépôt, chaque partie a son package :
| Package | Contenu | Connaît Spring ? |
|---|---|---|
domain | Order, OrderLine, InvalidOrderException | non |
application.port.in | CreateOrderUseCase, CreateOrderCommand | non |
application.port.out | OrderRepository | non |
application | CreateOrderService | non |
adapter.in.web | le contrôleur REST et ses DTO | oui |
adapter.out.persistence | l’entité JPA, l’adaptateur, le mapper | oui |
config | l’assemblage avec Spring | oui |
Ici : l’adaptateur entrant appelle, le cœur implémente. Le cœur appelle, l’adaptateur sortant implémente.
Le domaine
Le domaine contient les règles métier, et rien d’autre. Dans l’exemple, la règle est simple : une commande a au moins une ligne, chaque quantité est entre 1 et 99, et le total est calculé par le domaine.
public final class Order {
private final UUID id;
private final List<OrderLine> lines;
public Order(UUID id, List<OrderLine> lines) {
if (id == null) {
throw new InvalidOrderException("an order needs an id");
}
if (lines == null || lines.isEmpty()) {
throw new InvalidOrderException("an order needs at least one line");
}
this.id = id;
this.lines = List.copyOf(lines);
}
public static Order create(List<OrderLine> lines) {
return new Order(UUID.randomUUID(), lines);
}
public BigDecimal total() {
return lines.stream()
.map(OrderLine::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// id(), lines()
}
OrderLine est un record qui vérifie la quantité dans son
constructeur. Les règles sont vérifiées à la création : un objet
Order est donc toujours valide.
Pourquoi pas d’annotation JPA ici ? Parce que la forme de la base n’est
pas une règle métier. Une colonne renommée ne doit pas changer
Order. Pourquoi pas de DTO ? Pour la même raison : le format JSON de
l’API appartient à l’adaptateur REST.
Dans cet article, le domaine ne connaît personne : ni Spring, ni les ports, ni l’application. C’est un choix courant, pas une règle de Cockburn : sa page parle de « l’application » au centre, sans la découper en domaine et cas d’usage. D’autres codes mettent des interfaces dans le domaine (voir « Les erreurs fréquentes »).
Ici : le domaine n’appelle aucun port et n’implémente aucun port. Le cas d’usage l’appelle.
Les ports et le cas d’usage
Le port entrant dit ce que l’application offre au monde extérieur.
C’est une interface, dans application.port.in :
public interface CreateOrderUseCase {
Order create(CreateOrderCommand command);
}
public record CreateOrderCommand(List<OrderLine> lines) {
}
La commande ne contient ni JSON ni HTTP. Un autre adaptateur (un listener Kafka, un batch, un test) peut l’utiliser de la même façon.
Le port sortant dit ce dont l’application a besoin. C’est aussi une
interface, dans application.port.out :
public interface OrderRepository {
void save(Order order);
}
C’est le cœur qui écrit cette interface, avec ses mots à lui : save,
pas persist ni insertInto. Il ne sait pas qu’il y a JPA derrière.
Le cas d’usage implémente le port entrant et appelle le port sortant :
public class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
public CreateOrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public Order create(CreateOrderCommand command) {
Order order = Order.create(command.lines());
orderRepository.save(order);
return order;
}
}
Pourquoi une interface pour le port entrant, alors qu’il n’a qu’une implémentation ? Elle donne un nom au contrat, et l’adaptateur ne dépend que de ce contrat. C’est aussi un choix : beaucoup de codes appellent directement la classe du cas d’usage. Pour le port sortant, l’interface est obligatoire : c’est elle qui permet au cœur de ne pas dépendre de JPA.
Ici :
CreateOrderServiceimplémente le port entrant et appelle le port sortant. Les deux interfaces sont dans le cœur.
Les adaptateurs
L’adaptateur sortant : JPA
L’adaptateur JPA implémente le port sortant :
@Component
class OrderPersistenceAdapter implements OrderRepository {
private final OrderJpaRepository jpaRepository;
OrderPersistenceAdapter(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public void save(Order order) {
jpaRepository.save(OrderMapper.toEntity(order));
}
}
OrderJpaRepository est une interface Spring Data
(JpaRepository<OrderJpaEntity, UUID>). Seul l’adaptateur la connaît.
L’entité JPA est une autre classe que Order :
@Entity
@Table(name = "orders")
class OrderJpaEntity {
@Id
private UUID id;
@ElementCollection
@CollectionTable(name = "order_lines", joinColumns = @JoinColumn(name = "order_id"))
private List<OrderLineEmbeddable> lines = new ArrayList<>();
protected OrderJpaEntity() {
// for JPA
}
// constructor and getters
}
Elle a les contraintes de JPA (constructeur sans argument, champs non
final), que le domaine n’a pas. Le mapper passe de l’un à l’autre :
static OrderJpaEntity toEntity(Order order) {
List<OrderLineEmbeddable> lines = order.lines().stream()
.map(line -> new OrderLineEmbeddable(line.productCode(), line.quantity(), line.unitPrice()))
.toList();
return new OrderJpaEntity(order.id(), lines);
}
Ici, le mapper ne va que dans un sens, car le port n’a que save. Un
cas d’usage de lecture ajouterait le sens inverse (entité → Order).
La base est H2, en mémoire. D’après la doc de Spring Boot, « Spring Boot can auto-configure embedded H2, HSQL, and Derby (deprecated) databases. You need not provide any connection URLs. », et les tables sont créées au démarrage pour une base embarquée. Pas de Docker pour essayer l’exemple.
L’adaptateur entrant : REST
Le contrôleur n’implémente pas le port entrant. Il l’appelle :
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final CreateOrderUseCase createOrderUseCase;
OrderController(CreateOrderUseCase createOrderUseCase) {
this.createOrderUseCase = createOrderUseCase;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
OrderResponse create(@RequestBody CreateOrderRequest request) {
Order order = createOrderUseCase.create(request.toCommand());
return OrderResponse.from(order);
}
@ExceptionHandler(InvalidOrderException.class)
ProblemDetail invalidOrder(InvalidOrderException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, exception.getMessage());
}
}
Les DTO CreateOrderRequest et OrderResponse sont des record, dans
le package de l’adaptateur. La conversion DTO → commande se fait ici
(request.toCommand()), pas dans le cœur. Une règle cassée
(InvalidOrderException) devient une réponse 400.
Le sens HTTP n’est pas le sens de l’architecture. En HTTP, la requête entre et la réponse sort. Mais pour l’architecture, le contrôleur fait une seule chose : il appelle le port entrant, et la réponse est la valeur de retour de cet appel. Il reste un adaptateur entrant.
Essai, avec l’application lancée (mvn spring-boot:run) :
$ curl -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
-d '{"lines":[{"productCode":"BOOK-1","quantity":2,"unitPrice":12.50},{"productCode":"PEN-7","quantity":3,"unitPrice":1.20}]}'
{"id":"45e27a15-2bce-454b-83a4-3b770cf674b3","lines":[{"productCode":"BOOK-1","quantity":2,"unitPrice":12.50},{"productCode":"PEN-7","quantity":3,"unitPrice":1.20}],"total":28.60}
$ curl -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
-d '{"lines":[{"productCode":"BOOK-1","quantity":0,"unitPrice":12.50}]}'
{"detail":"quantity must be between 1 and 99, got 0","instance":"/api/orders","status":400,"title":"Bad Request"}
Le total (28.60) est calculé par le domaine. Le message d’erreur vient du domaine, et l’adaptateur REST le traduit en 400.
Ici : le contrôleur appelle le port entrant ; l’adaptateur JPA implémente le port sortant. Les DTO et l’entité JPA restent dans leurs adaptateurs.
L’assemblage avec Spring
Le cœur ne connaît pas Spring. Qui donne alors un OrderRepository à
CreateOrderService ? Spring, par le constructeur. La doc de Spring
Framework le recommande : « The Spring team generally advocates
constructor injection, as it lets you implement application components
as immutable objects and ensures that required dependencies are not
null. » Et avec un seul constructeur, @Autowired n’est pas
nécessaire : « An @Autowired annotation on such a constructor is not
necessary if the target bean defines only one constructor. »
Pour créer le cas d’usage sans annotation dans le cœur, une classe
@Configuration déclare le bean :
@Configuration
class OrderConfiguration {
@Bean
CreateOrderUseCase createOrderUseCase(OrderRepository orderRepository) {
return new CreateOrderService(orderRepository);
}
}
Spring trouve le bean qui implémente OrderRepository (l’adaptateur
JPA, marqué @Component) et le passe à la méthode. Le contrôleur reçoit
le cas d’usage de la même façon, par son constructeur.
La variante courante est plus courte : @Service sur
CreateOrderService, et pas de classe de configuration. Elle marche
aussi. Sa limite : le cœur dépend alors d’une annotation Spring. Dans
le dépôt, un test ArchUnit l’interdit. Avec @Service sur le cas
d’usage, le build échoue :
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in any package ['..domain..', '..application..'] should depend on classes that reside in any package ['org.springframework..', 'jakarta..', '..adapter..', '..config..']' was violated (1 times):
Class <dev.aymenjamaaoui.hexagonal.application.CreateOrderService> is annotated with <org.springframework.stereotype.Service> in (CreateOrderService.java:0)
Ici : Spring n’appelle et n’implémente rien dans le cœur. Il crée les objets et les branche : l’adaptateur JPA dans le cas d’usage, le cas d’usage dans le contrôleur.
Le test sans Spring
Le vrai gain est là. Le cas d’usage ne dépend que d’interfaces : on peut le tester avec un faux port sortant, en mémoire.
class InMemoryOrderRepository implements OrderRepository {
private final List<Order> saved = new ArrayList<>();
@Override
public void save(Order order) {
saved.add(order);
}
List<Order> saved() {
return saved;
}
}
class CreateOrderServiceTest {
private final InMemoryOrderRepository repository = new InMemoryOrderRepository();
private final CreateOrderService service = new CreateOrderService(repository);
@Test
void savesAValidOrder() {
Order order = service.create(new CreateOrderCommand(
List.of(new OrderLine("BOOK-1", 2, new BigDecimal("12.50")))));
assertThat(repository.saved()).containsExactly(order);
assertThat(order.total()).isEqualByComparingTo("25.00");
}
@Test
void savesNothingWhenTheOrderIsInvalid() {
assertThatThrownBy(() -> service.create(new CreateOrderCommand(List.of())))
.isInstanceOf(InvalidOrderException.class);
assertThat(repository.saved()).isEmpty();
}
}
Pas de Spring, pas de base, pas de mock : un new, et c’est tout. Sur
ma machine, deux exécutions de mvn verify donnent :
| Classe de test | Ce qu’elle teste | Durée |
|---|---|---|
OrderTest | le domaine seul | 0,002 s |
CreateOrderServiceTest | le cas d’usage, avec le faux port | 0,032 à 0,034 s |
OrderApiTest | HTTP → contrôleur → cas d’usage → JPA → H2 (@SpringBootTest) | 1,94 à 2,08 s |
Le test de bout en bout reste utile : il vérifie l’assemblage. Mais les règles se testent sans démarrer Spring.
Ici : le test appelle le port entrant (à travers le cas d’usage), et le faux repository implémente le port sortant, à la place de l’adaptateur JPA.
Les erreurs fréquentes
| Faux | Juste |
|---|---|
| Le contrôleur REST implémente le port entrant. | Le contrôleur appelle le port entrant ; le cas d’usage l’implémente. |
| Le domaine reçoit le DTO ou l’entité JPA. | Le DTO et l’entité JPA restent dans leurs adaptateurs ; la conversion se fait à la frontière. |
| Le contrôleur est entrant et sortant, car la réponse HTTP « sort ». | La réponse est la valeur de retour de l’appel au port entrant. Le contrôleur est un adaptateur entrant. |
Un point dépend des choix d’équipe : où vit l’interface du repository.
Dans cet article, elle est dans application.port.out, et le domaine ne
connaît personne. Dans beaucoup de projets DDD, elle est dans le domaine.
Le guide .NET de Microsoft sur DDD le dit ainsi : « it’s recommended
that you define and place the repository interfaces in the domain model
layer ». Les deux marchent. Ce qui compte : l’interface appartient au
cœur, et l’implémentation est dehors.
Ici : dans les deux variantes, le cœur appelle l’interface et l’adaptateur l’implémente. Seul le package de l’interface change.
Quand ne pas l’utiliser, et fiche récap
Cette organisation a un coût. Pour une seule fonction (créer une
commande), le dépôt a 17 classes dans src/main, dont 3 pour les
ports, une entité JPA et un mapper à côté du domaine. Pour un simple
CRUD, sans vraie règle métier, une application en couches suffit : il
n’y a rien à protéger au centre.
Elle paie quand les règles grandissent, quand une technologie change (une autre base, des messages Kafka en plus du REST), ou quand on veut tester les règles vite et souvent.
Fiche récap
| Élément | Package | Qui l’appelle | Qui l’implémente |
|---|---|---|---|
Order (domaine) | domain | le cas d’usage | — (une classe) |
CreateOrderUseCase (port entrant) | application.port.in | OrderController | CreateOrderService |
CreateOrderService (cas d’usage) | application | le contrôleur, à travers le port entrant | — |
OrderRepository (port sortant) | application.port.out | CreateOrderService | OrderPersistenceAdapter |
OrderController (adaptateur entrant) | adapter.in.web | Spring, pour chaque requête HTTP | — |
OrderPersistenceAdapter (adaptateur sortant) | adapter.out.persistence | le cas d’usage, à travers le port sortant | — |
Trois règles à garder :
- les dépendances pointent vers le cœur ;
- côté entrant, l’adaptateur appelle et le cœur implémente ; côté sortant, le cœur appelle et l’adaptateur implémente ;
- les DTO et les entités JPA ne passent pas la frontière.
Pour que la règle 1 tienne dans le temps, un test ArchUnit la vérifie à chaque build : voir la partie « La règle, vérifiée par le build » de l’exemple avec Kafka.
Testé avec
Java 25 (Temurin 25.0.5), Maven 3.10.0, Spring Boot 4.1.2 (Hibernate
7.4.13), H2 2.4.240 et ArchUnit 1.5.1, sur macOS. Le code est figé par
le tag
v1
du dépôt de démo.
Sources
- Alistair Cockburn, Hexagonal architecture (2005)
- Microsoft Learn, Designing the infrastructure persistence layer (guide .NET, partie DDD)
- Spring Framework, Dependency Injection, Using @Autowired, @Bean and @Configuration
- Spring Boot, SQL Databases
- ArchUnit User Guide
Pour aller plus loin
- Architecture hexagonale avec Spring Boot : un exemple simple :
les mêmes notions sur un vrai service, avec Kafka, un résultat
sealedet ArchUnit. - Kafka et Spring Boot : un projet complet en local