Pourquoi cet article ?
Quand on parle de conception logicielle, on croise souvent deux mondes qui se regardent de loin :
- le monde de la modélisation métier, avec DDD ;
- le monde des solutions de code, avec les design patterns.
En pratique, ces deux approches ne s’opposent pas. Elles se complètent.
L’objectif de cet article est simple : montrer comment une architecture hexagonale en Java pur permet de relier la pensée DDD à du code concret, lisible, et testable.
DDD stratégique vs DDD tactique
Le DDD recouvre en réalité deux niveaux complémentaires :
- Le DDD stratégique : il traite de la structuration globale du système (Bounded Contexts, Context Mapping, langage partagé entre équipes).
- Le DDD tactique : il concerne l’implémentation concrète dans le code (Entités, Value Objects, Agrégats, Repositories, Factories, Domain Services).
Dans cet article, nous nous concentrons volontairement sur le DDD tactique : comment structurer un cas d’usage en Java pur de manière cohérente, testable et fidèle au modèle métier.
Les questions stratégiques interviennent à une échelle plus large, mais la discipline tactique reste indispensable pour éviter que le code ne dérive vers un simple CRUD technique.
DDD et architecture hexagonale : même combat
Le DDD nous rappelle une idée centrale : le code doit refléter le métier.
Le problème, c’est que dans beaucoup de projets, la logique métier finit noyée dans :
- les détails techniques,
- les frameworks,
- les appels externes,
- les couches de plomberie.
L’architecture hexagonale répond exactement à ce problème.
Elle sépare :
- Le cœur métier (domain + use cases),
- Les ports (interfaces),
- Les adapters (implémentations techniques).
Résultat : le métier reste stable, l’infrastructure peut évoluer sans tout casser.
Modèle cible
Pour illustrer, prenons un cas simple : gestion de commande.
src/main/java
└── fr/eletutour/order
├── domain
│ ├── model
│ └── service
├── application
│ ├── port
│ └── usecase
└── infrastructure
├── persistence
└── notification
domain: règles métier, invariants.application: orchestration des cas d’usage.infrastructure: base de données, email, API externe, etc.
À quoi ressemble le flux complet ?
Prenons le scénario “création de commande” de bout en bout :
- Une entrée arrive (CLI, HTTP, batch, message...).
- On transforme cette entrée en commande applicative (
CreateOrderCommand). - Le use case orchestre la création de l’agrégat via une factory.
- Les règles métier s’appliquent (calcul, validation, invariants).
- Le port de persistence enregistre la commande.
- L’adapter technique fait le travail concret (JPA, JDBC, mémoire, etc.).
Ce flux permet de garder le métier au centre et la technique en périphérie.
Où interviennent les Design Patterns ?
C’est ici que les patterns deviennent utiles, sans sur-ingénierie.
Factory pour créer des agrégats valides
Au lieu d’instancier les objets métier n’importe comment, on centralise la création avec Factory.
public final class OrderFactory {
private OrderFactory() {}
public static Order create(String customerId, List<OrderLine> lines) {
if (lines == null || lines.isEmpty()) {
throw new IllegalArgumentException("An order must contain at least one line");
}
return new Order(UUID.randomUUID(), customerId, lines, OrderStatus.CREATED);
}
}
La factory protège les invariants dès l’entrée.
Strategy pour les règles variables
Quand un comportement peut changer (tarification, remise, taxation), Stratégie évite les if/else infinis.
public interface PricingStrategy {
BigDecimal computeTotal(List<OrderLine> lines);
}
public class StandardPricingStrategy implements PricingStrategy {
@Override
public BigDecimal computeTotal(List<OrderLine> lines) {
return lines.stream()
.map(line -> line.unitPrice().multiply(BigDecimal.valueOf(line.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
On gardes un domaine extensible sans le complexifier.
Adapter pour l’infrastructure
Le port d’application définit un contrat ; l’infrastructure l’implémente avec Adaptateur.
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(UUID id);
}
public class InMemoryOrderRepositoryAdapter implements OrderRepository {
private final Map<UUID, Order> storage = new HashMap<>();
@Override
public Order save(Order order) {
storage.put(order.id(), order);
return order;
}
@Override
public Optional<Order> findById(UUID id) {
return Optional.ofNullable(storage.get(id));
}
}
Le cas d’usage ne dépend ni de JPA, ni d’un framework.
Facade pour exposer un point d’entrée clair
Une Façade peut simplifier l’accès aux use cases côté client (CLI, REST, batch).
public class OrderFacade {
private final CreateOrderUseCase createOrderUseCase;
public OrderFacade(CreateOrderUseCase createOrderUseCase) {
this.createOrderUseCase = createOrderUseCase;
}
public UUID create(CreateOrderCommand command) {
return createOrderUseCase.handle(command);
}
}
Côté appelant, nous n'avons qu’une API métier simple.
Et les tests dans tout ça ?
Le vrai bénéfice de cette structure apparaît dans les tests :
- on teste les règles métier sans framework,
- on teste les use cases avec des doubles de ports,
- on gardes les tests d’intégration pour les adapters.
Exemple de test unitaire du use case :
class CreateOrderUseCaseTest {
@Test
void shouldCreateOrderAndPersistIt() {
OrderRepository repository = new InMemoryOrderRepositoryAdapter();
PricingStrategy pricing = new StandardPricingStrategy();
CreateOrderUseCase useCase = new CreateOrderUseCase(repository, pricing);
CreateOrderCommand command = new CreateOrderCommand(
"customer-1",
List.of(new OrderLine("book-1", 2, new BigDecimal("19.90")))
);
UUID orderId = useCase.handle(command);
assertThat(repository.findById(orderId)).isPresent();
}
}
Ce niveau de testabilité va dans le même sens que les tests d'architecture : rendre les limites de l’architecture explicites et vérifiables.
Cas d’usage complet
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final PricingStrategy pricingStrategy;
public CreateOrderUseCase(OrderRepository orderRepository,
PricingStrategy pricingStrategy) {
this.orderRepository = orderRepository;
this.pricingStrategy = pricingStrategy;
}
public UUID handle(CreateOrderCommand command) {
Order order = OrderFactory.create(command.customerId(), command.lines());
BigDecimal total = pricingStrategy.computeTotal(order.lines());
order.defineTotal(total);
return orderRepository.save(order).id();
}
}
Ici, on voit bien la chaîne DDD + patterns :
- modèle métier explicite,
- orchestration applicative,
- dépendances inversées via ports,
- techniques encapsulées en adapters.
Pourquoi ça réduit la dette technique
- On isole les changements techniques (DB, transport, framework).
- On teste le métier sans démarrer toute la stack.
- On rend l’architecture explicite et lisible pour l’équipe.
Ce n’est pas “du pattern pour du pattern”. C’est de la conception au service du métier.
Ce que cette architecture ne garantit pas
Cette structure ne résout pas tout :
- Elle ne remplace pas une réflexion sérieuse sur le métier.
- Elle ne rend pas un mauvais modèle métier bon.
- Elle n’élimine pas la complexité fonctionnelle.
- Elle peut sembler excessive pour un simple CRUD.
L’architecture hexagonale est un outil.
Elle devient pertinente dès que les règles métier commencent à porter de la complexité et des invariants à protéger.
Erreurs fréquentes
- Transformer l’hexagone en mille couches inutiles.
- Mettre des annotations framework dans le domaine.
- Confondre “séparation” et “duplication systématique”.
- Introduire des patterns sans problème concret à résoudre.
Si vous hésitez, revenez à la question de base :
Est-ce que cette décision rend le métier plus clair et le code plus change-friendly ?
Comment l’adopter progressivement dans un projet existant
Pas besoin de tout réécrire.
Une approche réaliste :
- Commencer par un seul use case critique.
- Isoler un premier port (ex: repository métier).
- Créer un adapter simple (même in-memory au départ).
- Déplacer progressivement les règles métier hors des contrôleurs/services techniques.
- Ajouter des tests de non-régression autour des invariants métier.
L’idée est d’introduire la structure au service du produit, pas de lancer un chantier “architecture pour l’architecture”.
Java pur aujourd’hui, Spring demain ?
Bonne nouvelle : ce modèle fonctionne aussi si l'on par ensuite sur Spring Boot.
- les use cases restent identiques,
- les ports restent identiques,
- seuls les adapters changent (JPA, REST, messaging...).
On peux donc conserver la clarté métier tout en profitant de l’écosystème Spring sur les aspects techniques.
Conclusion
DDD donne la direction.
Les design patterns donnent des outils.
L’architecture hexagonale fournit la structure.
Ce trio permet de construire un code qui tient dans le temps.
Si vous voulez approfondir la logique derrière chaque pattern, commencez par :
puis traverse les patterns de création, structurels et comportementaux pour voir lesquels répondent à vos vrais besoins.
