« 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.

À gauche, une application multicouche : le contrôleur dépend du service, le service dépend du repository JPA, le repository dépend de la base ; les flèches descendent, et le métier dépend de JPA. À droite, l'architecture hexagonale : au centre, le cœur (domaine et cas d'usage, en Java pur) ; l'adaptateur REST et l'adaptateur JPA dépendent du cœur ; les flèches pointent vers le centre.MulticoucheOrderControllerwebOrderServicemétierOrderRepositoryJPAbase de donnéesLes flèches descendent : le métier dépend de JPA.HexagonalCœurdomaine +cas d'usageJava purRESTadaptateurJPAadaptateurbaseLes flèches pointent vers le cœur.

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 ».

Vue d'ensemble de l'exemple. Côté entrant : OrderController appelle le port entrant CreateOrderUseCase, et CreateOrderService l'implémente. Au centre : CreateOrderService appelle Order, le domaine. Côté sortant : CreateOrderService appelle le port sortant OrderRepository, et OrderPersistenceAdapter l'implémente, puis écrit dans la base H2. Trait plein : appelle ; trait pointillé : implémente.Le cœur (Java pur)CreateOrderServicecas d'usageCreateOrderUseCaseport entrantOrderRepositoryport sortantOrderdomaineimplémenteappelleappellerequête HTTPOrderControlleradaptateur entrantappelleOrderPersistenceAdapteradaptateur sortantimplémentebase H2appelleimplé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 :

PackageContenuConnaît Spring ?
domainOrder, OrderLine, InvalidOrderExceptionnon
application.port.inCreateOrderUseCase, CreateOrderCommandnon
application.port.outOrderRepositorynon
applicationCreateOrderServicenon
adapter.in.weble contrôleur REST et ses DTOoui
adapter.out.persistencel’entité JPA, l’adaptateur, le mapperoui
configl’assemblage avec Springoui

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 : CreateOrderService implé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 testCe qu’elle testeDurée
OrderTestle domaine seul0,002 s
CreateOrderServiceTestle cas d’usage, avec le faux port0,032 à 0,034 s
OrderApiTestHTTP → 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

FauxJuste
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émentPackageQui l’appelleQui l’implémente
Order (domaine)domainle cas d’usage— (une classe)
CreateOrderUseCase (port entrant)application.port.inOrderControllerCreateOrderService
CreateOrderService (cas d’usage)applicationle contrôleur, à travers le port entrant—
OrderRepository (port sortant)application.port.outCreateOrderServiceOrderPersistenceAdapter
OrderController (adaptateur entrant)adapter.in.webSpring, pour chaque requête HTTP—
OrderPersistenceAdapter (adaptateur sortant)adapter.out.persistencele cas d’usage, à travers le port sortant—

Trois règles à garder :

  1. les dépendances pointent vers le cœur ;
  2. côté entrant, l’adaptateur appelle et le cœur implémente ; côté sortant, le cœur appelle et l’adaptateur implémente ;
  3. 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

Pour aller plus loin