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.
| Concept | Rôle | Exemple |
| Agrégat | Unité de cohérence du domaine, racine de la transaction | Order contient ses OrderLine |
| Value Object (VO) | Objet immuable identifié par sa valeur, pas son id | Money, Email, Quantity |
| Port IN | Interface que le domaine expose aux adaptateurs entrants | CreateOrderUseCase |
| Port OUT | Interface que le domaine exige de l'infrastructure | OrderRepository (interface dans le domaine) |
| Adapter IN | Traduit le monde extérieur → domaine | OrderController (REST) |
| Adapter OUT | Traduit le domaine → infrastructure | OrderJpaAdapter |
| Use Case | Implémentation d'un Port IN — orchestre le domaine | CreateOrderUseCaseImpl |
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.xmlhexagonal-order-service/application/pom.xml (Spring Boot main)
Étapes
- Créer la structure de dossiers telle qu'indiquée ci-dessus
- Configurer le
pom.xml parent avec les 3 modules - Vérifier que
domain/pom.xml n'a AUCUN import Spring ou JPA - 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.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/Quantity.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/OrderId.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/ProductId.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/vo/UserId.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/Product.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/Order.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/OrderLine.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/model/OrderStatus.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/exception/OrderStateException.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/exception/InsufficientStockException.java
Étapes
- Créer tous les Value Objects (
Money, Quantity, OrderId, ProductId, UserId) avec validation dans le constructeur compact - Créer l'entité
Product avec les fabriques create() et reconstitute() - Créer l'agrégat
Order avec les méthodes addLine(), confirm(), cancel() - Créer
OrderLine avec la méthode getSubtotal() - Créer les exceptions du domaine
- Écrire les tests unitaires du domaine — vérifier toutes les règles métier sans Spring
- Vérifier qu'il n'y a aucun import Spring/JPA dans le dossier
domain/
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.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/in/GetOrderUseCase.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/in/CancelOrderUseCase.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/out/OrderRepository.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/out/ProductRepository.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/port/out/UserRepository.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/service/CreateOrderService.javahexagonal-order-service/domain/src/main/java/fr/jeandecode/order/domain/service/CancelOrderService.java
Étapes
- Créer les interfaces des ports IN avec les records Command/Query internes
- Créer les interfaces des ports OUT (
OrderRepository, ProductRepository, UserRepository) - Implémenter
CreateOrderService — injecter les ports OUT par constructeur - Implémenter
CancelOrderService et GetOrderService - Écrire les tests des use cases avec des mocks Mockito pour les ports OUT
- 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 :
- Recevoir la requête HTTP et valider le format (JSON, contraintes
@Valid)
- Mapper la requête HTTP → Command du domaine
- 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.javahexagonal-order-service/adapter/src/main/java/fr/jeandecode/order/adapter/in/web/dto/OrderResponse.javahexagonal-order-service/adapter/src/main/java/fr/jeandecode/order/adapter/in/web/mapper/OrderWebMapper.javahexagonal-order-service/adapter/src/main/java/fr/jeandecode/order/adapter/in/web/OrderController.java
Étapes
- Créer les DTOs
CreateOrderRequest (avec @Valid) et OrderResponse - Créer
OrderWebMapper avec les méthodes toResponse(Order) et toCommand() - Créer
OrderController qui dépend des interfaces (ports IN), pas des implémentations - Gérer les exceptions du domaine → réponses HTTP dans le controller (ProblemDetail RFC 7807)
- 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.javahexagonal-order-service/adapter/.../persistence/entity/OrderLineJpaEntity.javahexagonal-order-service/adapter/.../persistence/repository/OrderJpaRepository.javahexagonal-order-service/adapter/.../persistence/mapper/OrderPersistenceMapper.javahexagonal-order-service/adapter/.../persistence/OrderJpaAdapter.javahexagonal-order-service/adapter/.../client/ProductFeignAdapter.javahexagonal-order-service/adapter/.../client/ProductFeignClient.java
Étapes
- Créer
OrderJpaEntity et OrderLineJpaEntity — uniquement des annotations JPA, aucune logique - Créer
OrderJpaRepository extends JpaRepository<OrderJpaEntity, Long> - Créer
OrderPersistenceMapper avec toJpaEntity(Order) et toDomain(OrderJpaEntity) - Créer
OrderJpaAdapter qui implements OrderRepository du domaine - Créer
ProductFeignClient (interface Feign vers product-service) - Créer
ProductFeignAdapter qui implements ProductRepository du domaine - 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 :
| Couche | Type de test | Annotation | Ce qu'on teste |
| Domaine (agrégats, VOs) | Test unitaire pur | (aucune) | Règles métier, invariants, comportements |
| Use Cases | Test unitaire avec mocks | @ExtendWith(Mockito) | Orchestration, ports OUT mockés |
| Adapter REST | Test de couche web | @WebMvcTest | Routes HTTP, validation, mapping |
| Adapter JPA | Test d'intégration JPA | @DataJpaTest | Requêtes SQL, mapping domaine↔JPA |
| Application complète | Test E2E | @SpringBootTest | Scé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.javahexagonal-order-service/application/src/main/java/fr/jeandecode/order/application/OrderApplication.javahexagonal-order-service/application/src/main/resources/application.ymlhexagonal-order-service/domain/src/test/java/.../OrderAggregateTest.javahexagonal-order-service/domain/src/test/java/.../CreateOrderServiceTest.java
Étapes
- Créer
OrderBeanConfig avec les @Bean pour chaque use case - Vérifier que Spring injecte bien
OrderJpaAdapter là où OrderRepository est demandé - Écrire les tests du domaine (
OrderAggregateTest) — doivent s'exécuter sans Spring - Écrire les tests des use cases (
CreateOrderServiceTest) avec Mockito - Tester l'adapter JPA avec
@DataJpaTest - Test E2E avec
@SpringBootTest : créer une commande de bout en bout - Vérifier que l'ensemble fonctionne avec Eureka + Gateway du Projet 2