Bridge conectaba dos jerarquías; hoy la estructura crece hacia dentro. La carta de un restaurante en PideYa no es una lista plana: es un árbol. Secciones ("Pizzas", "Bebidas") que contienen subsecciones ("Bebidas > Refrescos", "Bebidas > Vinos") que contienen platos; y menús que agrupan platos —y a veces otros menús— con precio conjunto. Cada vez que el código pregunta "¿esto es un plato o una sección?" antes de operar, se escribe un if que se repetirá en veinte sitios. Composite elimina esa pregunta: hojas y grupos comparten interfaz, y el árbol entero se trata igual que un solo objeto.

Contenido

  1. El problema en PideYa: la carta es un árbol
  2. Intención y estructura del patrón
  3. Implementación Java completa
  4. Recorridos recursivos
  5. Menús: cuando el grupo también se vende
  6. Transparencia frente a seguridad
  7. Cuándo usarlo y cuándo no
  8. Errores comunes, ejercicios y conclusión

El problema en PideYa: la carta es un árbol

La carta de La Bella Napoli, uno de los restaurantes estrella de PideYa, tiene esta pinta:

Carta La Bella Napoli
├── Pizzas
│   ├── Clásicas
│   │   ├── Margarita ........ 8,50 €
│   │   └── Prosciutto ....... 9,90 €
│   └── Especiales
│       └── Tartufo .......... 13,50 €
├── Pastas
│   └── Carbonara ............ 10,50 €
└── Bebidas
    ├── Agua ................. 1,50 €
    └── Refrescos
        └── Cola ............. 2,20 €

Profundidad arbitraria: secciones dentro de secciones, sin límite pactado. El primer modelo del equipo usó dos clases sin relación y colecciones separadas:

// Primer intento: dos mundos separados. NO imitar.
public class Seccion {
    private String nombre;
    private List<Seccion> subsecciones;
    private List<Plato> platos;
}

Y todo el código que toca la carta acabó infestado del mismo doble bucle con pregunta:

// Para mostrar la carta... y otra copia para disponibilidad... y otra para buscar...
void mostrar(Seccion seccion, int nivel) {
    imprimir(seccion.getNombre(), nivel);
    for (Plato p : seccion.getPlatos()) {          // rama 1: hojas
        imprimir(p.getNombre() + " " + p.getPrecio(), nivel + 1);
    }
    for (Seccion s : seccion.getSubsecciones()) {  // rama 2: grupos
        mostrar(s, nivel + 1);
    }
}

Cada operación nueva (¿está disponible esta sección? ¿cuántos platos sin gluten hay? ¿qué mostrar si la cocina cierra la parrilla?) duplica la estructura de doble rama. Y cuando negocio pida que un menú contenga otros menús, las dos listas paralelas ya no darán más de sí. El síntoma, en limpio: una estructura parte-todo recursiva tratada con condicionales en lugar de con una interfaz común.

Intención y estructura del patrón

Intención (GoF): componer objetos en estructuras de árbol para representar jerarquías parte-todo. Composite permite a los clientes tratar de manera uniforme tanto los objetos individuales como las composiciones de objetos.

Las dos ideas del patrón, que conviene separar:

  1. Una interfaz común (Component) para hojas y grupos: las operaciones que tienen sentido en ambos (mostrar, estaDisponible...).
  2. El grupo contiene Components, no hojas ni grupos concretos: por eso la anidación es ilimitada gratis — una sección que contiene secciones que contienen platos es simplemente un Composite con hijos Component.
classDiagram
    class ComponenteCarta {
        <<interface>>
        +getNombre() String
        +estaDisponible() boolean
        +contarPlatosDisponibles() int
        +mostrar(nivel: int)
    }
    class Plato {
        -nombre: String
        -precio: BigDecimal
        -disponible: boolean
        +estaDisponible() boolean
        +contarPlatosDisponibles() int
        +mostrar(nivel: int)
    }
    class SeccionCarta {
        -nombre: String
        -hijos: List~ComponenteCarta~
        +agregar(hijo: ComponenteCarta)
        +quitar(hijo: ComponenteCarta)
        +estaDisponible() boolean
        +contarPlatosDisponibles() int
        +mostrar(nivel: int)
    }
    class AppCliente

    ComponenteCarta <|.. Plato
    ComponenteCarta <|.. SeccionCarta
    SeccionCarta *-- ComponenteCarta : hijos
    AppCliente --> ComponenteCarta : usa
Rol GoF En PideYa
Component (interfaz común) ComponenteCarta
Leaf (hoja, sin hijos) Plato
Composite (grupo, con hijos Component) SeccionCarta (y luego Menu)
Client La app, el buscador, el motor de disponibilidad

La flecha clave del diagrama es la composición SeccionCarta *-- ComponenteCarta — la misma relación de rombo relleno que aprendimos con Pedido *-- LineaPedido en la lección de UML, y aquí además recursiva: el composite contiene elementos de su propia interfaz. Esa flecha que vuelve es la firma visual del patrón.

Implementación Java completa

El Component — solo operaciones con sentido en hojas y en grupos:

public interface ComponenteCarta {
    String getNombre();
    boolean estaDisponible();
    int contarPlatosDisponibles();
    void mostrar(int nivel);
}

La hoja: un plato responde por sí mismo, sin recursión:

public class Plato implements ComponenteCarta {

    private final String nombre;
    private final BigDecimal precio;
    private boolean disponible = true;   // la cocina puede agotarlo

    public Plato(String nombre, BigDecimal precio) {
        this.nombre = nombre;
        this.precio = precio;
    }

    @Override public String getNombre() { return nombre; }
    public BigDecimal getPrecio() { return precio; }
    public void agotar() { this.disponible = false; }

    @Override
    public boolean estaDisponible() { return disponible; }

    @Override
    public int contarPlatosDisponibles() { return disponible ? 1 : 0; }

    @Override
    public void mostrar(int nivel) {
        String sangria = "  ".repeat(nivel);
        System.out.println(sangria + nombre + " ... " + precio + " €"
                + (disponible ? "" : " [AGOTADO]"));
    }
}

El composite: una sección delega en sus hijos sin saber si son platos o subsecciones:

public class SeccionCarta implements ComponenteCarta {

    private final String nombre;
    private final List<ComponenteCarta> hijos = new ArrayList<>();

    public SeccionCarta(String nombre) { this.nombre = nombre; }

    public SeccionCarta agregar(ComponenteCarta hijo) {
        hijos.add(hijo);
        return this;              // fluido, al estilo del Builder del módulo 2
    }

    public void quitar(ComponenteCarta hijo) { hijos.remove(hijo); }

    @Override public String getNombre() { return nombre; }

    /** Una sección está disponible si ALGÚN hijo lo está. */
    @Override
    public boolean estaDisponible() {
        return hijos.stream().anyMatch(ComponenteCarta::estaDisponible);
    }

    /** Suma lo que digan los hijos; la recursión se propaga sola. */
    @Override
    public int contarPlatosDisponibles() {
        return hijos.stream().mapToInt(ComponenteCarta::contarPlatosDisponibles).sum();
    }

    @Override
    public void mostrar(int nivel) {
        System.out.println("  ".repeat(nivel) + "== " + nombre + " ==");
        for (ComponenteCarta hijo : hijos) {
            hijo.mostrar(nivel + 1);
        }
    }
}

Montar el árbol y usarlo — el cliente jamás pregunta qué tiene delante:

SeccionCarta carta = new SeccionCarta("Carta La Bella Napoli");

SeccionCarta pizzas = new SeccionCarta("Pizzas");
pizzas.agregar(new SeccionCarta("Clásicas")
                    .agregar(new Plato("Margarita", new BigDecimal("8.50")))
                    .agregar(new Plato("Prosciutto", new BigDecimal("9.90"))))
      .agregar(new SeccionCarta("Especiales")
                    .agregar(new Plato("Tartufo", new BigDecimal("13.50"))));

carta.agregar(pizzas);
carta.agregar(new SeccionCarta("Pastas").agregar(new Plato("Carbonara", new BigDecimal("10.50"))));

// Tratamiento uniforme: da igual pasarle un plato, una sección o la carta entera.
carta.mostrar(0);
System.out.println("Platos disponibles: " + carta.contarPlatosDisponibles());

Compara con el doble bucle del primer intento: aquí no hay ni un solo if (esSeccion) en todo el sistema. La pregunta desapareció porque la interfaz la respondió de una vez para siempre: cada nodo sabe operarse a sí mismo, y los grupos saben delegar. Añadir una operación nueva sigue costando (un método más en el Component y en cada clase), pero añadir niveles o nodos al árbol cuesta cero código.

Recorridos recursivos

Toda operación sobre el árbol sigue el mismo molde, que conviene interiorizar porque lo escribirás muchas veces:

  • En la hoja: el caso base, respuesta directa (un plato agotado cuenta 0).
  • En el composite: combinar las respuestas de los hijos (anyMatch, sum, allMatch, concatenar...), llamando a la misma operación sobre cada uno. La recursión no se programa: emerge de la estructura.

El diagrama de secuencia de contarPlatosDisponibles() sobre una carta pequeña lo hace visible:

sequenceDiagram
    participant App
    participant Carta as carta: SeccionCarta
    participant Pizzas as pizzas: SeccionCarta
    participant Marg as margarita: Plato
    participant Agua as agua: Plato

    App->>Carta: contarPlatosDisponibles()
    Carta->>Pizzas: contarPlatosDisponibles()
    Pizzas->>Marg: contarPlatosDisponibles()
    Marg-->>Pizzas: 1
    Pizzas-->>Carta: 1
    Carta->>Agua: contarPlatosDisponibles()
    Agua-->>Carta: 1
    Carta-->>App: 2

La app hizo una llamada; la estructura hizo el resto. Cuando en el módulo 4 veamos Iterator y Visitor, volveremos sobre este árbol para recorrerlo de formas más sofisticadas (solo mención por ahora: Iterator externaliza el recorrido; Visitor externaliza las operaciones).

Menús: cuando el grupo también se vende

La carta agrupa para mostrar; los menús agrupan para vender: "Menú Mediodía" (primero + segundo + postre, 12,90 € en vez de la suma), y el "Menú Familiar" que contiene dos menús mediodía y una bebida grande. Es otro parte-todo recursivo —menús dentro de menús—, pero con una operación nueva: el precio, calculado recursivamente con descuento:

public class Menu implements ComponenteCarta {

    private final String nombre;
    private final BigDecimal descuento;              // p. ej. 0.15 = 15% sobre la suma
    private final List<ComponenteCarta> elementos = new ArrayList<>();

    public Menu(String nombre, BigDecimal descuento) {
        this.nombre = nombre;
        this.descuento = descuento;
    }

    public Menu agregar(ComponenteCarta elemento) { elementos.add(elemento); return this; }

    /** Precio recursivo: suma de los precios de los elementos, con descuento. */
    public BigDecimal getPrecio() {
        BigDecimal suma = elementos.stream()
                .map(Menu::precioDe)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        return suma.multiply(BigDecimal.ONE.subtract(descuento))
                   .setScale(2, RoundingMode.HALF_UP);
    }

    private static BigDecimal precioDe(ComponenteCarta c) {
        if (c instanceof Plato plato) return plato.getPrecio();
        if (c instanceof Menu menu)  return menu.getPrecio();   // recursión: menús anidados
        throw new IllegalStateException("Un menú no puede contener secciones: " + c.getNombre());
    }

    /** Un menú solo se puede pedir si TODOS sus elementos están disponibles. */
    @Override
    public boolean estaDisponible() {
        return elementos.stream().allMatch(ComponenteCarta::estaDisponible);
    }

    @Override public String getNombre() { return nombre; }
    @Override public int contarPlatosDisponibles() {
        return elementos.stream().mapToInt(ComponenteCarta::contarPlatosDisponibles).sum();
    }
    @Override public void mostrar(int nivel) {
        System.out.println("  ".repeat(nivel) + "★ " + nombre + " ... " + getPrecio() + " €");
        elementos.forEach(e -> e.mostrar(nivel + 1));
    }
}

Dos matices con miga:

  • Fíjate en el cambio de política de disponibilidad: la sección usa anyMatch (viva si queda algo que ofrecer) y el menú usa allMatch (si falta el postre, no hay menú). Mismo molde recursivo, semántica distinta por composite: el patrón da la estructura; el negocio, las reglas.
  • Ese instanceof en precioDe es la costura honesta del diseño: getPrecio() no está en ComponenteCarta porque una sección no tiene precio ("Pizzas" no cuesta nada: contiene cosas que cuestan). Podríamos haberlo subido a la interfaz y hacer que las secciones lanzaran una excepción... lo cual nos lleva de cabeza al debate clásico del patrón.

Transparencia frente a seguridad

¿Qué operaciones van en el Component? El GoF plantea el dilema con los métodos de gestión de hijos (agregar/quitar), y aplica igual a operaciones como getPrecio():

Enfoque Qué va en Component Ganas Pagas
Transparente Todo, incluido agregar/quitar Uniformidad total: el cliente nunca hace instanceof ni cast Las hojas tienen métodos absurdos (plato.agregar(...)) que deben lanzar UnsupportedOperationException: errores en ejecución
Seguro Solo lo común de verdad; agregar/quitar solo en Composite El compilador impide plato.agregar(...): errores en compilación El cliente que construye/modifica el árbol necesita conocer el tipo concreto (o hacer casts)

Nuestra implementación eligió el enfoque seguro (agregar vive en SeccionCarta y Menu, no en la interfaz; getPrecio vive en Plato y Menu), y es la recomendación general en Java moderno: los errores de compilación son más baratos que los de ejecución, y en la práctica quien construye el árbol casi siempre sabe qué está construyendo (se construye desde configuración, base de datos o un builder), mientras que quien lo recorre no necesita mutar nada. La transparencia total tiene su territorio: frameworks donde el cliente manipula árboles genéricos sin conocer los tipos (el DOM, sistemas de ficheros). Elige por quién sufre: ¿el que construye (seguro le da igual, transparente no le aporta) o el que recorre (le basta lo común)?

Cuándo usarlo y cuándo no

Úsalo cuando:

  • El dominio es un árbol parte-todo: cartas con secciones, menús anidados, categorías de restaurantes, zonas de reparto con subzonas, grupos de permisos... y quieres operar igual sobre el individuo y sobre el grupo.
  • Detectas el síntoma del doble tratamiento: el mismo if (esGrupo) ... else ... repitiéndose con cada operación nueva.
  • La profundidad de anidación es arbitraria o crecerá: las listas paralelas (List<Seccion> + List<Plato>) no escalan a eso.

No lo uses cuando:

  • La estructura es plana y va a seguir siéndolo: una lista de platos sin secciones no necesita Component/Leaf/Composite; una List<Plato> es más simple y más clara (KISS).
  • Hojas y grupos no comparten operaciones significativas: si la interfaz común queda vacía o llena de UnsupportedOperationException, el dominio te está diciendo que no hay tratamiento uniforme que capturar.
  • Necesitas restricciones fuertes sobre la forma del árbol (máximo dos niveles, tipos concretos por nivel): la uniformidad del Composite juega en contra; a veces Carta → List<Seccion> → List<Plato> tipado a mano es exactamente lo que quieres.

Relación con otros patrones (solo mención): Decorator es estructuralmente un Composite de un solo hijo y suele compartir interfaz Component con él — lo veremos en la próxima lección con los extras de los platos; Iterator y Visitor son los patrones de recorrido y operación externa sobre estas estructuras; los árboles se construyen cómodamente con Builder y se clonan como plantillas con Prototype — de hecho el RegistroPlantillasCarta del módulo 2 guarda exactamente árboles como este.

Errores Comunes y Consejos

  • Devolver la lista de hijos viva. Un getHijos() que devuelve la List interna permite a cualquiera mutar el árbol por fuera (carta.getHijos().clear()). Devuelve List.copyOf(hijos) — la misma disciplina de inmutabilidad que aplicamos en el Builder de Pedido.
  • Ciclos en el "árbol". Nada impide seccionA.agregar(seccionB) y seccionB.agregar(seccionA): la primera recursión se lo comerá como un StackOverflowError. Si el árbol lo montan datos externos (un panel de administración de restaurante), valida al agregar (rechazar si el nuevo hijo contiene al padre).
  • Referencia al padre "por si acaso". Añadir getPadre() en Component parece inocente y acopla cada nodo a su contexto, complica mover subárboles y duplica la verdad estructural. Añádela solo con un caso de uso real (navegación ascendente) y mantenla coherente en agregar/quitar.
  • Meter en Component lo que no es común. Cada método que solo tiene sentido en un tipo de nodo empuja hacia la transparencia con excepciones. Antes de subir un método a la interfaz, pregunta: ¿qué responde una hoja? ¿y un grupo? Si una de las dos respuestas es "ni idea", abajo se queda.
  • Recursión sin caso degenerado. Secciones vacías: estaDisponible() con anyMatch sobre lista vacía devuelve false (razonable), pero allMatch en Menu vacío devuelve true (¡menú de cero platos disponible!). Decide y testea los casos vacíos explícitamente.
  • Consejo: escribe los tests contra ComponenteCarta, no contra las clases concretas: un test que recibe cualquier subárbol y comprueba invariantes (contar ≥ 0, mostrar no lanza) documenta el tratamiento uniforme, que es la promesa del patrón.

Ejercicios

Ejercicio 1: platos aptos para celíacos

Añade la operación contarSinGluten() que devuelve cuántos platos sin gluten hay bajo cualquier nodo. Supón que Plato gana un atributo booleano sinGluten. Escribe el método en las tres clases (Plato, SeccionCarta, Menu) e indica cuál es el caso base y cuál el paso recursivo.

Ejercicio 2: el menú imposible

La cocina agota la Carbonara, que es el único "primero" del Menú Mediodía; el Menú Familiar contiene dos Menús Mediodía y una bebida disponible. Sin ejecutar código, razona qué devuelven menuMediodia.estaDisponible() y menuFamiliar.estaDisponible(), y por qué la propagación es automática.

Ejercicio 3: transparencia con consecuencias

Un compañero propone subir agregar(ComponenteCarta) a la interfaz ComponenteCarta "para poder montar árboles sin casts", haciendo que Plato.agregar lance UnsupportedOperationException. Escribe (a) un ejemplo de código que hoy no compila y con su propuesta compilaría y fallaría en ejecución, y (b) un contexto en el que su propuesta sería, aun así, la correcta.

Soluciones

Solución 1:

// En Plato: CASO BASE, respuesta directa
@Override
public int contarSinGluten() { return (disponible && sinGluten) ? 1 : 0; }

// En SeccionCarta: PASO RECURSIVO, combinar hijos
@Override
public int contarSinGluten() {
    return hijos.stream().mapToInt(ComponenteCarta::contarSinGluten).sum();
}

// En Menu: mismo paso recursivo sobre sus elementos
@Override
public int contarSinGluten() {
    return elementos.stream().mapToInt(ComponenteCarta::contarSinGluten).sum();
}

Y la firma se añade a ComponenteCarta (es genuinamente común: toda hoja y todo grupo saben responderla). El molde es siempre el mismo: hoja = caso base, composite = combinación de hijos.

Solución 2: menuMediodia.estaDisponible() → la Carbonara devuelve false, el allMatch del menú encuentra un elemento no disponible → false. menuFamiliar.estaDisponible() → su allMatch pregunta a cada elemento; los dos Menús Mediodía devuelven false (por la recursión anterior) → false, aunque la bebida esté disponible. Nadie escribió lógica de propagación "hacia arriba": cada nodo aplicó su regla local (allMatch) y la composición recursiva hizo emerger el resultado global. Un plato agotado en la hoja apaga menús a cualquier profundidad.

Solución 3: (a) Hoy ComponenteCarta margarita = new Plato(...); margarita.agregar(otraCosa); no compila (la interfaz no tiene agregar): el error se detecta gratis. Con la propuesta, compila y revienta en ejecución con UnsupportedOperationException — quizá en producción, quizá en un caso raro no testeado. (b) Sería razonable en un editor genérico de cartas (un panel de administración que manipula nodos ComponenteCarta arrastrando y soltando, sin conocer tipos concretos): ahí la uniformidad de manipulación vale más que la seguridad estática, que es exactamente el territorio de la variante transparente.

Conclusión

Composite convierte una jerarquía parte-todo en una estructura operable con una sola interfaz: hojas que responden directamente, grupos que combinan las respuestas de sus hijos, y clientes que hacen una llamada sin preguntar jamás qué tienen delante. En PideYa quedó instalado en la carta (ComponenteCarta, Plato, SeccionCarta) y en los menús anidados (Menu, con precio recursivo y su propia regla de disponibilidad), y quedó decidida —con criterio, no por inercia— la variante segura frente a la transparente.

Ahora mira el plato que acabamos de dejar en la carta: la Margarita. Un cliente la quiere con doble queso; otro, sin gluten; otro, en ración grande con doble queso. ¿Subclases MargaritaDobleQueso, MargaritaSinGlutenDobleQueso...? Eso es otra explosión combinatoria, y la respuesta no es heredar sino envolver: objetos que rodean al plato añadiéndole precio y descripción, capa a capa, como quien viste una cebolla al revés. Nos vemos en Decorator.

Curso de Patrones de Diseño de Software

Módulo 1: Introducción a los Patrones de Diseño

Módulo 2: Patrones Creacionales

Módulo 3: Patrones Estructurales

Módulo 4: Patrones de Comportamiento

Módulo 5: Aplicación de Patrones de Diseño

Módulo 6: Patrones de Diseño Avanzados

Módulo 7: Recursos Adicionales y Conclusión

© Copyright 2026. Todos los derechos reservados