Spring Boot 4 — jeandecode.fr
Partie 03 · Projet 3

Architecture Hexagonale & Domain-Driven Design

L'architecture hexagonale (aussi appelée Ports & Adapters) place le domaine métier au centre et isole toute dépendance technique aux bords. Couplée au DDD, elle produit un code dont la structure reflète le métier, pas le framework.

Le problème des architectures en couches classiques

Dans le Projet 1, on a construit une architecture en 4 couches classique : Controller → Service → Repository → Entity. Cette architecture fonctionne bien, mais elle a un problème structurel : le domaine métier dépend de l'infrastructure.

// ❌ Problème : l'entité métier est couplée à JPA (infrastructure) @Entity // annotation JPA — framework @Table(name = "articles") // détail SQL — infrastructure @Getter @Setter // Lombok — outil externe public class Article { @Id @GeneratedValue // stratégie de persistence — infrastructure private Long id; // Le domaine est "pollué" par les préoccupations techniques // On ne peut pas tester Article sans JPA, sans une base de données }

Conséquences concrètes : les tests unitaires du domaine nécessitent un contexte Spring, les règles métier sont dispersées entre les annotations JPA, les services et les controllers, et changer de base de données (PostgreSQL → MongoDB) oblige à modifier les classes du domaine.

L'architecture hexagonale

L'idée centrale est simple : inverser les dépendances. Au lieu que le domaine dépende de l'infrastructure, c'est l'infrastructure qui dépend du domaine. Le domaine ne connaît pas Spring, pas JPA, pas HTTP — il ne connaît que Java pur.

REST Controller (Adapter IN)
Reçoit HTTP, convertit en commande du domaine, appelle un Use Case
↓ implémente Port IN (interface)
Use Cases
Orchestrent le domaine. Définissent ce qu'on peut faire (ports IN).
↓ utilise
Domain (Agrégats, VO, Entités)
Règles métier pures. Aucune dépendance externe. Testable sans framework.
↓ appelle Port OUT (interface)
Repository JPA (Adapter OUT)
Implémente le Port OUT. Traduit domaine ↔ tables SQL. Dépend du domaine.
ConceptRôleExemple
AgrégatUnité de cohérence du domaine, racine de la transactionOrder contient ses OrderLine
Value Object (VO)Objet immuable identifié par sa valeur, pas son idMoney, Email, Quantity
Port INInterface que le domaine expose aux adaptateurs entrantsCreateOrderUseCase
Port OUTInterface que le domaine exige de l'infrastructureOrderRepository (interface dans le domaine)
Adapter INTraduit le monde extérieur → domaineOrderController (REST)
Adapter OUTTraduit le domaine → infrastructureOrderJpaAdapter
Use CaseImplémentation d'un Port IN — orchestre le domaineCreateOrderUseCaseImpl

Structure du projet

On construit un service de gestion de commandes e-commerce avec 3 agrégats : Order, Product et User. On se concentre ici sur order-service — les autres services ont la même structure.

hexagonal-order-service/ └── src/main/java/fr/jeandecode/order/ │ ├── domain/ # LE CŒUR — aucune dépendance externe │ ├── model/ # agrégats, entités, value objects │ │ ├── Order.java # agrégat Order │ │ ├── OrderLine.java # entité dans l'agrégat │ │ ├── OrderStatus.java # enum du domaine │ │ ├── Product.java # entité produit (vue du domaine) │ │ └── vo/ # value objects │ │ ├── Money.java │ │ ├── Quantity.java │ │ ├── OrderId.java │ │ └── ProductId.java │ ├── port/ │ │ ├── in/ # ports entrants — ce que le domaine expose │ │ │ ├── CreateOrderUseCase.java # interface │ │ │ ├── GetOrderUseCase.java │ │ │ └── CancelOrderUseCase.java │ │ └── out/ # ports sortants — ce que le domaine exige │ │ ├── OrderRepository.java # interface (pas l'implem JPA) │ │ ├── ProductRepository.java │ │ └── UserRepository.java │ └── service/ # use cases — implémentent les ports IN │ ├── CreateOrderService.java │ ├── GetOrderService.java │ └── CancelOrderService.java │ ├── application/ # configuration Spring — assemble les pièces │ └── config/ │ └── OrderBeanConfig.java # @Bean : relie use cases ↔ adapters │ └── adapter/ # ADAPTATEURS — dépendent du domaine ├── in/ # adaptateurs entrants (driving) │ └── web/ │ ├── OrderController.java # REST Controller │ ├── dto/ │ │ ├── CreateOrderRequest.java │ │ └── OrderResponse.java │ └── mapper/OrderWebMapper.java └── out/ # adaptateurs sortants (driven) ├── persistence/ │ ├── OrderJpaAdapter.java # implémente OrderRepository du domaine │ ├── entity/OrderJpaEntity.java # entité JPA (séparée du modèle domaine) │ ├── entity/OrderLineJpaEntity.java │ ├── repository/OrderJpaRepository.java # Spring Data JPA │ └── mapper/OrderPersistenceMapper.java └── client/ ├── ProductFeignAdapter.java # implémente ProductRepository via Feign └── UserFeignAdapter.java
La règle de dépendance — les flèches de dépendance pointent toujours VERS le domaine, jamais depuis le domaine vers l'extérieur. Le dossier domain/ ne doit avoir aucun import de Spring, JPA, ou Feign.

pom.xml — organisation des modules Maven

Pour renforcer l'isolation, on peut organiser le projet en modules Maven : le module domain ne peut pas importer le module adapter même accidentellement.

<project> <modules> <!-- Le module domain n'a aucune dépendance Spring/JPA dans son pom.xml --> <module>domain</module> <!-- Le module adapter dépend de domain — pas l'inverse --> <module>adapter</module> <!-- Le module application assemble tout avec Spring Boot --> <module>application</module> </modules> </project>
<project> <artifactId>order-domain</artifactId> <dependencies> <!-- Lombok uniquement — pour réduire le boilerplate --> <!-- AUCUNE dépendance Spring, JPA, Feign ici --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> </project>
Module Maven séparé pour le domaine — si on essaie d'importer @Entity ou @RestController dans le module domain, Maven lève une erreur de compilation. L'isolation est garantie structurellement, pas seulement par convention.
✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • hexagonal-order-service/ (dossier racine)
  • hexagonal-order-service/pom.xml (parent multi-module)
  • hexagonal-order-service/domain/pom.xml (aucune dépendance Spring/JPA)
  • hexagonal-order-service/adapter/pom.xml
  • hexagonal-order-service/application/pom.xml (Spring Boot main)

Étapes

  1. Créer la structure de dossiers telle qu'indiquée ci-dessus
  2. Configurer le pom.xml parent avec les 3 modules
  3. Vérifier que domain/pom.xml n'a AUCUN import Spring ou JPA
  4. Faire hériter adapter/pom.xml de order-domain
Partie 03 · Projet 3

Le Domaine — Agrégats & Value Objects

Le domaine est le cœur du système. Il contient les règles métier, exprimées en Java pur, sans aucune dépendance technique. Ce chapitre construit les agrégats Order, Product et les Value Objects associés.

Value Objects — l'identité par la valeur

Un Value Object (VO) n'a pas d'identité propre — il est identifié uniquement par sa valeur. Deux instances de Money(100, "EUR") sont égales. Les VOs sont immuables : on ne peut pas modifier un VO, on en crée un nouveau.

En Java, les record sont parfaits pour les Value Objects :

// Record Java = Value Object parfait : immuable, equals/hashCode automatiques // Aucune annotation Spring ou JPA — Java pur public record Money(BigDecimal amount, String currency) { // Validation dans le constructeur compact — le VO est toujours valide public Money { Objects.requireNonNull(amount, "Le montant est obligatoire"); Objects.requireNonNull(currency, "La devise est obligatoire"); if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("Un montant ne peut pas être négatif : " + amount); } if (currency.length() != 3) { throw new IllegalArgumentException("La devise doit être un code ISO 3 lettres : " + currency); } } // Opérations métier — les calculs vivent dans le VO, pas dans le service public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException( "Impossible d'additionner " + this.currency + " et " + other.currency); } return new Money(this.amount.add(other.amount), this.currency); } public Money multiply(int factor) { return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency); } public boolean isGreaterThan(Money other) { return this.amount.compareTo(other.amount) > 0; } // Fabrique statique — plus lisible que new Money(...) public static Money of(BigDecimal amount, String currency) { return new Money(amount, currency); } public static Money zero(String currency) { return new Money(BigDecimal.ZERO, currency); } }
// Wrapper d'identifiant — évite de confondre un OrderId avec un ProductId // Long orderId et Long productId ont le même type — le compilateur ne peut pas détecter les erreurs // OrderId orderId et ProductId productId : le compilateur garantit qu'on ne les mélange pas public record OrderId(Long value) { public OrderId { Objects.requireNonNull(value, "L'ID de commande est obligatoire"); } public static OrderId of(Long value) { return new OrderId(value); } }
public record Quantity(int value) { public Quantity { if (value <= 0) throw new IllegalArgumentException("La quantité doit être positive : " + value); } public static Quantity of(int value) { return new Quantity(value); } public Quantity add(Quantity other) { return new Quantity(this.value + other.value); } public Quantity subtract(Quantity other) { if (other.value > this.value) throw new IllegalArgumentException("Stock insuffisant"); return new Quantity(this.value - other.value); } }
Pourquoi les VOs ? — Au lieu d'un service qui fait if (price < 0) throw..., la règle est dans le VO et s'applique partout automatiquement. On ne peut pas créer un Money invalide — c'est structurellement impossible. C'est le principe de Make illegal states unrepresentable.

Agrégat Product

// Entité du domaine — représente un produit tel que le domaine Order le voit // Ce n'est PAS l'entité JPA : pas de @Entity, pas de @Column // C'est ce que order-service a besoin de savoir sur un produit @Getter public class Product { private final ProductId id; private final String name; private final Money price; private Quantity stockQuantity; // Constructeur privé — on passe par les fabriques statiques private Product(ProductId id, String name, Money price, Quantity stockQuantity) { this.id = id; this.name = name; this.price = price; this.stockQuantity = stockQuantity; } // Fabrique statique — nom expressif qui décrit l'intention public static Product reconstitute(ProductId id, String name, Money price, Quantity stock) { Objects.requireNonNull(id, "L'ID produit est obligatoire"); Objects.requireNonNull(name, "Le nom est obligatoire"); Objects.requireNonNull(price, "Le prix est obligatoire"); Objects.requireNonNull(stock, "Le stock est obligatoire"); return new Product(id, name, price, stock); } // Règle métier : le produit sait si son stock est suffisant public boolean hasEnoughStock(Quantity requested) { return this.stockQuantity.value() >= requested.value(); } // Comportement métier : diminuer le stock — protège l'invariant public void decreaseStock(Quantity quantity) { if (!hasEnoughStock(quantity)) { throw new InsufficientStockException( "Stock insuffisant pour " + name + " : demandé " + quantity.value() + ", disponible " + stockQuantity.value() ); } this.stockQuantity = stockQuantity.subtract(quantity); } }
Constructeur privé + fabrique statique — la méthode reconstitute() indique qu'on recrée un produit existant depuis la base de données (il a déjà un ID). On pourrait avoir create() pour un nouveau produit (sans ID, qui sera généré). Cette distinction est importante en DDD.

Agrégat Order — racine de l'agrégat

L'agrégat Order est la racine de l'agrégat — c'est lui qui garantit la cohérence de l'ensemble. On ne peut pas manipuler OrderLine directement : toutes les modifications passent par Order.

// Agrégat Order — racine de cohérence, garantit tous les invariants @Getter public class Order { private OrderId id; // null pour une nouvelle commande private UserId customerId; private List lines; // lignes de commande private OrderStatus status; private Money totalAmount; private LocalDateTime createdAt; // ── Créer une nouvelle commande ───────────────────────── // Fabrique statique qui exprime l'intention métier public static Order create(UserId customerId) { Order order = new Order(); order.customerId = customerId; order.lines = new ArrayList<>(); order.status = OrderStatus.DRAFT; order.totalAmount = Money.zero("EUR"); order.createdAt = LocalDateTime.now(); return order; } // ── Reconstituer depuis la persistence ────────────────── public static Order reconstitute(OrderId id, UserId customerId, List lines, OrderStatus status, Money total, LocalDateTime createdAt) { Order order = new Order(); order.id = id; order.customerId = customerId; order.lines = new ArrayList<>(lines); order.status = status; order.totalAmount = total; order.createdAt = createdAt; return order; } // ── Comportements métier ───────────────────────────────── // Ajouter un produit à la commande — toutes les règles ici public void addLine(Product product, Quantity quantity) { // Invariant 1 : on ne peut ajouter des lignes qu'à une commande DRAFT if (status != OrderStatus.DRAFT) { throw new OrderStateException("Impossible d'ajouter un produit à une commande " + status); } // Invariant 2 : le stock doit être suffisant if (!product.hasEnoughStock(quantity)) { throw new InsufficientStockException(product.getName(), quantity); } // Vérifier si le produit est déjà dans la commande Optional existing = lines.stream() .filter(l -> l.getProductId().equals(product.getId())) .findFirst(); if (existing.isPresent()) { // Augmenter la quantité de la ligne existante existing.get().increaseQuantity(quantity); } else { // Ajouter une nouvelle ligne lines.add(OrderLine.create(product.getId(), product.getName(), product.getPrice(), quantity)); } // Recalculer le total — toujours cohérent recalculateTotal(); } // Confirmer la commande — règles métier centralisées public void confirm() { if (status != OrderStatus.DRAFT) { throw new OrderStateException("Seule une commande DRAFT peut être confirmée"); } if (lines.isEmpty()) { throw new OrderStateException("Impossible de confirmer une commande sans lignes"); } this.status = OrderStatus.CONFIRMED; } public void cancel() { if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) { throw new OrderStateException("Impossible d'annuler une commande déjà expédiée"); } this.status = OrderStatus.CANCELLED; } // Méthode privée — cohérence interne garantie private void recalculateTotal() { this.totalAmount = lines.stream() .map(OrderLine::getSubtotal) .reduce(Money.zero("EUR"), Money::add); } }
// Entité de l'agrégat — accessible uniquement via Order (pas de repository dédié) @Getter public class OrderLine { private OrderLineId lineId; private ProductId productId; private String productName; // dénormalisé pour éviter les jointures private Money unitPrice; private Quantity quantity; static OrderLine create(ProductId productId, String name, Money price, Quantity qty) { OrderLine line = new OrderLine(); line.productId = productId; line.productName = name; line.unitPrice = price; line.quantity = qty; return line; } void increaseQuantity(Quantity additional) { this.quantity = this.quantity.add(additional); } public Money getSubtotal() { return unitPrice.multiply(quantity.value()); } }
public enum OrderStatus { DRAFT, // en cours de construction CONFIRMED, // confirmée, sera traitée SHIPPED, // expédiée DELIVERED, // livrée CANCELLED // annulée }
Les règles métier dans l'agrégat — la méthode confirm() vérifie que la commande est en DRAFT et qu'elle a des lignes. Ces règles sont testables avec un simple new Order(), sans Spring, sans base de données. Un test unitaire s'exécute en quelques millisecondes.

Exceptions du domaine

Le domaine définit ses propres exceptions — elles expriment des concepts métier, pas des erreurs techniques.

// Exception métier — pas de dépendance Spring ou HTTP // La conversion en HTTP 400/409 se fait dans l'adapter REST, pas ici public class OrderStateException extends RuntimeException { public OrderStateException(String message) { super(message); } }
public class InsufficientStockException extends RuntimeException { private final String productName; private final Quantity requested; public InsufficientStockException(String productName, Quantity requested) { super("Stock insuffisant pour " + productName + " : demandé " + requested.value()); this.productName = productName; this.requested = requested; } public String getProductName() { return productName; } public Quantity getRequested() { return requested; } }
✓ Test du domaine sans Spring

C'est ici que réside le vrai avantage de l'architecture hexagonale. On peut tester toutes les règles métier avec de simples tests JUnit 5, sans @SpringBootTest, sans base de données :

@Test
void confirm_shouldFail_whenNoLines() {
    Order order = Order.create(UserId.of(1L));
    assertThrows(OrderStateException.class, order::confirm);
}

@Test
void addLine_shouldCalculateTotal() {
    Order order = Order.create(UserId.of(1L));
    Product p = Product.reconstitute(ProductId.of(1L), "T-shirt",
        Money.of(new BigDecimal("29.99"), "EUR"), Quantity.of(10));
    order.addLine(p, Quantity.of(2));
    assertEquals(new BigDecimal("59.98"), order.getTotalAmount().amount());
}
✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/Money.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/Quantity.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/OrderId.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/ProductId.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/UserId.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/Product.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/Order.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/OrderLine.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/OrderStatus.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/exception/OrderStateException.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/exception/InsufficientStockException.java

Étapes

  1. Créer tous les Value Objects (Money, Quantity, OrderId, ProductId, UserId) avec validation dans le constructeur compact
  2. Créer l'entité Product avec les fabriques create() et reconstitute()
  3. Créer l'agrégat Order avec les méthodes addLine(), confirm(), cancel()
  4. Créer OrderLine avec la méthode getSubtotal()
  5. Créer les exceptions du domaine
  6. Écrire les tests unitaires du domaine — vérifier toutes les règles métier sans Spring
  7. Vérifier qu'il n'y a aucun import Spring/JPA dans le dossier domain/
Partie 03 · Projet 3

Ports & Use Cases

Les ports sont les interfaces qui définissent les frontières du domaine. Les use cases implémentent les ports entrants — ils orchestrent le domaine pour répondre à une intention métier précise.

Ports IN — ce que le domaine expose

Un port entrant (Port IN ou driving port) est une interface qui décrit ce qu'on peut faire avec le système. Chaque use case correspond à une intention métier précise. On les préfixe souvent par le nom de l'opération.

// Interface dans le domaine — aucune dépendance Spring // L'adapter REST (Controller) appellera cette interface, pas l'implémentation directement public interface CreateOrderUseCase { // La Command est un objet immuable qui encapsule les données nécessaires à l'opération // On utilise un record : plus lisible que des paramètres individuels, immuable, auto-documenté record CreateOrderCommand( Long customerId, Long productId, int quantity ) {} // Retourner l'ID de la commande créée — permettra au client de la récupérer OrderId execute(CreateOrderCommand command); }
public interface GetOrderUseCase { // Une Query (différente d'une Command) — lecture seule, pas de modification d'état // Principe CQRS léger : séparer les opérations de lecture des opérations d'écriture record GetOrderQuery(Long orderId) {} Order findById(GetOrderQuery query); List findByCustomer(Long customerId); }
public interface CancelOrderUseCase { record CancelOrderCommand(Long orderId, Long customerId, String reason) {} void execute(CancelOrderCommand command); }
Command vs Query — une Command modifie l'état du système et retourne l'ID de l'objet créé/modifié (ou void). Une Query lit l'état et retourne des données. Cette distinction (CQRS léger) améliore la lisibilité et aide à séparer les préoccupations.

Ports OUT — ce que le domaine exige

Un port sortant (Port OUT ou driven port) est une interface que le domaine définit et dont il a besoin — mais dont il ne connaît pas l'implémentation. C'est ici qu'on inverse les dépendances : le domaine ne dépend pas de JPA, il définit une interface, et c'est JPA qui l'implémente.

// Interface dans le DOMAINE — pas dans la couche JPA // Le domaine définit ce dont il a besoin, l'infrastructure s'adapte // Aucun import JPA ici — Java pur public interface OrderRepository { // Sauvegarder ou mettre à jour une commande Order save(Order order); // Trouver par ID — retourne Optional pour forcer la gestion du "pas trouvé" Optional findById(OrderId id); // Trouver toutes les commandes d'un client List findByCustomerId(UserId customerId); // Vérifier l'existence boolean existsById(OrderId id); }
// Interface dans le domaine — le domaine Order a besoin de consulter les produits // L'implémentation sera un adapter Feign (appel vers product-service) // Le domaine ne sait pas que product-service existe — il voit juste ce contrat public interface ProductRepository { // Trouver un produit par son ID Optional findById(ProductId id); // Diminuer le stock d'un produit (appel distant vers product-service) void decreaseStock(ProductId productId, Quantity quantity); }
Le nom 'Repository' dans le domaine — ce n'est pas le JpaRepository de Spring Data. C'est une interface métier pure qui exprime ce que le domaine Order a besoin de faire avec les données. L'implémentation (JPA, API REST, cache...) est dans l'adapter, pas dans le domaine.

Implémentation des Use Cases

Les services du domaine (use case implementations) implémentent les ports IN et utilisent les ports OUT. Ils sont dans le domaine — pas d'annotations Spring sauf @Service qui peut être acceptable si on accepte cette légère dépendance.

// Implémentation du use case CreateOrder // Dépend uniquement d'interfaces (ports OUT) — jamais des implémentations concrètes public class CreateOrderService implements CreateOrderUseCase { // Ports OUT — injectés par le module application (via @Bean dans OrderBeanConfig) // Le domaine ne connaît que les interfaces, pas JpaAdapter ni FeignAdapter private final OrderRepository orderRepository; private final ProductRepository productRepository; private final UserRepository userRepository; // Constructeur sans @Autowired — l'injection se fait depuis OrderBeanConfig public CreateOrderService(OrderRepository orderRepository, ProductRepository productRepository, UserRepository userRepository) { this.orderRepository = orderRepository; this.productRepository = productRepository; this.userRepository = userRepository; } @Override public OrderId execute(CreateOrderCommand command) { // 1. Vérifier que le client existe (via UserRepository — port OUT) userRepository.findById(UserId.of(command.customerId())) .orElseThrow(() -> new UserNotFoundException(command.customerId())); // 2. Récupérer le produit (via ProductRepository — port OUT → Feign → product-service) Product product = productRepository.findById(ProductId.of(command.productId())) .orElseThrow(() -> new ProductNotFoundException(command.productId())); // 3. Créer la commande — règles métier dans l'agrégat Order order = Order.create(UserId.of(command.customerId())); order.addLine(product, Quantity.of(command.quantity())); // vérifie le stock, calcule le total order.confirm(); // vérifie qu'il y a des lignes, change le statut // 4. Diminuer le stock dans product-service (via ProductRepository — port OUT) productRepository.decreaseStock( ProductId.of(command.productId()), Quantity.of(command.quantity()) ); // 5. Persister la commande (via OrderRepository — port OUT → JPA) Order savedOrder = orderRepository.save(order); return savedOrder.getId(); } }
public class CancelOrderService implements CancelOrderUseCase { private final OrderRepository orderRepository; public CancelOrderService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Override public void execute(CancelOrderCommand command) { // Récupérer la commande — lever une exception métier si introuvable Order order = orderRepository.findById(OrderId.of(command.orderId())) .orElseThrow(() -> new OrderNotFoundException(command.orderId())); // Vérifier que l'utilisateur a le droit d'annuler (règle métier) if (!order.getCustomerId().value().equals(command.customerId())) { throw new OrderAccessDeniedException( "L'utilisateur " + command.customerId() + " n'a pas le droit d'annuler la commande " + command.orderId() ); } // Déléguer l'annulation à l'agrégat — les règles d'état sont dans Order.cancel() order.cancel(); orderRepository.save(order); } }
La testabilité en action — CreateOrderService ne dépend que d'interfaces. En test, on peut injecter des mocks Mockito ou des implémentations en mémoire. Le test est instantané, sans base de données, sans réseau.
✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/in/CreateOrderUseCase.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/in/GetOrderUseCase.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/in/CancelOrderUseCase.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/out/OrderRepository.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/out/ProductRepository.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/out/UserRepository.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/service/CreateOrderService.java
  • hexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/service/CancelOrderService.java

Étapes

  1. Créer les interfaces des ports IN avec les records Command/Query internes
  2. Créer les interfaces des ports OUT (OrderRepository, ProductRepository, UserRepository)
  3. Implémenter CreateOrderService — injecter les ports OUT par constructeur
  4. Implémenter CancelOrderService et GetOrderService
  5. Écrire les tests des use cases avec des mocks Mockito pour les ports OUT
  6. Vérifier : aucun import Spring dans les classes du domaine (sauf @Service optionnel)
Partie 03 · Projet 3

Adapter REST — Driving Adapter

L'adapter REST est un driving adapter : il reçoit les requêtes HTTP, les traduit en commandes du domaine, appelle le use case correspondant, et transforme le résultat en réponse HTTP. Il ne contient aucune logique métier.

Le rôle de l'adapter REST

L'adapter REST a trois responsabilités — et uniquement trois :

  1. Recevoir la requête HTTP et valider le format (JSON, contraintes @Valid)
  2. Mapper la requête HTTP → Command du domaine
  3. Appeler le use case et mapper le résultat → réponse HTTP

Tout ce qui ressemble à une règle métier ("on ne peut pas annuler une commande expédiée") doit être dans le domaine, pas ici.

DTOs de l'adapter

Les DTOs de l'adapter sont différents des objets du domaine — ils sont conçus pour le format HTTP (JSON), pas pour la logique métier.

// DTO entrant — représente ce que le client envoie en JSON // Ici uniquement : contraintes de format (@NotNull, @Min) et désérialisation JSON // Pas de règle métier ici public record CreateOrderRequest( @NotNull(message = "L'ID du produit est obligatoire") Long productId, @Min(value = 1, message = "La quantité doit être au moins 1") int quantity // Pas de customerId ici — il vient du header X-User-Email (injecté par la Gateway) ) {}
// DTO sortant — représente ce qu'on retourne au client en JSON // On expose uniquement ce que le client a besoin de voir public record OrderResponse( Long id, String status, String customerEmail, List lines, BigDecimal totalAmount, String currency, String createdAt // en String ISO 8601 pour faciliter la sérialisation ) { // Record imbriqué pour les lignes public record OrderLineResponse( Long productId, String productName, int quantity, BigDecimal unitPrice, BigDecimal subtotal ) {} }

Mapper — traduction Domain ↔ DTO

Le mapper est responsable de la traduction entre les objets du domaine et les DTOs de l'adapter. C'est lui qui connaît les deux mondes.

@Component public class OrderWebMapper { // Domaine Order → DTO OrderResponse (pour la réponse HTTP) public OrderResponse toResponse(Order order) { List lineResponses = order.getLines().stream() .map(line -> new OrderResponse.OrderLineResponse( line.getProductId().value(), line.getProductName(), line.getQuantity().value(), line.getUnitPrice().amount(), line.getSubtotal().amount() )) .collect(Collectors.toList()); return new OrderResponse( order.getId().value(), order.getStatus().name(), null, // customerEmail : à récupérer depuis le header ou un service user lineResponses, order.getTotalAmount().amount(), order.getTotalAmount().currency(), order.getCreatedAt().toString() ); } // DTO CreateOrderRequest + headers → Command du domaine public CreateOrderUseCase.CreateOrderCommand toCommand( CreateOrderRequest request, Long customerId) { return new CreateOrderUseCase.CreateOrderCommand( customerId, request.productId(), request.quantity() ); } }

Le Controller — orchestrateur léger

@RestController @RequestMapping("/api/orders") @RequiredArgsConstructor public class OrderController { // Le controller dépend des INTERFACES (ports IN), jamais des implémentations // Si demain on change CreateOrderService, le controller ne change pas private final CreateOrderUseCase createOrderUseCase; private final GetOrderUseCase getOrderUseCase; private final CancelOrderUseCase cancelOrderUseCase; private final OrderWebMapper mapper; // POST /api/orders — créer une commande @PostMapping public ResponseEntity create( @Valid @RequestBody CreateOrderRequest request, // L'ID client vient du header injecté par la Gateway (pas du body) @RequestHeader("X-User-Email") String userEmail) { // Résoudre l'email en ID (simplification — en prod : appel vers user-service) Long customerId = resolveCustomerId(userEmail); // Mapper DTO → Command, appeler le use case OrderId orderId = createOrderUseCase.execute(mapper.toCommand(request, customerId)); // Retourner 201 Created avec l'URL de la nouvelle ressource URI location = URI.create("/api/orders/" + orderId.value()); return ResponseEntity.created(location).build(); } // GET /api/orders/{id} @GetMapping("/{id}") public ResponseEntity findById( @PathVariable Long id, @RequestHeader("X-User-Email") String userEmail) { var query = new GetOrderUseCase.GetOrderQuery(id); Order order = getOrderUseCase.findById(query); return ResponseEntity.ok(mapper.toResponse(order)); } // GET /api/orders/my @GetMapping("/my") public ResponseEntity> myOrders( @RequestHeader("X-User-Email") String userEmail) { Long customerId = resolveCustomerId(userEmail); List orders = getOrderUseCase.findByCustomer(customerId); return ResponseEntity.ok(orders.stream().map(mapper::toResponse).toList()); } // DELETE /api/orders/{id} @DeleteMapping("/{id}") public ResponseEntity cancel( @PathVariable Long id, @RequestHeader("X-User-Email") String userEmail, @RequestParam(required = false) String reason) { Long customerId = resolveCustomerId(userEmail); var command = new CancelOrderUseCase.CancelOrderCommand(id, customerId, reason); cancelOrderUseCase.execute(command); return ResponseEntity.noContent().build(); } // Gestionnaire d'exceptions du domaine → réponses HTTP appropriées @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity handleNotFound(OrderNotFoundException e) { ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND); pd.setTitle("Commande introuvable"); pd.setDetail(e.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd); } @ExceptionHandler(OrderStateException.class) public ResponseEntity handleOrderState(OrderStateException e) { ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT); pd.setTitle("Opération impossible sur cette commande"); pd.setDetail(e.getMessage()); return ResponseEntity.status(HttpStatus.CONFLICT).body(pd); } @ExceptionHandler(InsufficientStockException.class) public ResponseEntity handleStock(InsufficientStockException e) { ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); pd.setTitle("Stock insuffisant"); pd.setDetail(e.getMessage()); pd.setProperty("product", e.getProductName()); return ResponseEntity.badRequest().body(pd); } // En production : appel vers user-service via Feign pour résoudre email → userId private Long resolveCustomerId(String email) { // Simplifié ici — voir UserFeignAdapter dans l'adapter OUT return 1L; } }
Le controller dépend des interfaces — private final CreateOrderUseCase createOrderUseCase et non pas CreateOrderService. C'est ce qui rend le controller testable avec un mock : on peut tester le controller HTTP sans exécuter la logique métier.
✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • hexagonal-order-service/adapter/src/main/java/fr/jeandecode/order/adapter/in/web/dto/CreateOrderRequest.java
  • hexagonal-order-service/adapter/src/main/java/fr/jeandecode/order/adapter/in/web/dto/OrderResponse.java
  • hexagonal-order-service/adapter/src/main/java/fr/jeandecode/order/adapter/in/web/mapper/OrderWebMapper.java
  • hexagonal-order-service/adapter/src/main/java/fr/jeandecode/order/adapter/in/web/OrderController.java

Étapes

  1. Créer les DTOs CreateOrderRequest (avec @Valid) et OrderResponse
  2. Créer OrderWebMapper avec les méthodes toResponse(Order) et toCommand()
  3. Créer OrderController qui dépend des interfaces (ports IN), pas des implémentations
  4. Gérer les exceptions du domaine → réponses HTTP dans le controller (ProblemDetail RFC 7807)
  5. Tester avec @WebMvcTest(OrderController.class) + @MockitoBean pour les use cases
Partie 03 · Projet 3

Adapter JPA — Driven Adapter

L'adapter JPA est un driven adapter : il implémente les ports OUT définis par le domaine. Il traduit les objets du domaine en entités JPA, persiste les données, et reconstruit les objets du domaine depuis la base.

Entités JPA séparées du modèle domaine

C'est la différence fondamentale avec l'architecture en couches classique. Dans le Projet 1, Article était à la fois l'entité JPA et l'objet métier. Ici, on a deux représentations séparées :

  • Order (dans domain/) — objet métier pur, sans annotation JPA
  • OrderJpaEntity (dans adapter/out/persistence/) — entité JPA, sans logique métier

Le mapper traduit entre les deux. C'est plus de code, mais ça garantit que les deux préoccupations restent séparées.

// Entité JPA pure — uniquement pour la persistence // Pas de logique métier, pas de règles de domaine @Entity @Table(name = "orders") @Getter @Setter @NoArgsConstructor public class OrderJpaEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "customer_id", nullable = false) private Long customerId; @Enumerated(EnumType.STRING) @Column(nullable = false) private OrderStatus status; @Column(name = "total_amount", nullable = false, precision = 10, scale = 2) private BigDecimal totalAmount; @Column(nullable = false, length = 3) private String currency; @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; // Relation avec les lignes de commande @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) private List lines = new ArrayList<>(); }
@Entity @Table(name = "order_lines") @Getter @Setter @NoArgsConstructor public class OrderLineJpaEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id", nullable = false) private OrderJpaEntity order; @Column(name = "product_id", nullable = false) private Long productId; @Column(name = "product_name", nullable = false) private String productName; @Column(name = "unit_price", nullable = false, precision = 10, scale = 2) private BigDecimal unitPrice; @Column(nullable = false, length = 3) private String currency; @Column(nullable = false) private int quantity; }

Repository Spring Data JPA

// Interface Spring Data JPA — dans l'adapter, pas dans le domaine // Elle travaille avec OrderJpaEntity, pas avec Order du domaine public interface OrderJpaRepository extends JpaRepository { List findByCustomerId(Long customerId); List findByCustomerIdAndStatus(Long customerId, OrderStatus status); }

Mapper persistence

Le mapper est la pièce centrale de l'adapter JPA. Il traduit dans les deux sens : domaine → JPA pour sauvegarder, JPA → domaine pour récupérer.

@Component public class OrderPersistenceMapper { // Domaine Order → Entité JPA (pour sauvegarder) public OrderJpaEntity toJpaEntity(Order order) { OrderJpaEntity entity = new OrderJpaEntity(); if (order.getId() != null) entity.setId(order.getId().value()); entity.setCustomerId(order.getCustomerId().value()); entity.setStatus(order.getStatus()); entity.setTotalAmount(order.getTotalAmount().amount()); entity.setCurrency(order.getTotalAmount().currency()); entity.setCreatedAt(order.getCreatedAt()); // Mapper les lignes List lineEntities = order.getLines().stream() .map(line -> toLineEntity(line, entity)) .collect(Collectors.toList()); entity.setLines(lineEntities); return entity; } private OrderLineJpaEntity toLineEntity(OrderLine line, OrderJpaEntity orderEntity) { OrderLineJpaEntity lineEntity = new OrderLineJpaEntity(); lineEntity.setOrder(orderEntity); lineEntity.setProductId(line.getProductId().value()); lineEntity.setProductName(line.getProductName()); lineEntity.setUnitPrice(line.getUnitPrice().amount()); lineEntity.setCurrency(line.getUnitPrice().currency()); lineEntity.setQuantity(line.getQuantity().value()); return lineEntity; } // Entité JPA → Domaine Order (pour récupérer depuis la base) public Order toDomain(OrderJpaEntity entity) { List lines = entity.getLines().stream() .map(this::toLineEntity) .collect(Collectors.toList()); return Order.reconstitute( OrderId.of(entity.getId()), UserId.of(entity.getCustomerId()), lines, entity.getStatus(), Money.of(entity.getTotalAmount(), entity.getCurrency()), entity.getCreatedAt() ); } private OrderLine toLineEntity(OrderLineJpaEntity lineEntity) { return OrderLine.reconstitute( ProductId.of(lineEntity.getProductId()), lineEntity.getProductName(), Money.of(lineEntity.getUnitPrice(), lineEntity.getCurrency()), Quantity.of(lineEntity.getQuantity()) ); } }

L'adapter — implémentation du port OUT

L'adapter JPA implémente l'interface OrderRepository définie dans le domaine. C'est lui qui fait le lien entre le domaine et Spring Data JPA.

// Implémente le port OUT du domaine — Spring l'injectera partout où OrderRepository est demandé @Component @RequiredArgsConstructor public class OrderJpaAdapter implements OrderRepository { // ← interface du DOMAINE private final OrderJpaRepository jpaRepository; // Spring Data JPA private final OrderPersistenceMapper mapper; @Override public Order save(Order order) { // Traduire le domaine en entité JPA OrderJpaEntity entity = mapper.toJpaEntity(order); // Persister via Spring Data JPA OrderJpaEntity saved = jpaRepository.save(entity); // Reconstruire un objet domaine depuis l'entité sauvegardée (avec l'ID généré) return mapper.toDomain(saved); } @Override public Optional findById(OrderId id) { return jpaRepository.findById(id.value()) .map(mapper::toDomain); } @Override public List findByCustomerId(UserId customerId) { return jpaRepository.findByCustomerId(customerId.value()).stream() .map(mapper::toDomain) .collect(Collectors.toList()); } @Override public boolean existsById(OrderId id) { return jpaRepository.existsById(id.value()); } }
// Implémente le port OUT ProductRepository via un appel Feign vers product-service // Le domaine ne sait pas que product-service existe — il voit juste ProductRepository @Component @RequiredArgsConstructor public class ProductFeignAdapter implements ProductRepository { // ← interface du DOMAINE private final ProductFeignClient productFeignClient; // client Feign vers product-service @Override public Optional findById(ProductId productId) { try { ProductFeignResponse response = productFeignClient.getProduct(productId.value()); // Reconstruire un objet domaine Product depuis la réponse Feign return Optional.of(Product.reconstitute( ProductId.of(response.id()), response.name(), Money.of(response.price(), "EUR"), Quantity.of(response.stockQuantity()) )); } catch (FeignException.NotFound e) { return Optional.empty(); } } @Override public void decreaseStock(ProductId productId, Quantity quantity) { productFeignClient.decreaseStock(productId.value(), new StockDecreaseRequest(quantity.value())); } }
ProductFeignAdapter implémente ProductRepository — c'est l'élégance de l'architecture hexagonale. Le domaine définit ProductRepository avec findById(). L'adapter JPA l'implémente avec une base de données. L'adapter Feign l'implémente avec un appel HTTP. Le use case utilise les deux de la même façon, sans rien savoir de la différence.
✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • hexagonal-order-service/adapter/.../persistence/entity/OrderJpaEntity.java
  • hexagonal-order-service/adapter/.../persistence/entity/OrderLineJpaEntity.java
  • hexagonal-order-service/adapter/.../persistence/repository/OrderJpaRepository.java
  • hexagonal-order-service/adapter/.../persistence/mapper/OrderPersistenceMapper.java
  • hexagonal-order-service/adapter/.../persistence/OrderJpaAdapter.java
  • hexagonal-order-service/adapter/.../client/ProductFeignAdapter.java
  • hexagonal-order-service/adapter/.../client/ProductFeignClient.java

Étapes

  1. Créer OrderJpaEntity et OrderLineJpaEntity — uniquement des annotations JPA, aucune logique
  2. Créer OrderJpaRepository extends JpaRepository<OrderJpaEntity, Long>
  3. Créer OrderPersistenceMapper avec toJpaEntity(Order) et toDomain(OrderJpaEntity)
  4. Créer OrderJpaAdapter qui implements OrderRepository du domaine
  5. Créer ProductFeignClient (interface Feign vers product-service)
  6. Créer ProductFeignAdapter qui implements ProductRepository du domaine
  7. Ajouter OrderLine.reconstitute() dans le domaine si pas encore fait
Partie 03 · Projet 3

Assembler l'application

Le module application est le seul endroit qui connaît toutes les pièces. Il configure Spring Boot, crée les beans des use cases en leur injectant les adapters, et relie tout ensemble.

Configuration Spring — relier use cases et adapters

Les use cases (dans domain/) ne sont pas annotés @Service — ils ne savent pas qu'ils vivent dans un conteneur Spring. C'est le module application qui les déclare comme beans et leur injecte les adapters.

@Configuration public class OrderBeanConfig { // Spring injecte automatiquement les adapters annotés @Component // CreateOrderService (use case) reçoit les adapters — via les interfaces (ports OUT) @Bean public CreateOrderUseCase createOrderUseCase( OrderRepository orderRepository, // ← injecte OrderJpaAdapter (@Component) ProductRepository productRepository, // ← injecte ProductFeignAdapter (@Component) UserRepository userRepository) { // ← injecte UserFeignAdapter (@Component) // Instanciation manuelle du use case — pas de @Service dans le domaine return new CreateOrderService(orderRepository, productRepository, userRepository); } @Bean public GetOrderUseCase getOrderUseCase(OrderRepository orderRepository) { return new GetOrderService(orderRepository); } @Bean public CancelOrderUseCase cancelOrderUseCase(OrderRepository orderRepository) { return new CancelOrderService(orderRepository); } }
Injection via les interfaces — Spring voit que OrderRepository est demandé. Il cherche un bean qui implémente cette interface — c'est OrderJpaAdapter (annoté @Component). L'injection se fait automatiquement. Si demain on veut remplacer JPA par MongoDB, on crée OrderMongoAdapter implements OrderRepository — le use case ne change pas d'une ligne.

Application principale

@SpringBootApplication // On scan les packages des adapters et du domaine @ComponentScan(basePackages = { "fr.jeandecode.order.adapter", // adapters IN et OUT (@Component) "fr.jeandecode.order.application" // config (@Configuration, @Bean) }) @EnableFeignClients(basePackages = "fr.jeandecode.order.adapter.out.client") public class OrderApplication { public static void main(String[] args) { SpringApplication.run(OrderApplication.class, args); } }
server: port: 8083 spring: application: name: order-service datasource: url: jdbc:postgresql://localhost:5435/order_db username: postgres password: postgres jpa: hibernate: ddl-auto: update # Enregistrement dans Eureka (si utilisé avec le Projet 2) eureka: client: service-url: defaultZone: http://localhost:8761/eureka/ # Resilience4j pour les appels vers product-service resilience4j: circuitbreaker: instances: product-service: failureRateThreshold: 50 waitDurationInOpenState: 30s

Stratégie de tests complète

L'architecture hexagonale rend chaque couche indépendamment testable :

CoucheType de testAnnotationCe qu'on teste
Domaine (agrégats, VOs)Test unitaire pur(aucune)Règles métier, invariants, comportements
Use CasesTest unitaire avec mocks@ExtendWith(Mockito)Orchestration, ports OUT mockés
Adapter RESTTest de couche web@WebMvcTestRoutes HTTP, validation, mapping
Adapter JPATest d'intégration JPA@DataJpaTestRequêtes SQL, mapping domaine↔JPA
Application complèteTest E2E@SpringBootTestScénarios complets
// Test du domaine pur — aucun framework, s'exécute en millisecondes class OrderAggregateTest { private Product buildProduct(int stock) { return Product.reconstitute( ProductId.of(1L), "T-shirt", Money.of(new BigDecimal("29.99"), "EUR"), Quantity.of(stock) ); } @Test void addLine_shouldCalculateTotal() { Order order = Order.create(UserId.of(1L)); order.addLine(buildProduct(10), Quantity.of(2)); assertEquals(new BigDecimal("59.98"), order.getTotalAmount().amount()); } @Test void confirm_shouldFail_whenNoLines() { Order order = Order.create(UserId.of(1L)); assertThrows(OrderStateException.class, order::confirm, "Impossible de confirmer une commande sans lignes"); } @Test void cancel_shouldFail_whenAlreadyShipped() { Order order = Order.create(UserId.of(1L)); order.addLine(buildProduct(5), Quantity.of(1)); order.confirm(); // Simuler l'expédition (méthode ship() à implémenter) // order.ship(); // assertThrows(OrderStateException.class, order::cancel); } @Test void addLine_shouldFail_whenInsufficientStock() { Order order = Order.create(UserId.of(1L)); Product p = buildProduct(1); // stock = 1 assertThrows(InsufficientStockException.class, () -> order.addLine(p, Quantity.of(5))); // demande 5 } }
// Test du use case avec des mocks Mockito @ExtendWith(MockitoExtension.class) class CreateOrderServiceTest { @Mock OrderRepository orderRepository; @Mock ProductRepository productRepository; @Mock UserRepository userRepository; @InjectMocks CreateOrderService createOrderService; @Test void execute_shouldCreateOrder_whenProductAvailable() { // Arrange var command = new CreateOrderUseCase.CreateOrderCommand(1L, 42L, 2); given(userRepository.findById(UserId.of(1L))) .willReturn(Optional.of(/* User mock */null)); given(productRepository.findById(ProductId.of(42L))) .willReturn(Optional.of(Product.reconstitute( ProductId.of(42L), "T-shirt", Money.of(new BigDecimal("29.99"), "EUR"), Quantity.of(10) ))); given(orderRepository.save(any())) .willAnswer(inv -> { Order o = inv.getArgument(0); return Order.reconstitute(OrderId.of(1L), o.getCustomerId(), o.getLines(), o.getStatus(), o.getTotalAmount(), o.getCreatedAt()); }); // Act OrderId result = createOrderService.execute(command); // Assert assertNotNull(result); then(productRepository).should().decreaseStock(ProductId.of(42L), Quantity.of(2)); then(orderRepository).should().save(any(Order.class)); } }
📌 Bilan du Projet 3 — Hexagonale + DDD
  • Domain — Java pur, aucune dépendance externe. Agrégats avec invariants, Value Objects immuables, ports (interfaces).
  • Use Cases — implémentent les ports IN, dépendent uniquement des ports OUT. Testables avec de simples mocks.
  • Adapter IN (REST) — traduit HTTP → Command/Query → Use Case → HTTP. Aucune logique métier.
  • Adapter OUT (JPA) — entités JPA séparées du domaine. Mapper bidirectionnel. Implémente les ports OUT.
  • Adapter OUT (Feign) — appels vers d'autres microservices. Implémente les mêmes ports OUT que JPA.
  • Application — assemble tout. @Bean pour relier use cases ↔ adapters. Seul endroit qui connaît tout.
✅ Récapitulatif — ce chapitre en pratique

Fichiers à créer

  • hexagonal-order-service/application/src/main/java/fr/jeandecode/order/application/config/OrderBeanConfig.java
  • hexagonal-order-service/application/src/main/java/fr/jeandecode/order/application/OrderApplication.java
  • hexagonal-order-service/application/src/main/resources/application.yml
  • hexagonal-order-service/domain/src/test/java/.../OrderAggregateTest.java
  • hexagonal-order-service/domain/src/test/java/.../CreateOrderServiceTest.java

Étapes

  1. Créer OrderBeanConfig avec les @Bean pour chaque use case
  2. Vérifier que Spring injecte bien OrderJpaAdapter là où OrderRepository est demandé
  3. Écrire les tests du domaine (OrderAggregateTest) — doivent s'exécuter sans Spring
  4. Écrire les tests des use cases (CreateOrderServiceTest) avec Mockito
  5. Tester l'adapter JPA avec @DataJpaTest
  6. Test E2E avec @SpringBootTest : créer une commande de bout en bout
  7. Vérifier que l'ensemble fonctionne avec Eureka + Gateway du Projet 2
← PrécédentAdapter JPA (Driven)Bientôt →Projet 4 — bientôt