Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.
- Créationnels : Singleton et Factory Method (1/2) · Abstract Factory et Builder (2/2)
- Structurels : Adapter et Decorator (1/2) · Facade et Proxy (2/2)
- Comportementaux, orchestration : Strategy et Observer (1/2, cet article) · Template Method et State (2/2)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)
Une méthode processOrder() de plusieurs centaines de lignes mélange souvent des responsabilités très différentes : calcul du prix, application des promotions, vérification du stock, choix du transporteur, notification du client, le tout dans un seul bloc if/else if/else. Chaque nouvelle règle oblige à modifier cette méthode, avec le risque de casser les précédentes. Les quatre patterns de ces deux parties découpent ce bloc selon l'axe qui varie : Strategy isole les algorithmes interchangeables (les règles de prix), Observer laisse d'autres composants réagir à un événement (la commande créée), Template Method fixe le squelette d'un traitement dont certaines étapes changent (un import), State fait dépendre le comportement d'un objet de son cycle de vie (un ticket).
Les patterns comportementaux définissent comment les objets communiquent et se répartissent les responsabilités. Là où les patterns créationnels décident comment créer et les patterns structurels comment assembler, les patterns comportementaux décident comment se comporter.
Cette première partie traite Strategy et Observer ; la seconde traite Template Method et State.
Le code est illustratif et simplifié : les classes non définies (Order, Customer, Product, StockService, EmailService…) sont supposées exister. Les noms suivent les conventions Java et le rôle du pattern en suffixe, comme dans le reste de la série ; les diagrammes UML reprennent les noms du code.. Les exemples ont été compilés et exécutés avec le JDK 21 et Spring Boot 3.5 ; les versions minimales de Java sont indiquées quand une fonctionnalité en dépend.
Strategy : choisir un algorithme à l'exécution
Le problème
Un système de tarification doit appliquer des règles différentes selon le type de client. Le code naïf :
// Difficile à maintenir
public BigDecimal calculatePrice(Product product, Customer customer) {
BigDecimal price = product.getBasePrice();
if ("VIP".equals(customer.getType())) {
price = price.multiply(new BigDecimal("0.80")); // -20%
} else if ("EMPLOYEE".equals(customer.getType())) {
price = price.multiply(new BigDecimal("0.70")); // -30%
} else if ("WHOLESALE".equals(customer.getType())) {
if (customer.getOrderCount() > 100) {
price = price.multiply(new BigDecimal("0.60")); // -40%
} else {
price = price.multiply(new BigDecimal("0.75")); // -25%
}
} else if ("SEASONAL_PROMO".equals(customer.getType())) {
// ...50 lignes de logique saisonnière
}
// ... et ainsi de suite pour chaque nouveau type
return price;
}
Chaque nouveau type de client ou nouvelle règle tarifaire oblige à modifier cette méthode. Avec Strategy, chaque politique de prix devient une classe indépendante, derrière une interface commune.
Le diagramme UML
Qui choisit la stratégie ?
Dans la définition du GoF, c'est le client du contexte qui choisit l'algorithme et le fournit au contexte. Dans l'exemple ci-dessous, la sélection est automatisée : PricingService interroge chaque stratégie via appliesTo(customer) et retient le meilleur résultat. Ce mécanisme de sélection par règle est un complément au pattern (proche d'une Chain of Responsibility, où chaque maillon décide s'il s'applique). Les stratégies elles-mêmes restent de purs algorithmes interchangeables, et rien n'empêche un appelant de choisir directement VipPricingStrategy quand il connaît le contexte.
L'implémentation
L'interface Strategy :
public interface PricingStrategy {
/**
* Calcule le prix pour ce produit et ce client.
*/
BigDecimal calculatePrice(Product product, Customer customer);
/**
* Nom de la stratégie pour le logging et l'audit.
*/
String getStrategyName();
/**
* Détermine si cette stratégie s'applique à ce client.
*/
boolean appliesTo(Customer customer);
}
Les stratégies concrètes, chacune dans sa propre classe :
@Component
public class StandardPricingStrategy implements PricingStrategy {
@Override
public BigDecimal calculatePrice(Product product, Customer customer) {
return product.getBasePrice();
}
@Override
public String getStrategyName() {
return "Tarif standard";
}
@Override
public boolean appliesTo(Customer customer) {
return true; // Toujours applicable : sert de référence
}
}
@Component
public class VipPricingStrategy implements PricingStrategy {
private static final BigDecimal VIP_DISCOUNT = new BigDecimal("0.80");
@Override
public BigDecimal calculatePrice(Product product, Customer customer) {
BigDecimal discounted = product.getBasePrice().multiply(VIP_DISCOUNT);
// Remise supplémentaire selon l'ancienneté
// (getRegisteredAt() retourne un LocalDate)
long yearsActive = ChronoUnit.YEARS.between(
customer.getRegisteredAt(), LocalDate.now()
);
if (yearsActive >= 5) {
// VIP fidèle : 5 % supplémentaires
discounted = discounted.multiply(new BigDecimal("0.95"));
}
return discounted.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String getStrategyName() {
return "Tarif VIP (-20 %, puis -5 % si client fidèle)";
}
@Override
public boolean appliesTo(Customer customer) {
return CustomerType.VIP.equals(customer.getType());
}
}
@Component
public class WholesalePricingStrategy implements PricingStrategy {
@Override
public BigDecimal calculatePrice(Product product, Customer customer) {
BigDecimal basePrice = product.getBasePrice();
int orderCount = customer.getOrderCount();
// Remise dégressive par volume
BigDecimal rate;
if (orderCount > 500) {
rate = new BigDecimal("0.55"); // -45%
} else if (orderCount > 100) {
rate = new BigDecimal("0.60"); // -40%
} else {
rate = new BigDecimal("0.75"); // -25%
}
return basePrice.multiply(rate).setScale(2, RoundingMode.HALF_UP);
}
@Override
public String getStrategyName() {
return "Tarif grossiste (volume)";
}
@Override
public boolean appliesTo(Customer customer) {
return CustomerType.WHOLESALE.equals(customer.getType());
}
}
@Component
public class SeasonalPromoPricingStrategy implements PricingStrategy {
private final boolean enabled;
private final BigDecimal seasonalRate;
public SeasonalPromoPricingStrategy(
@Value("${pricing.seasonal.enabled:false}") boolean enabled,
@Value("${pricing.seasonal.discount:0.90}") BigDecimal seasonalRate) {
this.enabled = enabled;
this.seasonalRate = seasonalRate;
}
@Override
public BigDecimal calculatePrice(Product product, Customer customer) {
return product.getBasePrice()
.multiply(seasonalRate)
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String getStrategyName() {
return "Promo saisonnière";
}
@Override
public boolean appliesTo(Customer customer) {
if (!enabled) return false;
// Soldes : quatre semaines, dates indicatives (fixées chaque année), bornes incluses
MonthDay today = MonthDay.now();
return isBetween(today, MonthDay.of(1, 8), MonthDay.of(2, 4))
|| isBetween(today, MonthDay.of(6, 25), MonthDay.of(7, 22));
}
// isAfter() et isBefore() excluent les bornes : on les inclut explicitement
private static boolean isBetween(MonthDay day, MonthDay start, MonthDay end) {
return !day.isBefore(start) && !day.isAfter(end);
}
}
Le Context choisit la stratégie automatiquement :
@Service
public class PricingService {
private static final Logger log = LoggerFactory.getLogger(PricingService.class);
private final List<PricingStrategy> strategies;
/**
* Spring injecte automatiquement TOUTES les implémentations de PricingStrategy.
*/
public PricingService(List<PricingStrategy> strategies) {
this.strategies = List.copyOf(strategies);
log.info("Stratégies de tarification chargées : {}",
strategies.stream().map(PricingStrategy::getStrategyName).toList());
}
/**
* Règle métier : parmi les stratégies applicables, on retient le meilleur
* prix pour le client. Une promo saisonnière ne peut donc jamais écraser
* un tarif VIP plus avantageux.
*/
public PriceResult calculatePrice(Product product, Customer customer) {
PriceResult best = strategies.stream()
.filter(s -> s.appliesTo(customer))
.map(s -> new PriceResult(s.calculatePrice(product, customer), s.getStrategyName()))
.min(Comparator.comparing(PriceResult::price))
.orElseThrow(() -> new IllegalStateException("Aucune stratégie applicable"));
log.info("Prix calculé : {} EUR via '{}' pour le client {}",
best.price(), best.appliedStrategy(), customer.getId());
return best;
}
}
public record PriceResult(BigDecimal price, String appliedStrategy) {}
Le critère « meilleur prix » est une décision métier, pas une propriété du pattern. D'autres règles sont tout aussi légitimes : priorité explicite entre les stratégies, cumul de remises, plafond de remise. L'important est que cette règle soit écrite à un seul endroit, et pas répartie entre les stratégies.
Avec Spring : ajouter une stratégie revient à ajouter une classe
Avec cette architecture, créer un tarif « étudiant » demande une seule action :
@Component
public class StudentPricingStrategy implements PricingStrategy {
@Override
public BigDecimal calculatePrice(Product product, Customer customer) {
return product.getBasePrice()
.multiply(new BigDecimal("0.50"))
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String getStrategyName() { return "Tarif étudiant (-50 %)"; }
@Override
public boolean appliesTo(Customer customer) {
return CustomerType.STUDENT.equals(customer.getType());
}
}
Aucune modification dans PricingService, aucune dans les stratégies existantes. Spring détecte le nouveau @Component et l'ajoute à la liste. C'est l'Open/Closed Principle en pratique.
Strategy dans le JDK
Comparator est un exemple direct de Strategy dans le JDK :
// La stratégie de tri est fournie par l'appelant
List<Product> products = getProducts();
products.sort(Comparator.comparing(Product::getPrice)); // Par prix
products.sort(Comparator.comparing(Product::getName)); // Par nom
products.sort(Comparator.comparing(Product::getPrice).reversed()); // Prix décroissant
Plus généralement, une lambda passée à une méthode pour en paramétrer l'algorithme joue le rôle de Strategy : le Comparator de sort(), le Predicate de filter(), le Function de map(). Il serait abusif d'en conclure que toute interface fonctionnelle est une Strategy. Supplier ou Consumer, par exemple, sont d'abord des types de fonctions ; ils ne deviennent des Strategies que lorsqu'ils servent à choisir la variante d'un algorithme.
Observer : réagir aux événements
Le problème
Quand une commande est passée, il faut mettre à jour le stock, envoyer un e-mail de confirmation, notifier l'équipe logistique, mettre à jour le dashboard temps réel et générer la facture. Si OrderService appelle directement ces cinq services, il connaît tout le monde : le couplage est maximal. Ajouter une sixième action (analytics, par exemple) oblige à modifier OrderService.
L'Observer permet à un objet (le sujet) de notifier tous les objets intéressés (les observateurs) quand son état change, sans les connaître.
Le diagramme UML
Implémentation « à la main » (pour comprendre le mécanisme)
public class OrderEventPublisher {
private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);
private final List<OrderEventListener> listeners = new CopyOnWriteArrayList<>();
public void subscribe(OrderEventListener listener) {
listeners.add(listener);
}
public void unsubscribe(OrderEventListener listener) {
listeners.remove(listener);
}
/**
* Notification synchrone : les listeners s'exécutent dans le thread appelant.
*/
public void publish(OrderEvent event) {
for (OrderEventListener listener : listeners) {
if (listener.supports(event.type())) {
try {
listener.onOrderEvent(event);
} catch (Exception e) {
// Ici, on choisit d'isoler les erreurs : un listener qui plante
// ne bloque pas les suivants.
log.error("Erreur dans le listener {}",
listener.getClass().getSimpleName(), e);
}
}
}
}
}
public interface OrderEventListener {
void onOrderEvent(OrderEvent event);
boolean supports(OrderEventType type);
}
public record OrderEvent(
Order order,
OrderEventType type,
LocalDateTime timestamp
) {
public OrderEvent(Order order, OrderEventType type) {
this(order, type, LocalDateTime.now());
}
}
public enum OrderEventType {
CREATED, CONFIRMED, SHIPPED, DELIVERED, CANCELLED, REFUNDED
}
Un abonné implémente l'interface et s'inscrit auprès du sujet :
public class AuditOrderEventListener implements OrderEventListener {
private static final Logger log = LoggerFactory.getLogger(AuditOrderEventListener.class);
@Override
public void onOrderEvent(OrderEvent event) {
log.info("Audit : commande #{} -> {}", event.order().getOrderNumber(), event.type());
}
@Override
public boolean supports(OrderEventType type) {
return true; // s'intéresse à tous les événements
}
}
// Inscription
OrderEventPublisher publisher = new OrderEventPublisher();
publisher.subscribe(new AuditOrderEventListener());
La version Spring : ApplicationEventPublisher
En Spring, l'Observer est natif : l'infrastructure existe déjà. Depuis Spring 4.2, un événement peut être n'importe quel objet, un record convient bien.
L'événement :
public record OrderCreatedEvent(Order order, String triggeredBy) {}
Le publisher publie l'événement sans connaître les listeners :
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository,
ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
public Order createOrder(CreateOrderRequest request) {
Order order = buildOrder(request); // conversion de la requête en entité, omise ici
Order savedOrder = orderRepository.save(order);
// Publie l'événement : aucune connaissance des listeners
eventPublisher.publishEvent(
new OrderCreatedEvent(savedOrder, SecurityUtils.getCurrentUsername())
);
return savedOrder;
}
}
Attention : par défaut, un @EventListener est synchrone. L'appel à publishEvent() ne rend la main qu'une fois tous les listeners exécutés, dans le même thread et, si le publisher est transactionnel, dans la même transaction. Les listeners sont donc découplés au niveau du code (le publisher ne les connaît pas), mais pas indépendants à l'exécution. Une exception levée par un listener remonte jusqu'à createOrder() et annule la transaction : la commande n'est pas créée. Ce comportement est parfois voulu (la mise à jour du stock doit réussir ou échouer avec la commande), et parfois non (un e-mail ne devrait pas empêcher une commande).
Les listeners choisissent chacun leur mode d'exécution selon ce qu'ils doivent garantir :
@Component
public class StockUpdateListener {
private static final Logger log = LoggerFactory.getLogger(StockUpdateListener.class);
private final StockService stockService;
public StockUpdateListener(StockService stockService) {
this.stockService = stockService;
}
// Synchrone et dans la transaction de la commande : le stock est décrémenté
// atomiquement avec la création. Une erreur annule aussi la commande.
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
Order order = event.order();
log.info("Mise à jour du stock pour la commande #{}", order.getOrderNumber());
for (OrderLine line : order.getLines()) {
stockService.decreaseStock(line.getProduct().getId(), line.getQuantity());
}
}
}
@Component
public class OrderEmailListener {
private static final Logger log = LoggerFactory.getLogger(OrderEmailListener.class);
private final EmailService emailService;
public OrderEmailListener(EmailService emailService) {
this.emailService = emailService;
}
// Exécuté seulement si la commande est réellement validée en base
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
Order order = event.order();
log.info("Envoi de l'e-mail de confirmation pour la commande #{}", order.getOrderNumber());
emailService.sendOrderConfirmation(order.getCustomer().getEmail(), order);
}
}
@Component
public class InvoiceListener {
private static final Logger log = LoggerFactory.getLogger(InvoiceListener.class);
private final InvoiceService invoiceService;
public InvoiceListener(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
// La facture est écrite en base : après le commit, il faut une NOUVELLE transaction
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderCreated(OrderCreatedEvent event) {
log.info("Génération de la facture pour la commande #{}", event.order().getOrderNumber());
invoiceService.generateInvoice(event.order());
}
}
@Component
public class AnalyticsListener {
private static final Logger log = LoggerFactory.getLogger(AnalyticsListener.class);
private final AnalyticsService analyticsService;
public AnalyticsListener(AnalyticsService analyticsService) {
this.analyticsService = analyticsService;
}
// Après le commit, et dans un thread séparé : ne ralentit pas la réponse
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
log.info("Tracking analytics de la commande #{}", event.order().getOrderNumber());
analyticsService.trackOrderCreated(event.order(), event.triggeredBy());
}
}
@TransactionalEventListener et @Async : les subtilités
AFTER_COMMIT. Le listener ne s'exécute que si la transaction a été validée. Si la création de la commande fait un rollback, l'événement n'est jamais traité : on évite d'envoyer un e-mail de confirmation pour une commande qui n'existe pas. C'est essentiel pour la cohérence.
Écrire en base depuis un listener AFTER_COMMIT. À ce stade, la transaction d'origine est déjà validée, mais ses ressources sont encore liées au thread. Une écriture faite sans nouvelle transaction n'est jamais commitée. C'est pourquoi InvoiceListener déclare @Transactional(propagation = Propagation.REQUIRES_NEW).
@Async. Le listener s'exécute dans un autre thread : si l'analytics échoue, la commande reste créée. Trois précautions : activer @EnableAsync, déclarer un TaskExecutor borné plutôt que de laisser l'exécuteur par défaut, et configurer un AsyncUncaughtExceptionHandler, faute de quoi les exceptions sont simplement perdues. Dans un autre thread, la session JPA de la requête n'existe plus : un événement qui transporte une entité avec des relations paresseuses expose à des LazyInitializationException. Mieux vaut y mettre des identifiants ou des données déjà chargées.
Ordre d'exécution. Entre listeners synchrones, l'ordre n'est pas garanti par défaut. Si l'ordre compte, il faut l'exprimer avec @Order, ou mieux, ne pas en dépendre.
Ce qu'il faut retenir
| Pattern | Quand l'utiliser | Équivalent Spring / JDK |
|---|---|---|
| Strategy | Algorithmes interchangeables à l'exécution | Injection de List<Interface>, @Profile, Comparator |
| Observer | Découplage publication/souscription | ApplicationEventPublisher, @EventListener, @TransactionalEventListener |
La partie 2 traite Template Method, pour fixer le squelette d'un traitement, et State, pour faire dépendre le comportement d'un objet de son état. Elle se termine par un service qui combine Strategy, State et Observer.
Partie précédente : Facade et Proxy. Partie suivante : Template Method et State.
Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.
- Créationnels : Singleton et Factory Method (1/2) · Abstract Factory et Builder (2/2)
- Structurels : Adapter et Decorator (1/2) · Facade et Proxy (2/2)
- Comportementaux, orchestration : Strategy et Observer (1/2, cet article) · Template Method et State (2/2)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)