Design Patterns Comportementaux en Java, communication (partie 2/3) : Chain of Responsibility et Mediator

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
5 min de lecture
4 vues

Écouter l'article

Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.


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

Diagramme de classes (diagramme 1)
Diagramme de classes (diagramme 1)

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.servlet avant Jakarta EE 9 et Spring Boot 3) : la chaîne de filtres Servlet suit exactement ce principe, avec FilterChain.doFilter() qui passe au suivant
  • Spring Security : la SecurityFilterChain est une chaîne d'une quinzaine de filtres selon la configuration (CORS, CSRF, authentification, autorisation...)
  • java.util.logging : un Logger transmet 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.

Diagramme de classes (diagramme 2)
Diagramme de classes (diagramme 2)

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 :

  • DispatcherServlet de 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.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.