Design Patterns Comportementaux en Java, orchestration (partie 1/2) : Strategy et Observer

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
6 min de lecture
355 vues

Écouter l'article

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


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

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

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

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

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.

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.