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) · Template Method et State (2/2)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3, cet article) · Iterator et Visitor (3/3)
Cette partie traite deux patterns de communication. Chain of Responsibility fait passer une requête de maillon en maillon (authentification, autorisation, validation, limitation de débit). Mediator centralise les échanges entre composants qui ne se connaissent pas (stock, paiement, expédition, notifications). Elle se lit sans la partie 1 (Command), qui est seulement citée dans la comparaison finale.
Le code est illustratif et simplifié : les classes non définies (Request, Response, TokenService, PermissionService, Order, InventoryRepository…) 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.
Chain of Responsibility : la chaîne de traitement
Le problème
Les requêtes HTTP entrantes d'une application doivent passer par une série de vérifications : authentification, autorisation, validation, limitation de débit (rate limiting), journalisation. Si chaque contrôleur appelle directement chacune de ces vérifications, on obtient une duplication importante. Et quand l'ordre change ou qu'on ajoute une vérification, il faut toucher à tous les endroits.
Chain of Responsibility permet de chaîner les traitements : chaque maillon décide s'il traite la requête, la passe au suivant ou la rejette.
Le diagramme UML
L'implémentation
Le maillon abstrait :
public abstract class RequestHandler {
private RequestHandler next;
/**
* Définit le maillon suivant. Retourne ce maillon suivant pour le chaînage fluide.
*/
public RequestHandler setNext(RequestHandler next) {
this.next = next;
return next;
}
/**
* Point d'entrée : exécute le traitement, puis passe au suivant.
*/
public Response handle(Request request) {
Response response = process(request);
// Si ce maillon a rejeté la requête, on arrête
if (response != null && !response.isSuccess()) {
return response;
}
// Passe au maillon suivant
if (next != null) {
return next.handle(request);
}
// Fin de la chaîne : succès
return Response.ok("Requête traitée");
}
/**
* Logique spécifique de ce maillon.
* Retourne null pour continuer, ou une Response pour arrêter.
*/
protected abstract Response process(Request request);
}
Les maillons concrets :
public class AuthenticationHandler extends RequestHandler {
private static final Logger log = LoggerFactory.getLogger(AuthenticationHandler.class);
private final TokenService tokenService;
public AuthenticationHandler(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
protected Response process(Request request) {
String token = request.getHeader("Authorization");
if (token == null || token.isBlank()) {
log.warn("Requête sans token d'authentification");
return Response.unauthorized("Token manquant");
}
try {
UserDetails user = tokenService.validateAndExtract(token);
request.setAuthenticatedUser(user);
log.debug("Authentifié : {}", user.getUsername());
return null; // Continue la chaîne
} catch (InvalidTokenException e) {
// Le détail de l'erreur reste dans les logs, pas dans la réponse
log.warn("Token invalide : {}", e.getMessage());
return Response.unauthorized("Token invalide");
}
}
}
public class AuthorizationHandler extends RequestHandler {
private static final Logger log = LoggerFactory.getLogger(AuthorizationHandler.class);
private final PermissionService permissionService;
public AuthorizationHandler(PermissionService permissionService) {
this.permissionService = permissionService;
}
@Override
protected Response process(Request request) {
UserDetails user = request.getAuthenticatedUser();
if (user == null) {
// 401 : l'identité n'est pas établie (différent de 403, identité connue mais interdite)
return Response.unauthorized("Authentification requise");
}
String resource = request.getResource();
String action = request.getAction();
if (!permissionService.hasPermission(user, resource, action)) {
log.warn("Accès refusé : {} -> {}:{}", user.getUsername(), resource, action);
return Response.forbidden("Permission insuffisante pour " + action + " sur " + resource);
}
return null; // Continue
}
}
/**
* Limitation de débit par token bucket : pas de thread de fond à arrêter,
* et pas d'effet de bord de fenêtre fixe (jusqu'à 2 x la limite autour d'une
* remise à zéro, quand un compteur est vidé toutes les minutes).
*/
public class RateLimitHandler extends RequestHandler {
private static final Logger log = LoggerFactory.getLogger(RateLimitHandler.class);
private static final class Bucket {
private double tokens;
private long lastRefillNanos;
Bucket(double tokens, long now) {
this.tokens = tokens;
this.lastRefillNanos = now;
}
}
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
private final int capacity; // rafale maximale autorisée
private final double refillPerNano; // débit soutenu
public RateLimitHandler(int maxRequestsPerMinute) {
this.capacity = maxRequestsPerMinute;
this.refillPerNano = maxRequestsPerMinute / (60.0 * 1_000_000_000L);
}
@Override
protected Response process(Request request) {
String clientIp = request.getClientIp();
if (!tryConsume(clientIp)) {
log.warn("Rate limit dépassé pour l'IP {}", clientIp);
return Response.tooManyRequests(
"Limite de " + capacity + " requêtes par minute dépassée"
);
}
return null;
}
private boolean tryConsume(String key) {
long now = System.nanoTime();
Bucket bucket = buckets.computeIfAbsent(key, k -> new Bucket(capacity, now));
synchronized (bucket) {
double elapsed = now - bucket.lastRefillNanos;
bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * refillPerNano);
bucket.lastRefillNanos = now;
if (bucket.tokens >= 1.0) {
bucket.tokens -= 1.0;
return true;
}
return false;
}
}
}
public class ValidationHandler extends RequestHandler {
@Override
protected Response process(Request request) {
if (request.getBody() == null && request.requiresBody()) {
return Response.badRequest("Corps de requête requis");
}
List<String> errors = new ArrayList<>();
if (request.getBody() != null) {
// Validation basique
if (request.getBody().length() > 10_000_000) {
errors.add("Corps trop volumineux (max 10 Mo)");
}
}
if (request.getResource() == null || request.getResource().isBlank()) {
errors.add("Ressource non spécifiée");
}
if (!errors.isEmpty()) {
return Response.badRequest("Erreurs de validation : " + String.join(", ", errors));
}
return null;
}
}
public class LoggingHandler extends RequestHandler {
private static final Logger log = LoggerFactory.getLogger(LoggingHandler.class);
@Override
protected Response process(Request request) {
// Placé après l'authentification : l'utilisateur est connu à ce stade
log.info("{} {} (user: {}, ip: {})",
request.getAction(),
request.getResource(),
request.getAuthenticatedUser().getUsername(),
request.getClientIp()
);
return null; // Toujours continue
}
}
Ce RateLimitHandler sert à illustrer le mécanisme. Dans une application réelle, on préfère une bibliothèque éprouvée (Bucket4j, Resilience4j), avec un stockage partagé (Redis) dès que l'application tourne sur plusieurs instances. Il faut aussi purger les entrées inactives de la table, et ne se fier à l'adresse IP transmise par un proxy (X-Forwarded-For) que pour les proxys de confiance.
Assemblage et utilisation
L'ordre de la chaîne compte. Le rate limiting vient en premier, pour protéger l'authentification elle-même contre le brute force. Le logging est placé après l'authentification : en première position, request.getAuthenticatedUser() serait toujours null (tout serait journalisé comme « anonymous »). Les requêtes rejetées plus tôt sont de toute façon tracées par le maillon qui les refuse.
@Configuration
public class RequestPipelineConfig {
@Bean
public RequestHandler requestPipeline(TokenService tokenService,
PermissionService permissionService) {
// Construction de la chaîne
RequestHandler rateLimit = new RateLimitHandler(100);
RequestHandler auth = new AuthenticationHandler(tokenService);
RequestHandler logging = new LoggingHandler();
RequestHandler authz = new AuthorizationHandler(permissionService);
RequestHandler validation = new ValidationHandler();
// Chaînage : RateLimit -> Auth -> Logging -> Authz -> Validation
rateLimit.setNext(auth);
auth.setNext(logging);
logging.setNext(authz);
authz.setNext(validation);
return rateLimit; // Retourne le premier maillon
}
}
// Utilisation
@Service
public class ApiGateway {
private final RequestHandler pipeline;
public ApiGateway(RequestHandler pipeline) {
this.pipeline = pipeline;
}
public Response processRequest(Request request) {
return pipeline.handle(request);
}
}
Chain of Responsibility dans le JDK et Spring
Ce pattern se retrouve à plusieurs endroits :
jakarta.servlet.Filter(javax.servletavant Jakarta EE 9 et Spring Boot 3) : la chaîne de filtres Servlet suit exactement ce principe, avecFilterChain.doFilter()qui passe au suivant- Spring Security : la
SecurityFilterChainest une chaîne d'une quinzaine de filtres selon la configuration (CORS, CSRF, authentification, autorisation...) java.util.logging: unLoggertransmet ses enregistrements à ses handlers, puis à son logger parent- Middleware (Express.js, Laravel...) : même concept,
next()passe au maillon suivant
La différence entre Spring Security et l'implémentation ci-dessus : Spring utilise une variante où chaque filtre reçoit la chaîne en paramètre (doFilter(request, response, chain)) au lieu de stocker une référence vers le suivant. C'est plus flexible, car l'ordre se configure de l'extérieur et un filtre peut agir avant et après le reste de la chaîne.
Mediator : centraliser la communication
Le problème
Un formulaire complexe avec des champs interdépendants : quand on change le pays, la liste des villes se met à jour, la devise change, les taxes se recalculent, le format du code postal s'adapte, et le champ « province » apparaît ou disparaît. Si chaque composant communique directement avec les autres, on obtient un réseau de dépendances ingérable (n composants peuvent former jusqu'à n*(n-1)/2 liens directs).
Le Mediator centralise la communication : les composants ne se connaissent pas, ils ne connaissent que le médiateur.
Le diagramme UML
L'exemple du formulaire illustre le problème ; le code et le diagramme transposent le même principe à la coordination de services côté backend. Le diagramme reprend les noms du code : l'interface OrderProcessingMediator, son implémentation OrderProcessingCoordinator et quatre composants qui héritent de OrderComponent.
L'implémentation (côté backend : coordination de services)
Un traitement de commande fait collaborer plusieurs composants (stock, paiement, expédition, notifications) sans qu'ils se connaissent. La méthode du médiateur s'appelle notifyEvent : un nom notify serait surchargé avec Object.notify(), ce qui prête à confusion.
/**
* Événements échangés entre les composants et le médiateur.
*/
public enum ProcessingEvent {
STOCK_RESERVED, STOCK_INSUFFICIENT, STOCK_RELEASED,
PAYMENT_SUCCESS, PAYMENT_FAILED,
SHIPPING_SCHEDULED
}
/**
* Interface du médiateur : reçoit les notifications des composants.
*/
public interface OrderProcessingMediator {
void notifyEvent(Object sender, ProcessingEvent event, Order order);
}
/**
* Composant de base : ne connaît que le médiateur.
*/
public abstract class OrderComponent {
protected OrderProcessingMediator mediator;
public void setMediator(OrderProcessingMediator mediator) {
this.mediator = mediator;
}
}
Les composants, déclarés comme beans pour que Spring les injecte dans le médiateur : chacun fait son travail et notifie le médiateur, sans appeler les autres composants.
@Component
public class InventoryManager extends OrderComponent {
private static final Logger log = LoggerFactory.getLogger(InventoryManager.class);
private final InventoryRepository inventoryRepository;
public InventoryManager(InventoryRepository inventoryRepository) {
this.inventoryRepository = inventoryRepository;
}
public boolean reserveStock(Order order) {
List<OrderLine> reserved = new ArrayList<>();
for (OrderLine line : order.getLines()) {
Long productId = line.getProduct().getId();
Inventory inventory = inventoryRepository.findByProductId(productId)
.orElseThrow();
if (inventory.getAvailable() < line.getQuantity()) {
log.warn("Stock insuffisant pour le produit {}", productId);
// Compense les réservations déjà faites pour cette commande
reserved.forEach(this::release);
mediator.notifyEvent(this, ProcessingEvent.STOCK_INSUFFICIENT, order);
return false;
}
inventory.reserve(line.getQuantity());
inventoryRepository.save(inventory);
reserved.add(line);
}
log.info("Stock réservé pour la commande #{}", order.getOrderNumber());
mediator.notifyEvent(this, ProcessingEvent.STOCK_RESERVED, order);
return true;
}
public void releaseStock(Order order) {
order.getLines().forEach(this::release);
mediator.notifyEvent(this, ProcessingEvent.STOCK_RELEASED, order);
}
private void release(OrderLine line) {
Inventory inventory = inventoryRepository
.findByProductId(line.getProduct().getId()).orElseThrow();
inventory.release(line.getQuantity());
inventoryRepository.save(inventory);
}
}
@Component
public class PaymentManager extends OrderComponent {
private static final Logger log = LoggerFactory.getLogger(PaymentManager.class);
private final PaymentGateway gateway;
public PaymentManager(PaymentGateway gateway) {
this.gateway = gateway;
}
public boolean processPayment(Order order) {
boolean paid;
try {
PaymentResult result = gateway.charge(
order.getCustomer().getPaymentMethod(),
order.getTotalAmount()
);
paid = result.isSuccess();
if (paid) {
log.info("Paiement accepté : {} EUR", order.getTotalAmount());
} else {
log.warn("Paiement refusé : {}", result.getErrorMessage());
}
} catch (Exception e) {
log.error("Erreur lors du paiement de la commande #{}", order.getOrderNumber(), e);
paid = false;
}
// La notification est hors du try/catch : seule la passerelle de paiement
// peut conduire à PAYMENT_FAILED. Une erreur en aval (expédition,
// notifications) ne doit pas être prise pour un échec du paiement.
mediator.notifyEvent(this,
paid ? ProcessingEvent.PAYMENT_SUCCESS : ProcessingEvent.PAYMENT_FAILED, order);
return paid;
}
}
@Component
public class ShippingManager extends OrderComponent {
private static final Logger log = LoggerFactory.getLogger(ShippingManager.class);
private final ShippingService shippingService;
public ShippingManager(ShippingService shippingService) {
this.shippingService = shippingService;
}
public void scheduleShipping(Order order) {
ShippingLabel label = shippingService.createLabel(order);
log.info("Expédition planifiée : tracking {}", label.getTrackingNumber());
mediator.notifyEvent(this, ProcessingEvent.SHIPPING_SCHEDULED, order);
}
}
@Component
public class NotificationManager extends OrderComponent {
private static final Logger log = LoggerFactory.getLogger(NotificationManager.class);
private final EmailService emailService;
private final SmsService smsService;
public NotificationManager(EmailService emailService, SmsService smsService) {
this.emailService = emailService;
this.smsService = smsService;
}
public void sendOrderConfirmation(Order order) {
emailService.sendOrderConfirmation(order.getCustomer().getEmail(), order);
if (order.getCustomer().getPhoneNumber() != null) {
smsService.sendSms(order.getCustomer().getPhoneNumber(),
"Commande #" + order.getOrderNumber() + " confirmée !");
}
log.info("Notifications envoyées pour la commande #{}", order.getOrderNumber());
}
public void sendPaymentFailureNotice(Order order, String reason) {
emailService.sendPaymentFailure(order.getCustomer().getEmail(), order, reason);
}
}
Le médiateur concret : c'est lui qui porte la logique de coordination. Chaque événement déclenche la réaction appropriée sur les autres composants :
@Service
public class OrderProcessingCoordinator implements OrderProcessingMediator {
private static final Logger log = LoggerFactory.getLogger(OrderProcessingCoordinator.class);
private final InventoryManager inventoryManager;
private final PaymentManager paymentManager;
private final ShippingManager shippingManager;
private final NotificationManager notificationManager;
public OrderProcessingCoordinator(InventoryManager inventoryManager,
PaymentManager paymentManager,
ShippingManager shippingManager,
NotificationManager notificationManager) {
this.inventoryManager = inventoryManager;
this.paymentManager = paymentManager;
this.shippingManager = shippingManager;
this.notificationManager = notificationManager;
// Enregistre le médiateur dans chaque composant
inventoryManager.setMediator(this);
paymentManager.setMediator(this);
shippingManager.setMediator(this);
notificationManager.setMediator(this);
}
@Override
public void notifyEvent(Object sender, ProcessingEvent event, Order order) {
log.info("Événement {} reçu de {}", event, sender.getClass().getSimpleName());
switch (event) {
case PAYMENT_SUCCESS -> shippingManager.scheduleShipping(order);
case SHIPPING_SCHEDULED -> notificationManager.sendOrderConfirmation(order);
case PAYMENT_FAILED -> {
// Coordination du retour arrière : libérer le stock, prévenir le client
inventoryManager.releaseStock(order);
notificationManager.sendPaymentFailureNotice(order, "Paiement refusé");
}
case STOCK_RESERVED, STOCK_INSUFFICIENT, STOCK_RELEASED ->
log.debug("Événement de stock enregistré : {}", event);
}
}
/**
* Point d'entrée : déclenche le début du traitement.
* La suite (expédition, notifications, compensation) est coordonnée
* par notifyEvent().
*/
public OrderResult processOrder(Order order) {
log.info("Traitement de la commande #{}", order.getOrderNumber());
if (!inventoryManager.reserveStock(order)) {
return OrderResult.failed("Stock insuffisant");
}
if (!paymentManager.processPayment(order)) {
return OrderResult.failed("Paiement refusé");
}
return OrderResult.success(order.getOrderNumber());
}
}
Si notifyEvent() se contentait de journaliser pendant que processOrder() appelait tous les composants dans l'ordre, on aurait un simple orchestrateur et non un Mediator. La différence : ici, les composants ne s'appellent pas entre eux, et les réactions à un événement vivent à un seul endroit. Une erreur levée en aval après un paiement réussi remonte à l'appelant sans déclencher PAYMENT_FAILED : le client a été débité, et c'est à une compensation explicite (un remboursement) de réparer cela. Cette centralisation a un coût : le médiateur peut devenir un objet-dieu si on lui confie trop de règles. Pour un processus long avec des échecs partiels sur plusieurs services, on se tourne plutôt vers une saga, avec des étapes de compensation explicites (par exemple un remboursement si l'expédition échoue après le paiement).
Mediator dans le monde réel
Plusieurs mécanismes jouent un rôle de médiateur, avec des nuances :
DispatcherServletde Spring MVC : point central qui reçoit les requêtes et les distribue aux contrôleurs, dans l'esprit du Front Controller, proche du Mediator- Bus d'événements et brokers de messages (RabbitMQ, Kafka) : producteurs et consommateurs ne se connaissent pas. On parle plutôt de publish-subscribe, parent du Mediator
- MediatR (.NET) : une bibliothèque dédiée à l'envoi de requêtes vers des handlers
- Contrôleurs MVC : le contrôleur fait le lien entre la vue et le modèle
Le conteneur Spring (ApplicationContext) est parfois cité comme un médiateur géant. L'analogie est lâche : il crée et injecte les beans, mais ne coordonne pas leurs interactions à l'exécution.
Mediator vs Observer
Les deux découplent les communications, d'où leur ressemblance :
- Observer : les observateurs s'inscrivent directement auprès du sujet. La communication est 1-vers-N, et le sujet ne coordonne pas les observateurs entre eux.
- Mediator : la communication est N-vers-N, centralisée. Le médiateur connaît les composants et orchestre leurs interactions.
Un ApplicationEventPublisher Spring est avant tout un Observer centralisé, un bus d'événements. On l'utilise parfois pour construire un Mediator, quand un composant réagit aux événements des autres pour les coordonner.
Ce qu'il faut retenir
| Pattern | Quand l'utiliser | Exemples JDK / Spring |
|---|---|---|
| Chain of Responsibility | Pipeline de traitements séquentiels | Filter, Spring Security FilterChain |
| Mediator | Communication centralisée N-vers-N | Orchestrateurs de services, bus d'événements |
La partie 3 clôt la série avec Iterator et Visitor, puis un scénario qui réunit plusieurs patterns comportementaux.
Partie précédente : Command. Partie suivante : Iterator et Visitor.
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) · Template Method et State (2/2)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3, cet article) · Iterator et Visitor (3/3)