Programmation Java

Design patterns structurels et comportementaux en Java

Intermédiaire11 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Les patterns structurels décident de la façon dont les objets se composent ; les patterns comportementaux, de la façon dont ils collaborent et se partagent les responsabilités. Ce guide passe en revue ceux que vous rencontrerez dans toute base de code Java sérieuse — Adapter, Decorator, Facade, Proxy et Composite ; Strategy, Observer, Command, Template Method, Chain of Responsibility, State et Visitor — avec des implémentations idiomatiques en Java 21, leur place dans le JDK et Spring, et la manière dont les lambdas, les types scellés et le pattern matching les simplifient.

Ce que vous allez apprendre

  • Reconnaître Adapter, Decorator, Facade, Proxy et Composite dans le code des bibliothèques et des frameworks
  • Implémenter Strategy, Command et Observer avec des interfaces fonctionnelles quand c'est pertinent
  • Appliquer Template Method, Chain of Responsibility et State pour éliminer des conditionnelles
  • Remplacer Visitor par des interfaces scellées et un switch avec pattern matching quand cela s'y prête
  • Expliquer comment les proxies de Spring et les flux java.io incarnent ces patterns

Une fois les objets créés, deux questions demeurent : comment les assembler en structures plus grandes, et comment répartir le comportement entre eux pour que chaque changement ne touche qu'un seul endroit. Les patterns structurels et comportementaux du catalogue classique répondent à ces questions et, contrairement à certains patterns de création, ils ont bien vieilli : la plupart se voient dans le JDK, dans Spring et dans tous les frameworks web. Ce guide retient les douze que vous avez le plus de chances d'utiliser ou de rencontrer, donne pour chacun une implémentation en Java 21 et montre où les fonctionnalités modernes du langage allègent la forme classique. Les patterns de création sont traités à part.

Patterns structurels : composer des objets

Adapter

Intention : convertir l'interface d'une classe en celle qu'attend un client, pour que des classes qui ne pourraient pas collaborer autrement le puissent.

Il vous faut un adaptateur quand le code appelant et le code appelé vous appartiennent, mais pas l'interface entre les deux — typiquement pour intégrer une bibliothèque tierce derrière votre propre abstraction :

public interface PaymentGateway {
    PaymentResult charge(Money amount, CardToken card);
}

// Third-party SDK with a different vocabulary
public final class StripeGatewayAdapter implements PaymentGateway {
    private final StripeClient stripe;

    public StripeGatewayAdapter(StripeClient stripe) { this.stripe = stripe; }

    @Override
    public PaymentResult charge(Money amount, CardToken card) {
        ChargeResponse response = stripe.charges().create(
                new ChargeRequest(amount.minorUnits(), amount.currency().code(), card.value()));
        return response.succeeded()
                ? new Approved(response.id())
                : new Declined(response.failureCode());
    }
}

Le code applicatif dépend de PaymentGateway ; l'adaptateur traduit les types, les unités et les modèles d'erreur en un seul endroit. Arrays.asList (d'un tableau vers une List), InputStreamReader (d'un flux d'octets vers un lecteur de caractères) et Collections.enumeration sont des adaptateurs du JDK. C'est la façon la plus propre d'empêcher le SDK d'un fournisseur de se répandre dans toute la base de code, et cela rend ce fournisseur remplaçable.

Decorator

Intention : attacher dynamiquement des responsabilités à un objet en l'enveloppant dans un objet de même interface.

Les décorateurs s'empilent : chacun implémente l'interface, détient une autre instance de celle-ci et ajoute quelque chose avant ou après la délégation. Les classes de flux de java.io en sont l'exemple canonique — new BufferedInputStream(new GZIPInputStream(new FileInputStream(f))) — et le guide sur les E/S les montre à l'œuvre. Dans du code de service, la même forme ajoute un comportement transverse sans toucher à la classe principale :

public interface ProductCatalogue {
    Optional<Product> find(String sku);
}

public final class CachingCatalogue implements ProductCatalogue {
    private final ProductCatalogue delegate;
    private final Map<String, Optional<Product>> cache = new ConcurrentHashMap<>();

    public CachingCatalogue(ProductCatalogue delegate) { this.delegate = delegate; }

    @Override
    public Optional<Product> find(String sku) {
        return cache.computeIfAbsent(sku, delegate::find);
    }
}

public final class TimingCatalogue implements ProductCatalogue {
    private final ProductCatalogue delegate;
    private final MeterRegistry metrics;
    // ... constructor

    @Override
    public Optional<Product> find(String sku) {
        long start = System.nanoTime();
        try {
            return delegate.find(sku);
        } finally {
            metrics.timer("catalogue.find").record(Duration.ofNanos(System.nanoTime() - start));
        }
    }
}

ProductCatalogue catalogue = new TimingCatalogue(new CachingCatalogue(new DatabaseCatalogue(ds)), metrics);

L'ordre d'enveloppement est l'ordre d'exécution, ce qui fait à la fois la puissance et le piège du pattern : un chronométrage placé autour du cache mesure aussi les hits ; un cache placé autour du chronométrage ne mesure que les misses. Collections.unmodifiableList et synchronizedList sont des décorateurs, tout comme chaque advice Spring AOP — d'où l'importance du pattern suivant.

Proxy

Intention : fournir un substitut qui contrôle l'accès à un autre objet.

Un proxy a la même interface que son sujet et, contrairement à un décorateur, existe pour gérer ce sujet : le créer à la demande, le protéger par une vérification de permissions, transmettre les appels à une implémentation distante ou intercepter les appels pour le compte d'un framework. Java sait générer des proxies à l'exécution :

public interface AccountService { void close(String accountId); }

AccountService real = new DefaultAccountService();
AccountService guarded = (AccountService) Proxy.newProxyInstance(
        AccountService.class.getClassLoader(),
        new Class<?>[] { AccountService.class },
        (proxy, method, args) -> {
            if (!currentUser().hasRole("ADMIN")) {
                throw new AccessDeniedException(method.getName());
            }
            return method.invoke(real, args);            // InvocationHandler: intercept every call
        });

java.lang.reflect.Proxy fonctionne avec des interfaces ; CGLIB et ByteBuddy sous-classent des classes concrètes. C'est exactement ainsi que Spring implémente @Transactional, @Cacheable, @Async et les annotations de sécurité : le bean injecté est un proxy autour de votre objet, et le comportement de l'annotation s'exécute dans la couche d'interception. Cela explique aussi le grand classique des énigmes Spring : une méthode @Transactional appelée depuis une autre méthode de la même classe n'est pas transactionnelle, car l'appel interne ne passe jamais par le proxy. Les entités JPA à chargement paresseux et les stubs RMI sont eux aussi des proxies.

Facade

Intention : offrir une interface unique et simplifiée à un sous-système.

Une façade n'ajoute pas de comportement ; elle cache un ensemble de classes qui collaborent derrière un point d'entrée unique, pour que les appelants n'aient pas à connaître la chorégraphie :

public final class CheckoutFacade {
    private final InventoryService inventory;
    private final PaymentGateway payments;
    private final OrderRepository orders;
    private final Notifier notifier;
    // ... constructor

    public OrderConfirmation checkout(Cart cart, CardToken card) {
        ReservationToken reservation = inventory.reserve(cart.lines());
        PaymentResult payment = payments.charge(cart.total(), card);
        if (payment instanceof Declined d) {
            inventory.release(reservation);
            throw new PaymentDeclinedException(d.reason());
        }
        Order order = orders.save(Order.from(cart, payment));
        notifier.orderPlaced(order);
        return new OrderConfirmation(order.id());
    }
}

Dans une architecture en couches, un service applicatif est une façade sur le domaine ; javax.crypto.Cipher est une façade sur les providers ; Files peut passer pour une façade sur les providers de systèmes de fichiers. C'est souvent le premier refactoring à appliquer à un contrôleur qui a fini par orchestrer lui-même cinq services.

Composite

Intention : composer des objets en arborescences et permettre aux clients de traiter uniformément objets individuels et compositions.

Dès qu'une chose peut contenir des choses de sa propre espèce — répertoires et fichiers, conteneurs et widgets d'interface, groupes et éléments de menu, nœuds d'expression —, Composite donne la même interface à la feuille et au conteneur, si bien que les opérations deviennent naturellement récursives :

public sealed interface Expense permits LineItem, ExpenseGroup {
    long totalCents();
}

public record LineItem(String label, long cents) implements Expense {
    @Override public long totalCents() { return cents; }
}

public record ExpenseGroup(String label, List<Expense> children) implements Expense {
    @Override public long totalCents() {
        return children.stream().mapToLong(Expense::totalCents).sum();   // recursion through the interface
    }
}

Un appelant demande son total à n'importe quelle Expense sans jamais vérifier s'il s'agit d'un groupe. Le Container extends Component de Swing, le DOM et le Path d'un parcours de système de fichiers sont des Composites. Le prix à payer : les opérations qui n'ont de sens que pour les conteneurs (add, remove) polluent l'interface des feuilles ou imposent un cast ; les réserver au seul type conteneur, comme ci-dessus, est le choix le plus sûr.

Bridge et Flyweight complètent la famille structurelle. Bridge sépare une abstraction de son implémentation pour que chacune puisse varier — la Connection de JDBC au-dessus de multiples drivers — et revient en pratique à « programmer vers une interface et injecter l'implémentation ». Flyweight partage un état immuable entre de nombreux objets pour économiser la mémoire ; le cache des petites valeurs d'Integer.valueOf et l'internement des String en sont les exemples dans le JDK, et des records dédupliqués via equals dans une map sont la façon moderne d'en construire un.

Patterns comportementaux : attribuer les responsabilités

Strategy

Intention : définir une famille d'algorithmes interchangeables et laisser le client en choisir un à l'exécution.

Strategy remplace une conditionnelle sur « quel algorithme » par du polymorphisme. Avant les lambdas, chaque stratégie était une classe ; désormais, une stratégie à méthode unique est une interface fonctionnelle, et l'on garde une interface nommée là où le nom métier le justifie :

@FunctionalInterface
public interface ShippingRule {
    Money cost(Shipment shipment);

    static ShippingRule flat(Money amount)      { return s -> amount; }
    static ShippingRule perKilo(Money perKilo)  { return s -> perKilo.times(s.weightKg()); }
    static ShippingRule freeAbove(Money threshold, ShippingRule otherwise) {
        return s -> s.orderValue().isGreaterThan(threshold) ? Money.ZERO : otherwise.cost(s);
    }
}

Map<Region, ShippingRule> rules = Map.of(
        Region.EU, ShippingRule.freeAbove(Money.eur(50), ShippingRule.flat(Money.eur(4))),
        Region.US, ShippingRule.perKilo(Money.usd(3)));

Money cost = rules.get(shipment.region()).cost(shipment);

Comparator est la Strategy la plus utilisée du JDK ; ThreadFactory, RejectedExecutionHandler et chaque Function passée à un stream en sont aussi. Une Map qui associe une clé à une stratégie, comme ci-dessus, remplace un switch qu'il faudrait modifier à chaque nouveau cas.

Template Method

Intention : figer le squelette d'un algorithme dans une superclasse et laisser les sous-classes redéfinir certaines étapes.

Le guide sur l'héritage présente la forme classique à base de classe abstraite. Sa force est de garantir l'ordre des étapes ; sa faiblesse est de reposer sur l'héritage : une classe ne peut suivre qu'un seul template, et le couplage est fort. L'alternative fonctionnelle passe les étapes variables en paramètres, et c'est ainsi que fonctionnent JdbcTemplate, TransactionTemplate et RestTemplate dans Spring : le template gère l'acquisition des ressources, la traduction des exceptions et le nettoyage, et un callback fournit le cœur du traitement. Quand vous vous surprenez à écrire le même try/finally autour de corps différents, c'est un template qui attend d'être extrait.

Observer

Intention : lorsqu'un objet change d'état, notifier automatiquement tous ceux qui en dépendent sans que le sujet sache qui ils sont.

public final class OrderTracker {
    private final List<Consumer<OrderEvent>> listeners = new CopyOnWriteArrayList<>();

    public Runnable subscribe(Consumer<OrderEvent> listener) {
        listeners.add(listener);
        return () -> listeners.remove(listener);          // unsubscribe handle
    }

    void publish(OrderEvent event) {
        for (Consumer<OrderEvent> l : listeners) {
            try {
                l.accept(event);
            } catch (RuntimeException e) {
                log.warn("Listener failed for {}", event, e);   // one bad listener must not stop the rest
            }
        }
    }
}

CopyOnWriteArrayList permet sans risque à un listener de se désabonner pendant la notification. Deux questions de conception déterminent si une implémentation d'Observer posera problème : à qui appartient le thread — une notification synchrone bloque l'émetteur et hérite de sa transaction, une notification asynchrone exige un executor et perd les garanties d'ordre ; et comment les listeners sont retirés — un listener oublié est une fuite mémoire, raison pour laquelle la méthode d'abonnement renvoie un handle de désabonnement. L'ApplicationEventPublisher de Spring avec @EventListener (et @TransactionalEventListener pour déclencher après le commit) vous offre ce pattern avec toute l'intendance prise en charge ; java.util.Observable est déprécié et PropertyChangeSupport ne survit guère que dans le code desktop.

Command

Intention : encapsuler une requête dans un objet pour pouvoir la mettre en file d'attente, la journaliser, l'annuler ou l'exécuter plus tard.

Un Runnable est une Command sans annulation, et ExecutorService.submit joue le rôle d'invocateur. Le pattern prend toute sa forme quand les commandes ont besoin d'une identité et d'être réversibles :

public interface EditorCommand {
    void execute(Document doc);
    void undo(Document doc);
}

public record InsertText(int offset, String text) implements EditorCommand {
    @Override public void execute(Document doc) { doc.insert(offset, text); }
    @Override public void undo(Document doc)    { doc.delete(offset, text.length()); }
}

public final class CommandHistory {
    private final Deque<EditorCommand> done = new ArrayDeque<>();
    private final Document doc;
    // ... constructor

    public void run(EditorCommand cmd) { cmd.execute(doc); done.push(cmd); }
    public void undo() { if (!done.isEmpty()) done.pop().undo(doc); }
}

Avec les records, les commandes ne coûtent presque rien, se sérialisent facilement à la main et se journalisent sans effort. Au-delà des éditeurs, Command sous-tend les files de jobs, les command handlers de CQRS, les transactional outboxes et les journaux d'audit : partout où « ce qui a été demandé » doit exister indépendamment de « qui le fait ».

Chain of Responsibility

Intention : faire passer une requête le long d'une chaîne de handlers jusqu'à ce que l'un d'eux la traite, de sorte que l'émetteur soit découplé du destinataire.

Les filtres de servlet, la filter chain de Spring Security, les intercepteurs des clients HTTP et les appenders de logging sont tous des chaînes. La forme moderne est une liste de handlers dotés d'une continuation next :

@FunctionalInterface
public interface Handler {
    Response handle(Request request, Chain chain);

    interface Chain { Response proceed(Request request); }
}

public final class Pipeline {
    private final List<Handler> handlers;
    private final Function<Request, Response> terminal;
    // ... constructor

    public Response run(Request request) { return proceed(0, request); }

    private Response proceed(int index, Request request) {
        if (index == handlers.size()) return terminal.apply(request);
        return handlers.get(index).handle(request, r -> proceed(index + 1, r));
    }
}

Pipeline pipeline = new Pipeline(List.of(
        (req, chain) -> req.hasValidToken() ? chain.proceed(req) : Response.unauthorised(),
        (req, chain) -> { log.info("{} {}", req.method(), req.path()); return chain.proceed(req); },
        (req, chain) -> rateLimiter.allow(req.clientId()) ? chain.proceed(req) : Response.tooManyRequests()),
        controller::dispatch);

Chaque handler décide s'il s'arrête, transforme la requête ou appelle chain.proceed. Comme un handler peut aussi agir après le retour de proceed, la chaîne fait également office de pile d'advices « around ». Gardez les handlers sans état et ordonnez-les explicitement : une chaîne assemblée par scan du classpath dans un ordre indéfini est un cauchemar à déboguer.

State

Intention : permettre à un objet de changer de comportement quand son état interne change, en déléguant à un objet état.

State remplace un switch (status) répété dans chaque méthode par une classe (ou une constante d'enum) par état, qui connaît ses propres transitions :

public enum OrderStatus {
    NEW {
        @Override OrderStatus pay()    { return PAID; }
        @Override OrderStatus cancel() { return CANCELLED; }
    },
    PAID {
        @Override OrderStatus ship()   { return SHIPPED; }
        @Override OrderStatus cancel() { return REFUNDED; }
    },
    SHIPPED, CANCELLED, REFUNDED;

    OrderStatus pay()    { throw new IllegalStateException("Cannot pay from " + this); }
    OrderStatus ship()   { throw new IllegalStateException("Cannot ship from " + this); }
    OrderStatus cancel() { throw new IllegalStateException("Cannot cancel from " + this); }
}

Les constantes d'enum avec corps sont l'implémentation de State la plus légère en Java : chaque transition est au même endroit, les transitions invalides échouent bruyamment, et le compilateur énumère tous les états. Quand les états portent des données ou ont besoin de services injectés, utilisez une interface scellée avec un record par état et faites un switch dessus. Dans les deux cas, l'intérêt du pattern est qu'ajouter un état revient à ajouter une classe, et non à modifier chaque méthode.

Visitor, et son remplaçant à base de types scellés

Intention : représenter une opération à effectuer sur les éléments d'une structure d'objets sans modifier les classes de ces éléments.

Le Visitor classique réalise un double dispatch — le comportement dépend à la fois du type de l'élément et de l'opération — grâce à une méthode accept(Visitor) sur chaque élément et une méthode visitX par type d'élément sur le visiteur. Ajouter une opération est facile (un nouveau visiteur) ; ajouter un type d'élément est pénible (tous les visiteurs changent). C'est la bonne forme pour les compilateurs et les modèles de documents qui appliquent de nombreuses opérations à un ensemble stable de types de nœuds, et c'est ainsi que sont construites les API javax.lang.model et FileVisitor du JDK.

Java 21 propose une voie plus légère pour les hiérarchies qui vous appartiennent : scellez l'interface et faites un switch avec des record patterns.

public sealed interface Shape permits Circle, Rectangle, Group {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Group(List<Shape> children) implements Shape {}

static double area(Shape shape) {
    return switch (shape) {
        case Circle(double r)          -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
        case Group(List<Shape> kids)   -> kids.stream().mapToDouble(Program::area).sum();
    };
}

Chaque opération tient en une fonction exhaustive ; ajouter une forme casse chaque switch à la compilation, ce qui est précisément la garantie qu'apportait Visitor via l'interface. Gardez le Visitor classique quand la hiérarchie est ouverte, qu'elle vit dans un autre module, ou quand une instance de visiteur doit accumuler de l'état au fil d'un parcours de façon réutilisable.

Iterator, Mediator, Memento et Interpreter complètent le catalogue comportemental. Iterator est intégré au langage via Iterable et la boucle for-each ; implémentez-le pour vos propres agrégats afin qu'ils fonctionnent avec les streams (StreamSupport.stream(spliterator(), false)). Mediator centralise la communication entre composants — un contrôleur entre les champs d'un formulaire, un broker de messages entre des services. Memento capture un état pour le restaurer plus tard ; un record immuable servant d'instantané suffit généralement. Interpreter construit un évaluateur au-dessus d'une grammaire et se prête lui aussi naturellement aux types scellés.

Erreurs fréquentes

La conception guidée par les patterns. Choisir des patterns d'abord puis chercher où les caser produit des fabriques abstraites de visiteurs de stratégies que personne ne peut suivre. Partez de la duplication ou du changement que vous n'arrivez pas à faire proprement, puis nommez la solution.

L'ordre des décorateurs laissé implicite. Envelopper dans le mauvais ordre (retry autour du timeout, ou cache autour de l'autorisation) change la sémantique sans bruit. Documentez l'ordre ou construisez la pile dans une seule méthode de fabrique.

L'auto-invocation à travers un proxy. Appeler une méthode annotée depuis le même bean Spring contourne le proxy ; déplacez la méthode dans un autre bean, et n'injectez le bean dans lui-même qu'en dernier recours.

Les observers qui ne se désabonnent jamais. Le sujet détient une référence forte, si bien que le listener et tout ce qu'il référence vivent éternellement. Renvoyez des handles de désabonnement et appelez-les.

Bloquer ou lever une exception dans un listener. Un listener lent paralyse l'émetteur ; une exception peut empêcher les listeners suivants de s'exécuter. Isolez chaque appel et envisagez un executor.

Des classes Strategy pour des one-liners. Une classe PlusOneStrategy qui implémente IntUnaryOperator, c'est une lambda déguisée.

Visitor sur une hiérarchie ouverte impossible à sceller. Chaque nouveau type casse tous les visiteurs ; utilisez une méthode par défaut ou un switch avec un default, et acceptez de perdre l'exhaustivité.

Un état mutable dans des handlers partagés. Les filtres et les handlers de chaîne sont utilisés en concurrence ; un champ qui change à chaque requête, c'est une race condition.

Points clés à retenir

  • Adapter change une interface, Decorator ajoute du comportement derrière la même interface, Proxy contrôle l'accès — tous par enveloppement, et tous visibles dans java.io, les utilitaires de collections et Spring AOP.
  • Facade simplifie un sous-système derrière un point d'entrée unique ; Composite permet de traiter des arborescences comme des objets uniques grâce à une interface commune.
  • Strategy, Command et Observer se ramènent désormais largement à des interfaces fonctionnelles, des lambdas et des listes de Consumer ; gardez des types nommés là où le sens métier ou la présence de plusieurs méthodes le justifie.
  • Template Method fige l'ordre des étapes ; sa forme à callback (JdbcTemplate) évite l'héritage.
  • Chain of Responsibility et State éliminent des conditionnelles : des handlers dotés d'une continuation proceed, et des constantes d'enum ou des records scellés qui portent leurs propres transitions.
  • Les interfaces scellées associées au switch avec pattern matching remplacent Visitor pour les hiérarchies que vous maîtrisez, en conservant l'exhaustivité sans le code répétitif.

Sources et lectures complémentaires

Questions fréquentes

Quelle différence entre Decorator, Proxy et Adapter ?
Tous trois enveloppent un objet, mais pour des raisons différentes. Un Adapter change l'interface pour qu'une classe existante s'insère là où un autre type est attendu. Un Decorator conserve la même interface et ajoute du comportement, et les décorateurs s'empilent. Un Proxy conserve lui aussi l'interface mais contrôle l'accès à l'objet réel — en le créant à la demande, en vérifiant des permissions, en ajoutant de l'accès distant ou du cache — et le client ignore généralement sa présence.
Les lambdas rendent-elles le pattern Strategy obsolète ?
Elles rendent la hiérarchie de classes facultative. Quand une stratégie se résume à une seule opération, une interface fonctionnelle avec des lambdas ou des références de méthode, c'est le pattern Strategy avec moins de code. Gardez une interface et des classes nommées quand la stratégie a plusieurs méthodes, a besoin de configuration ou d'état, ou quand son nom porte un sens métier qu'une simple Function ferait perdre.
Faut-il utiliser le pattern Observer ou un bus d'événements ?
L'Observer direct — un sujet qui tient une liste de listeners — convient à l'intérieur d'un composant, et c'est ce qu'utilisent les toolkits d'interface graphique et PropertyChangeSupport. Dès que plusieurs composants sans lien réagissent aux mêmes événements, un mécanisme d'événements en mémoire comme l'ApplicationEventPublisher de Spring découple les émetteurs des abonnés et permet de rendre les handlers asynchrones ou transactionnels. Entre processus, utilisez un broker de messages.
Visitor est-il encore utile maintenant que Java a le pattern matching ?
Pour vos propres hiérarchies fermées, une interface scellée et un switch avec des record patterns offrent le même double dispatch, avec vérification d'exhaustivité et bien moins de code répétitif. Visitor reste utile quand la hiérarchie n'est pas scellée, quand elle vit dans une bibliothèque que vous ne pouvez pas modifier, ou quand vous voulez que l'objet visiteur soit lui-même un composant réutilisable et doté d'un état qu'on fait circuler.