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í:
using System;
using System.Collections.Generic;
public abstract class Tarea
{
protected Tarea(string nombre)
{
Nombre = nombre;
}
public string Nombre { get; }
public abstract int HorasEstimadas();
public abstract void MarcarComoCompleta();
}
public class TareaSimple : Tarea
{
private int _horasRestantes;
private bool _completa;
public TareaSimple(string nombre, int horasRestantes) : base(nombre)
{
_horasRestantes = horasRestantes;
}
public override int HorasEstimadas()
{
return _completa ? 0 : _horasRestantes;
}
public override void MarcarComoCompleta()
{
_completa = true;
_horasRestantes = 0;
Console.WriteLine($"Tarea simple completada: {Nombre}");
}
}
public class TareaCompuesta : Tarea
{
private readonly List<Tarea> _subtareas = new List<Tarea>();
public TareaCompuesta(string nombre) : base(nombre)
{
}
public void Agregar(Tarea tarea)
{
_subtareas.Add(tarea);
}
public void Eliminar(Tarea tarea)
{
_subtareas.Remove(tarea);
}
public override int HorasEstimadas()
{
var total = 0;
foreach (var tarea in _subtareas)
{
total += tarea.HorasEstimadas();
}
return total;
}
public override void MarcarComoCompleta()
{
Console.WriteLine($"Marcando grupo como completo: {Nombre}");
foreach (var tarea in _subtareas)
{
tarea.MarcarComoCompleta();
}
}
}
public static class GestorProyecto
{
public static void Main()
{
var investigar = new TareaSimple("Investigar APIs", 6);
var prototipo = new TareaSimple("Programar prototipo", 12);
var entregable = new TareaCompuesta("Preparar entregable");
entregable.Agregar(investigar);
entregable.Agregar(prototipo);
Console.WriteLine($"Horas estimadas: {entregable.HorasEstimadas()}");
entregable.MarcarComoCompleta();
Console.WriteLine($"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:
using System;
using System.Collections.Generic;
public abstract class EntradaMenu
{
protected EntradaMenu(string etiqueta)
{
Etiqueta = etiqueta;
}
public string Etiqueta { get; }
public abstract void Ejecutar();
public virtual void Imprimir(int nivel)
{
Console.WriteLine(new string(' ', nivel * 2) + "- " + Etiqueta);
}
}
public class AccionMenu : EntradaMenu
{
private readonly Action _accion;
public AccionMenu(string etiqueta, Action accion) : base(etiqueta)
{
_accion = accion;
}
public override void Ejecutar()
{
Console.WriteLine($"Ejecutando accion: {Etiqueta}");
_accion();
}
}
public class MenuCompuesto : EntradaMenu
{
private readonly List<EntradaMenu> _hijos = new List<EntradaMenu>();
public MenuCompuesto(string etiqueta) : base(etiqueta)
{
}
public MenuCompuesto Agregar(EntradaMenu entrada)
{
_hijos.Add(entrada);
return this;
}
public override void Ejecutar()
{
Console.WriteLine($"Abriendo menu: {Etiqueta}");
foreach (var hijo in _hijos)
{
hijo.Ejecutar();
}
}
public override void Imprimir(int nivel)
{
base.Imprimir(nivel);
foreach (var hijo in _hijos)
{
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.