El patrón Composite permite tratar de forma uniforme objetos individuales y colecciones jerárquicas como si fueran del mismo tipo. Se emplea cuando los dominios del problema se prestan a estructuras tipo árbol, por ejemplo archivos y carpetas, widgets visuales, nodos de expresiones o elementos de catálogos.
Su fuerza radica en eliminar condicionales que distinguen entre elementos simples y compuestos. En su lugar se define una interfaz común que abstrae las operaciones relevantes, delegando a cada participante la responsabilidad de ejecutarlas de la forma adecuada.
En dominios jerárquicos, el código cliente suele verse obligado a preguntar si cada nodo es una hoja o un contenedor para decidir cómo proceder. Esto genera estructuras condicionales complejas, dificulta la extensión y acopla al cliente con detalles internos de las colecciones.
Composite introduce una jerarquía de objetos donde todos comparten la misma interfaz de alto nivel. De esta manera, el cliente delega la operación en el nodo sin preocuparse por su naturaleza. Cada tipo decide si ejecuta la acción de manera directa (hojas) o la propaga a sus hijos (compuestos).
La intención es construir objetos complejos a partir de composiciones de objetos similares de forma que todos se manipulen a través de la misma interfaz. Se aplica cuando:
La motivación principal es evitar condicionales dispersos que comprueban tipos concretos, logrando polimorfismo real y delegación recursiva.
Los participantes esenciales son:
render()
, obtenerPrecio()
o calcularTamaño()
).El patrón suele incorporar operaciones adicionales para agregar, eliminar u obtener hijos. Dependiendo de la variante elegida (transparente o segura) esas operaciones se exponen en la interfaz Component o solo en Composite.
Consideremos un sistema de gestión de proyectos donde tareas simples y grupos de tareas se combinan y comparten operaciones como cálculo de horas restantes o marcación de estado. Una implementación base podría verse así:
import java.util.ArrayList;
import java.util.List;
public abstract class Tarea {
private final String nombre;
protected Tarea(String nombre) {
this.nombre = nombre;
}
public String getNombre() {
return nombre;
}
public abstract int horasEstimadas();
public abstract void marcarComoCompleta();
}
public class TareaSimple extends Tarea {
private int horasRestantes;
private boolean completa;
public TareaSimple(String nombre, int horasRestantes) {
super(nombre);
this.horasRestantes = horasRestantes;
}
@Override
public int horasEstimadas() {
return completa ? 0 : horasRestantes;
}
@Override
public void marcarComoCompleta() {
completa = true;
horasRestantes = 0;
System.out.println("Tarea simple completada: " + getNombre());
}
}
public class TareaCompuesta extends Tarea {
private final List<Tarea> subtareas = new ArrayList<>();
public TareaCompuesta(String nombre) {
super(nombre);
}
public void agregar(Tarea tarea) {
subtareas.add(tarea);
}
public void eliminar(Tarea tarea) {
subtareas.remove(tarea);
}
@Override
public int horasEstimadas() {
return subtareas.stream()
.mapToInt(Tarea::horasEstimadas)
.sum();
}
@Override
public void marcarComoCompleta() {
System.out.println("Marcando grupo como completo: " + getNombre());
subtareas.forEach(Tarea::marcarComoCompleta);
}
}
public class GestorProyecto {
public static void main(String[] args) {
TareaSimple investigar = new TareaSimple("Investigar APIs", 6);
TareaSimple prototipo = new TareaSimple("Programar prototipo", 12);
TareaCompuesta entregable = new TareaCompuesta("Preparar entregable");
entregable.agregar(investigar);
entregable.agregar(prototipo);
System.out.println("Horas estimadas: " + entregable.horasEstimadas());
entregable.marcarComoCompleta();
System.out.println("Horas pendientes: " + entregable.horasEstimadas());
}
}
El cliente gestiona referencias al tipo abstracto Tarea
, por lo que puede operar sobre tareas simples o grupos indistintamente. Observa que la recursividad surge de forma natural cuando los compuestos delegan en su colección.
Un menú de navegación compuesto por opciones y submenús es otro caso clásico. Podemos enriquecer el diseño agregando iteración y lógica de habilitación:
import java.util.ArrayList;
import java.util.List;
public abstract class EntradaMenu {
private final String etiqueta;
protected EntradaMenu(String etiqueta) {
this.etiqueta = etiqueta;
}
public String getEtiqueta() {
return etiqueta;
}
public abstract void ejecutar();
public void imprimir(int nivel) {
System.out.println(" ".repeat(nivel) + "- " + etiqueta);
}
}
public class AccionMenu extends EntradaMenu {
public AccionMenu(String etiqueta) {
super(etiqueta);
}
@Override
public void ejecutar() {
System.out.println("Ejecutando acción: " + getEtiqueta());
}
}
public class MenuCompuesto extends EntradaMenu {
private final List<EntradaMenu> hijos = new ArrayList<>();
public MenuCompuesto(String etiqueta) {
super(etiqueta);
}
public MenuCompuesto agregar(EntradaMenu entrada) {
hijos.add(entrada);
return this;
}
@Override
public void ejecutar() {
System.out.println("Abriendo menú: " + getEtiqueta());
hijos.forEach(EntradaMenu::ejecutar);
}
@Override
public void imprimir(int nivel) {
super.imprimir(nivel);
hijos.forEach(hijo -> hijo.imprimir(nivel + 1));
}
}
La clase MenuCompuesto
devuelve la referencia a sí misma en el método agregar
para facilitar la definición fluida de jerarquías. El cliente imprime y ejecuta el menú sin preguntar qué tipo de entrada está recorriendo.
Existen dos variantes reconocidas:
Otra decisión de diseño se relaciona con el almacenamiento de referencias al padre. Algunas implementaciones lo requieren para facilitar eliminaciones o navegación inversa, pero esto puede aumentar el acoplamiento.
El principal riesgo es violar el principio de responsabilidad única al incluir lógica compleja en los compuestos. Se recomienda que el Composite coordine, pero delegue el trabajo especializado en objetos colaborativos.
Otro problema frecuente es tratar de imponer una colección fija de tipos. Si se experimenta con lógica condicional para cada nuevo hijo, el patrón pierde su ventaja. Además, conviene evitar dependencias cíclicas: los nodos no deberían tener referencias cruzadas que formen grafos generales, porque el patrón asume una estructura arborescente.
Composite se complementa con Decorator cuando se desea agregar responsabilidades a nodos individuales manteniendo la jerarquía. Si el objetivo principal es traducir interfaces incompatibles, Adapter puede resultar más apropiado. Para algoritmos que recorren estructuras complejas sin cambiar clases existentes, Visitor ofrece una abstracción más potente, aunque incrementa el acoplamiento.
Antes de aplicarlo, verifica que la jerarquía sea estable y que los clientes realmente necesiten manipular colecciones y elementos individuales por igual. De lo contrario, podría bastar con una colección convencional o con objetos de alto nivel sin recursividad.