Programación en Java

Patrones de diseño estructurales y de comportamiento en Java

Intermedio11 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Los patrones estructurales deciden cómo se componen los objetos; los de comportamiento, cómo colaboran y se reparten las responsabilidades. Esta guía repasa los que encontrarás en cualquier código Java serio — Adapter, Decorator, Facade, Proxy y Composite; Strategy, Observer, Command, Template Method, Chain of Responsibility, State y Visitor — con implementaciones idiomáticas en Java 21, los lugares donde aparecen en el JDK y en Spring, y cómo las lambdas, los tipos sellados y el pattern matching los simplifican.

Lo que aprenderás

  • Reconocer Adapter, Decorator, Facade, Proxy y Composite en el código de librerías y frameworks
  • Implementar Strategy, Command y Observer con interfaces funcionales cuando convenga
  • Aplicar Template Method, Chain of Responsibility y State para eliminar condicionales
  • Sustituir Visitor por interfaces selladas y switch con pattern matching cuando encaje
  • Explicar cómo los proxies de Spring y los streams de java.io encarnan estos patrones

Una vez que los objetos existen, quedan dos preguntas: cómo combinarlos en estructuras mayores y cómo repartir el comportamiento entre ellos para que cada cambio toque un solo sitio. Los patrones estructurales y de comportamiento del catálogo clásico responden a esas preguntas y, a diferencia de algunos patrones creacionales, han envejecido bien: la mayoría se ven en el JDK, en Spring y en cualquier framework web. Esta guía recoge los doce que más probablemente vas a usar o encontrarte, da para cada uno una implementación en Java 21 y muestra dónde las características modernas del lenguaje reducen la forma clásica. Los patrones creacionales se tratan por separado.

Patrones estructurales: componer objetos

Adapter

Propósito: convertir la interfaz de una clase en la que espera un cliente, para que puedan colaborar clases que de otro modo no podrían.

Necesitas un adaptador cuando el código que llama y el código llamado son tuyos, pero no la interfaz entre ambos; lo típico es integrar una librería de terceros detrás de tu propia abstracción:

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());
    }
}

El código de la aplicación depende de PaymentGateway; el adaptador traduce tipos, unidades y modelos de error en un único lugar. Arrays.asList (de array a List), InputStreamReader (de flujo de bytes a lector de caracteres) y Collections.enumeration son adaptadores del JDK. Es la forma más limpia de evitar que el SDK de un proveedor se filtre por todo el código, y hace que ese proveedor sea reemplazable.

Decorator

Propósito: añadir responsabilidades a un objeto de forma dinámica envolviéndolo en otro objeto con la misma interfaz.

Los decoradores se apilan: cada uno implementa la interfaz, guarda otra instancia de ella y añade algo antes o después de delegar. Las clases de streams de java.io son el ejemplo canónico — new BufferedInputStream(new GZIPInputStream(new FileInputStream(f))) — y la guía de E/S las muestra en acción. En código de servicios, la misma forma añade comportamiento transversal sin tocar la clase principal:

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);

El orden en que envuelves es el orden del comportamiento, y ahí están a la vez la potencia y la trampa: si la medición de tiempos envuelve a la caché, mide también los aciertos; si la caché envuelve a la medición, solo se miden los fallos. Collections.unmodifiableList y synchronizedList son decoradores, igual que cualquier advice de Spring AOP, y por eso importa el siguiente patrón.

Proxy

Propósito: proporcionar un sustituto que controla el acceso a otro objeto.

Un proxy tiene la misma interfaz que su sujeto y, a diferencia de un decorador, existe para gestionarlo: crearlo bajo demanda, protegerlo con una comprobación de permisos, reenviar las llamadas a una implementación remota o interceptarlas en nombre de un framework. Java puede generar proxies en tiempo de ejecución:

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 funciona con interfaces; CGLIB y ByteBuddy crean subclases de clases concretas. Así es exactamente como Spring implementa @Transactional, @Cacheable, @Async y las anotaciones de seguridad: el bean que inyectas es un proxy alrededor de tu objeto, y el comportamiento de la anotación se ejecuta en la capa de intercepción. También explica el clásico rompecabezas de Spring: un método @Transactional llamado desde otro método de la misma clase no es transaccional, porque la llamada interna nunca pasa por el proxy. Las entidades JPA con carga perezosa y los stubs de RMI también son proxies.

Facade

Propósito: ofrecer una interfaz única y simplificada a un subsistema.

Una fachada no añade comportamiento; oculta un conjunto de clases que colaboran tras un único punto de entrada, para que quien llama no tenga que conocer la coreografía:

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());
    }
}

En una arquitectura por capas, un servicio de aplicación es una fachada sobre el dominio; javax.crypto.Cipher es una fachada sobre los providers; Files es, en cierto modo, una fachada sobre los providers de sistemas de archivos. Suele ser la primera refactorización que conviene aplicar a un controlador que ha acabado orquestando él solo cinco servicios.

Composite

Propósito: componer objetos en estructuras de árbol y permitir que los clientes traten por igual los objetos individuales y las composiciones.

Siempre que algo pueda contener cosas de su mismo tipo — directorios y archivos, contenedores y widgets de interfaz, grupos y elementos de menú, nodos de expresiones —, Composite da la misma interfaz a la hoja y al contenedor, de modo que las operaciones recorren el árbol de forma natural:

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
    }
}

Quien llama pide el total a cualquier Expense sin comprobar nunca si es un grupo. El Container extends Component de Swing, el DOM y el Path de un recorrido por el sistema de archivos son Composites. El coste del patrón es que las operaciones que solo tienen sentido para los contenedores (add, remove) o bien ensucian la interfaz de las hojas o bien obligan a hacer un cast; dejarlas solo en el tipo contenedor, como arriba, es la opción más segura.

Bridge y Flyweight completan el grupo estructural. Bridge separa una abstracción de su implementación para que ambas puedan variar — la Connection de JDBC sobre muchos drivers — y en la práctica se reduce a «programar contra una interfaz e inyectar la implementación». Flyweight comparte estado inmutable entre muchos objetos para ahorrar memoria; la caché de valores pequeños de Integer.valueOf y el interning de String son los ejemplos del JDK, y los records deduplicados por equals en un mapa son la forma moderna de construir uno.

Patrones de comportamiento: repartir responsabilidades

Strategy

Propósito: definir una familia de algoritmos intercambiables y dejar que el cliente elija uno en tiempo de ejecución.

Strategy sustituye un condicional sobre «qué algoritmo» por polimorfismo. Antes de las lambdas cada estrategia era una clase; ahora una estrategia de un solo método es una interfaz funcional, y se mantiene una interfaz con nombre donde el nombre de dominio lo justifica:

@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 es la Strategy más usada del JDK; también lo son ThreadFactory, RejectedExecutionHandler y cualquier Function que pases a un stream. Un Map de clave a estrategia, como el de arriba, sustituye a un switch que habría que editar cada vez que se añade un caso.

Template Method

Propósito: fijar el esqueleto de un algoritmo en una superclase y dejar que las subclases sobrescriban pasos concretos.

La guía de herencia muestra la forma clásica con una clase abstracta. Su fuerza es que garantiza el orden de los pasos; su debilidad, que se basa en la herencia, así que una clase solo puede seguir una plantilla y el acoplamiento es fuerte. La alternativa funcional pasa los pasos variables como parámetros, y así funcionan JdbcTemplate, TransactionTemplate y RestTemplate en Spring: la plantilla se encarga de obtener los recursos, traducir las excepciones y limpiar, y un callback aporta la parte central. Cuando te veas escribiendo el mismo try/finally alrededor de cuerpos distintos, tienes una plantilla esperando a ser extraída.

Observer

Propósito: cuando un objeto cambia de estado, notificar automáticamente a todos sus dependientes sin que el sujeto sepa quiénes son.

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 hace que sea seguro que un listener se dé de baja durante la notificación. Dos decisiones de diseño determinan si una implementación de Observer va a dar problemas: de quién es el thread — la notificación síncrona bloquea al publicador y hereda su transacción, mientras que la asíncrona necesita un executor y pierde las garantías de orden — y cómo se eliminan los listeners — un listener olvidado es una fuga de memoria, y por eso el método de suscripción devuelve un handle para darse de baja. El ApplicationEventPublisher de Spring con @EventListener (y @TransactionalEventListener para dispararse tras el commit) es el patrón con toda la gestión ya resuelta; java.util.Observable está obsoleto y PropertyChangeSupport sobrevive sobre todo en código de escritorio.

Command

Propósito: encapsular una petición como objeto para poder encolarla, registrarla, deshacerla o ejecutarla más tarde.

Un Runnable es un Command sin deshacer, y ExecutorService.submit es el invocador. El patrón muestra su forma completa cuando los comandos necesitan identidad y poder revertirse:

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); }
}

Con los records, los comandos salen baratos, se serializan a mano con facilidad y son fáciles de registrar en logs. Más allá de los editores, Command es la base de las colas de trabajos, los command handlers de CQRS, los transactional outboxes y los registros de auditoría: todo lo que requiera que «lo que se pidió» sobreviva con independencia de «quién lo hace».

Chain of Responsibility

Propósito: pasar una petición por una cadena de handlers hasta que uno se ocupe de ella, de modo que el emisor quede desacoplado del receptor.

Los filtros de servlet, la filter chain de Spring Security, los interceptores de los clientes HTTP y los appenders de logging son cadenas. La forma moderna es una lista de handlers con una continuación 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);

Cada handler decide si se detiene, transforma la petición o llama a chain.proceed. Como un handler también puede actuar después de que proceed devuelva el control, la cadena funciona además como una pila de advices «around». Mantén los handlers sin estado y ordénalos de forma explícita: una cadena montada escaneando el classpath en un orden indefinido es una pesadilla a la hora de depurar.

State

Propósito: permitir que un objeto cambie de comportamiento cuando cambia su estado interno, delegando en un objeto de estado.

State sustituye un switch (status) repetido en cada método por una clase (o constante de enum) por estado que conoce sus propias transiciones:

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); }
}

Las constantes de enum con cuerpo son la implementación de State más ligera en Java: cada transición está en un solo sitio, las transiciones inválidas fallan de forma ruidosa y el compilador enumera todos los estados. Cuando los estados llevan datos o necesitan servicios inyectados, usa una interfaz sellada con un record por estado y haz un switch sobre ella. En cualquier caso, la ventaja del patrón es que añadir un estado significa añadir una clase, no editar todos los métodos.

Visitor y su sustituto con tipos sellados

Propósito: representar una operación que se aplica a los elementos de una estructura de objetos sin modificar las clases de esos elementos.

El Visitor clásico consigue el double dispatch — el comportamiento depende tanto del tipo del elemento como de la operación — con un método accept(Visitor) en cada elemento y un método visitX por tipo de elemento en el visitante. Añadir una operación es fácil (un visitante nuevo); añadir un tipo de elemento es doloroso (cambian todos los visitantes). Es la forma adecuada para compiladores y modelos de documentos con muchas operaciones sobre un conjunto estable de tipos de nodo, y así están construidas las API javax.lang.model y FileVisitor del JDK.

Java 21 ofrece una vía más ligera para las jerarquías que son tuyas: sella la interfaz y haz un switch con 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();
    };
}

Cada operación es una única función exhaustiva; añadir una figura rompe todos los switch en tiempo de compilación, que es justo la garantía que Visitor daba a través de la interfaz. Quédate con el Visitor clásico cuando la jerarquía sea abierta, viva en otro módulo o cuando una instancia del visitante tenga que acumular estado durante un recorrido de forma reutilizable.

Iterator, Mediator, Memento e Interpreter completan el catálogo de comportamiento. Iterator está integrado en el lenguaje mediante Iterable y el bucle for-each; impleméntalo en tus propios agregados para que funcionen con streams (StreamSupport.stream(spliterator(), false)). Mediator centraliza la comunicación entre componentes: un controlador entre los campos de un formulario, un broker de mensajes entre servicios. Memento captura un estado para restaurarlo más tarde; normalmente basta con un record inmutable que haga de instantánea. Interpreter construye un evaluador sobre una gramática y también encaja de forma natural con los tipos sellados.

Errores comunes

Diseñar a partir de los patrones. Elegir primero los patrones y luego buscar dónde meterlos produce fábricas abstractas de visitantes de estrategias que nadie es capaz de seguir. Parte de la duplicación o del cambio que no puedes hacer limpiamente y luego ponle nombre a la solución.

Dejar implícito el orden de los decoradores. Envolver en el orden equivocado (el reintento envolviendo al timeout, o la caché envolviendo a la autorización) cambia la semántica sin avisar. Documenta el orden o constrúyelo en un único método de fábrica.

Autoinvocación a través de un proxy. Llamar a un método anotado desde el mismo bean de Spring se salta el proxy; mueve el método a otro bean y recurre a inyectar el bean en sí mismo solo como último recurso.

Observers que nunca se dan de baja. El sujeto mantiene una referencia fuerte, así que el listener y todo lo que referencia viven para siempre. Devuelve handles para darse de baja y llámalos.

Bloquear o lanzar excepciones dentro de los listeners. Un listener lento frena al publicador; una excepción puede impedir que se ejecuten los siguientes. Aísla cada llamada y plantéate usar un executor.

Clases Strategy para una sola línea. Una clase PlusOneStrategy que implementa IntUnaryOperator es una lambda disfrazada.

Visitor sobre una jerarquía abierta que no puedes sellar. Cada tipo nuevo rompe todos los visitantes; usa un método por defecto o un switch con default y asume la pérdida de exhaustividad.

Estado mutable en handlers compartidos. Los filtros y los handlers de la cadena se usan de forma concurrente; un campo que cambia en cada petición es una condición de carrera.

Puntos clave

  • Adapter cambia una interfaz, Decorator añade comportamiento tras la misma interfaz y Proxy controla el acceso; todos envolviendo, y todos visibles en java.io, las utilidades de colecciones y Spring AOP.
  • Facade simplifica un subsistema tras un único punto de entrada; Composite permite tratar los árboles como objetos individuales mediante una interfaz común.
  • Strategy, Command y Observer se reducen hoy en gran medida a interfaces funcionales, lambdas y listas de Consumer; mantén tipos con nombre donde el significado de dominio o la existencia de varios métodos lo justifiquen.
  • Template Method fija el orden de los pasos; su variante con callback (JdbcTemplate) evita la herencia.
  • Chain of Responsibility y State eliminan condicionales: handlers con una continuación proceed, y constantes de enum o records sellados que son dueños de sus transiciones.
  • Las interfaces selladas junto con el switch con pattern matching sustituyen a Visitor en las jerarquías que controlas, conservando la exhaustividad sin el código repetitivo.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Qué diferencia hay entre Decorator, Proxy y Adapter?
Los tres envuelven un objeto, pero por motivos distintos. Un Adapter cambia la interfaz para que una clase existente encaje donde se espera otro tipo. Un Decorator mantiene la misma interfaz y añade comportamiento, y los decoradores se apilan. Un Proxy también mantiene la interfaz, pero controla el acceso al objeto real — creándolo bajo demanda, comprobando permisos, añadiendo acceso remoto o caché — y normalmente el cliente ni siquiera sabe que está ahí.
¿Las lambdas dejan obsoleto el patrón Strategy?
Hacen que la jerarquía de clases sea opcional. Cuando una estrategia es una sola operación, una interfaz funcional con lambdas o referencias a métodos es el patrón Strategy con menos código. Mantén una interfaz y clases con nombre cuando la estrategia tenga varios métodos, necesite configuración o estado, o cuando el nombre aporte un significado de dominio que una Function a secas perdería.
¿Conviene usar el patrón Observer o un bus de eventos?
El Observer directo — un sujeto con una lista de listeners — es lo adecuado dentro de un mismo componente, y es lo que usan los toolkits de interfaz gráfica y PropertyChangeSupport. En cuanto varios componentes sin relación reaccionan a los mismos eventos, un mecanismo de eventos en proceso como el ApplicationEventPublisher de Spring desacopla a los publicadores de los suscriptores y te permite hacer los handlers asíncronos o transaccionales. Entre procesos, usa un broker de mensajes.
¿Sigue haciendo falta Visitor ahora que Java tiene pattern matching?
Para tus propias jerarquías cerradas, una interfaz sellada y un switch con record patterns ofrecen el mismo double dispatch, con comprobación de exhaustividad y mucho menos código repetitivo. Visitor sigue siendo útil cuando la jerarquía no está sellada, cuando vive en una librería que no puedes modificar o cuando quieres que el propio visitante sea un componente reutilizable y con estado que se pasa de un sitio a otro.