Clean code & architecture

Architecture hexagonale Spring Boot : guide pratique avec exemple

Organiser un projet Spring Boot en ports et adaptateurs : domaine en Java pur, ports entrants et sortants, adaptateurs REST et JPA, et des tests métier qui tournent sans Spring.

Illustration : un domaine métier au centre d’un hexagone, relié à des adaptateurs en périphérie
Lexique
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 @Entity sur 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

Une leçon, un kata, chaque mois

Les nouveaux articles et un exercice de refactoring, directement par e-mail.

S'inscrire à la newsletter