Módulo 5: Patrones para estructurar y adaptar

6. Composite: tratar el todo y la parte igual

Descripción

Al terminar esta lección vas a saber reconocer una estructura en árbol cuando la tengas delante y a escribir el patrón que la trata con elegancia: Composite, donde un grupo de cosas se usa exactamente igual que una cosa sola. Vas a ver el caso legítimo de Boletia —los descuentos, que pueden ser uno solo o una combinación anidada— y vas a salir con la propiedad que define el patrón: el compuesto ES un componente, así que quien lo usa nunca pregunta qué le tocó.

Y vas a salir, sobre todo, con la otra mitad: cuándo no usarlo. Composite es el patrón más elegante de este módulo y el que más se fuerza donde no corresponde, porque una vez que lo aprendes empiezas a ver árboles en cualquier lista. Media lección va a estar dedicada a los tres problemas honestos que trae —el orden de aplicación, la asimetría entre hoja y rama, y la profundidad— y a la señal de que lo estás poniendo donde no hay árbol.

Esto importa porque es el patrón donde más se nota la diferencia entre saber la forma y tener criterio. Las otras tres familias del módulo resuelven un problema que reconoces por el dolor: el SDK que se filtra, los diez pasos copiados, el reintento que hay que repetir cuatro veces. Composite no duele hasta que lo necesitas de verdad, y para entonces ya lo aplicaste tres veces donde no hacía falta.

Conexión con el módulo: los tres patrones anteriores resolvían el contacto con lo que no controlas —Adapter traduce, Facade simplifica, Decorator agrega—. Este es el raro de la familia: no resuelve un contacto con nada externo, sino un problema de forma de los datos. Está aquí porque comparte la mecánica —un objeto que contiene otros— y porque, honestamente, es donde más falta hace el freno. La lección 7 es ese freno aplicado a todo el módulo: las tres preguntas que deciden si hacía falta una clase o bastaba una función. Y la lección 8 —el proyecto— te pide aislar la integración con el proveedor de pago eligiendo el patrón mínimo que sirva, que es exactamente el criterio que esta lección empieza a instalar.

Carpetas y archivos

Abre el explorador de archivos de tu computadora y mira lo que puedes hacer con un archivo: arrastrarlo, copiarlo, borrarlo, renombrarlo, preguntar cuánto pesa, ponerlo dentro de una carpeta.

Ahora mira lo que puedes hacer con una carpeta: exactamente lo mismo. La arrastras, la copias, la borras, la renombras, preguntas cuánto pesa, la metes dentro de otra carpeta.

Eso es todo el patrón, y conviene detenerse en lo raro que es. Una carpeta y un archivo son cosas profundamente distintas: uno tiene contenido, la otra tiene hijos. Y sin embargo el sistema operativo te dejó usarlos igual, y por eso nunca en tu vida tuviste que preguntarte "¿esto que voy a arrastrar es un archivo o una carpeta?". Simplemente lo arrastras.

Fíjate en tres consecuencias.

La primera: "cuánto pesa" tiene la misma respuesta en los dos casos, pero se calcula distinto. Un archivo sabe su tamaño directo. Una carpeta le pregunta a cada hijo y suma. Y si un hijo es otra carpeta, esa hace lo mismo. La recursión está adentro y quien pregunta no la ve.

La segunda: la anidación no tiene un límite artificial. Una carpeta dentro de una carpeta dentro de otra funciona igual. Nadie tuvo que programar el caso de tres niveles: sale gratis de que una carpeta pueda contener carpetas.

Y la tercera, que es la advertencia del patrón: no todo es igual entre los dos. A una carpeta le puedes agregar hijos; a un archivo, no. Ahí la simetría se rompe, y esa grieta es el problema real de este patrón —el que vamos a discutir en serio—.

Un último detalle de la analogía, para calibrar cuándo vale la pena. El sistema de archivos es un árbol de verdad: carpetas dentro de carpetas, sin profundidad fija, con miles de nodos. Si tu computadora solo pudiera tener una carpeta con archivos adentro y nada más, todo esto sería un lujo innecesario: bastaría una lista. Composite se paga con la profundidad real de tu árbol.

Qué es un Composite, en una frase

Un Composite es un objeto que contiene varios objetos del mismo tipo que él y se comporta como uno solo.

La propiedad que lo define, dicha con precisión: el compuesto implementa la misma interfaz que sus hijos. Un grupo de descuentos es un descuento. Una carpeta es un elemento del sistema de archivos. Por eso quien lo usa nunca pregunta qué le tocó.

Suena parecido al Decorator y conviene separarlos ahora, porque en el diagrama se ven casi igual:

DecoratorComposite
Cuántos contieneUnoVarios
Para quéAgregar comportamiento alrededorTratar un grupo como una unidad
Qué pasa si lo quitasEl resultado del negocio no cambiaPierdes hijos: cambia todo
Cómo se usaSe apila en el punto de construcciónSe arma como un árbol, a menudo desde datos

La regla mecánica: si contiene uno, es Decorator; si contiene varios, es Composite. Y hay un caso donde se tocan de verdad —un Composite con un solo hijo se comporta como un Decorator que no agrega nada—, lo cual es una curiosidad, no un problema.

La anatomía tiene tres piezas, y por primera vez en el módulo son tres y no cuatro:

1. El componente. El contrato común, que cumplen tanto las hojas como las ramas. En Boletia es Discount, con un método que dice cuánto descuenta. Este contrato tiene que ser pequeño: cuantos más métodos tenga, más difícil será que una rama los cumpla todos con sentido.

2. La hoja. El caso simple, el que no tiene hijos: PercentageOff, FixedAmountOff. Hace el trabajo real.

3. El compuesto. El que tiene hijos y cumple el mismo contrato preguntándoles a ellos: AllOf, BestOf. Su implementación es casi siempre un recorrido de los hijos y una forma de combinar sus respuestas.

Ejemplo trabajado: los descuentos de Boletia

Aquí está el caso, y viene de una necesidad real del negocio.

Boletia arrancó con descuentos simples: un porcentaje por código promocional. Después el equipo comercial empezó a pedir cosas:

  • "Diez por ciento a estudiantes." Fácil.
  • "Doscientos pesos menos si compras cuatro boletos o más." Fácil.
  • "Las dos cosas a la vez, si el cliente califica para ambas." Ya no es una regla, es una combinación.
  • "El código de prensa da veinticinco por ciento, pero no se acumula con nada: el cliente recibe lo mejor de los dos mundos, no los dos." Ahora hay una combinación de otro tipo.
  • "En la preventa del Festival Cumbre: lo mejor entre (estudiante + compra grande, acumulables) y (código de prensa)." Y ahora hay una combinación dentro de otra.

Ese último pedido es la señal. En el momento en que una combinación puede contener otra combinación, tienes un árbol, y tratar de resolverlo con una lista plana produce código que crece con cada campaña nueva.

Cómo se ve el intento sin el patrón. Este es el código que estaba en pricing/discounts.py:

# Archivo: pricing/discounts.py  — ANTES

def apply(order, subtotal):
    """Aplica los descuentos que correspondan. Crece con cada campaña."""
    campaign = campaigns.for_event(order.event_id)

    if campaign.kind == "student_percentage":
        return subtotal * Decimal("0.10")

    if campaign.kind == "bulk_fixed":
        return Decimal("200") if len(order.ticket_ids) >= 4 else Decimal("0")

    if campaign.kind == "student_and_bulk":
        total = subtotal * Decimal("0.10")
        if len(order.ticket_ids) >= 4:
            total += Decimal("200")
        return total

    if campaign.kind == "best_of_student_bulk_or_press":
        stacked = subtotal * Decimal("0.10")
        if len(order.ticket_ids) >= 4:
            stacked += Decimal("200")
        press = subtotal * Decimal("0.25") if order.has_press_code else Decimal("0")
        return max(stacked, press)

    return Decimal("0")

Mira la cuarta rama. La lógica de "estudiante" está escrita tres veces, la de "compra grande" tres veces, y cada combinación nueva que pida el equipo comercial va a copiarlas otra vez. En la quinta campaña alguien va a cambiar el porcentaje de estudiante en una rama y no en las otras dos, y durante un mes el mismo descuento va a valer distinto según la campaña. Este es el mismo error que ya viste tres veces en el módulo: la misma decisión escrita varias veces.

Y hay un problema peor, que es el que hace que este caso pida específicamente un Composite y no una Strategy: el equipo comercial va a seguir inventando combinaciones, y cada una es una forma nueva de armar las mismas piezas. No hacen falta comportamientos nuevos; hacen falta combinaciones nuevas. Eso es un árbol.

Paso 1 — Define el contrato, y que sea pequeño.

# Archivo: pricing/discount.py

from decimal import Decimal
from typing import Protocol


class Discount(Protocol):
    """Un descuento sabe cuánto descuenta y sabe explicarse.

    Dos métodos, no más. Un contrato pequeño es lo que permite que un GRUPO
    de descuentos pueda cumplirlo igual que uno solo: cada método que agregues
    es un método que las ramas van a tener que inventarse cómo cumplir.
    """

    def amount_for(self, order, subtotal: Decimal) -> Decimal:
        """Cuánto se descuenta. Siempre positivo o cero, nunca negativo."""
        ...

    def describe(self) -> str:
        """Texto para mostrarle al cliente por qué le descontamos."""
        ...

Fíjate en la decisión de diseño: amount_for devuelve cuánto se descuenta, no el precio final. Si devolviera el precio final, componer sería un lío —¿el grupo aplica el segundo descuento sobre el precio ya rebajado?— y las reglas quedarían acopladas entre sí. Devolviendo un monto, cada descuento es independiente y el grupo decide cómo combinar. La forma del contrato decide si el Composite es fácil o imposible.

Paso 2 — Las hojas.

# Archivo: pricing/rules.py  — las hojas

@dataclass(frozen=True)
class PercentageOff:
    """Un porcentaje del subtotal, si el cliente cumple la condición."""
    percent: Decimal            # 0.10 = 10%
    label: str
    applies_to: Callable[[object], bool] = lambda order: True

    def amount_for(self, order, subtotal: Decimal) -> Decimal:
        if not self.applies_to(order):
            return Decimal("0")
        # Redondeo a dos decimales aquí y no al final: el cliente ve este
        # monto en el desglose, y tiene que cuadrar con lo que se le cobra.
        return (subtotal * self.percent).quantize(Decimal("0.01"), ROUND_HALF_UP)

    def describe(self) -> str:
        return f"{self.label} ({self.percent:.0%})"


@dataclass(frozen=True)
class FixedAmountOff:
    """Un monto fijo, si el cliente cumple la condición."""
    amount: Decimal
    label: str
    applies_to: Callable[[object], bool] = lambda order: True

    def amount_for(self, order, subtotal: Decimal) -> Decimal:
        if not self.applies_to(order):
            return Decimal("0")
        # Nunca descontamos más que el subtotal: un total negativo significaría
        # devolverle dinero a alguien por comprar.
        return min(self.amount, subtotal)

    def describe(self) -> str:
        return f"{self.label} (${self.amount})"

Paso 3 — Los compuestos. Aquí está el patrón:

# Archivo: pricing/combinations.py  — los compuestos

@dataclass(frozen=True)
class AllOf:
    """Todos los descuentos que apliquen, sumados. Es un Discount más.

    Todos se calculan sobre el MISMO subtotal, no en cadena. Ver la nota
    sobre el orden más abajo: es la decisión más importante de este archivo.
    """
    children: tuple[Discount, ...]
    label: str = "Descuentos acumulados"

    def amount_for(self, order, subtotal: Decimal) -> Decimal:
        total = sum((child.amount_for(order, subtotal) for child in self.children),
                    Decimal("0"))
        return min(total, subtotal)      # el tope también aplica al grupo

    def describe(self) -> str:
        applied = [c.describe() for c in self.children]
        return f"{self.label}: " + " + ".join(applied)


@dataclass(frozen=True)
class BestOf:
    """El descuento más conveniente para el cliente. También es un Discount.

    Se usa cuando las promociones NO se acumulan y el negocio decidió que el
    cliente reciba la mejor.
    """
    children: tuple[Discount, ...]
    label: str = "Mejor promoción disponible"

    def amount_for(self, order, subtotal: Decimal) -> Decimal:
        if not self.children:
            return Decimal("0")
        return max(child.amount_for(order, subtotal) for child in self.children)

    def describe(self) -> str:
        return f"{self.label} entre: " + " | ".join(c.describe() for c in self.children)

Detente en una línea de cada uno, porque ahí está la esencia del patrón. AllOf.amount_for no sabe qué son sus hijos. Pueden ser dos porcentajes, o un porcentaje y otro BestOf con cuatro hijos adentro. Le pregunta a cada uno y suma. Esa ignorancia es exactamente lo que hace que la anidación salga gratis.

Paso 4 — Arma el árbol de la campaña.

# La campaña del Festival Cumbre, tal como la pidió el equipo comercial.

is_student = lambda order: order.customer.is_student
is_bulk = lambda order: len(order.ticket_ids) >= 4
has_press = lambda order: order.has_press_code

cumbre_preventa = BestOf((
    AllOf((
        PercentageOff(Decimal("0.10"), "Estudiante", applies_to=is_student),
        FixedAmountOff(Decimal("200"), "Compra de 4 o más", applies_to=is_bulk),
    )),
    PercentageOff(Decimal("0.25"), "Código de prensa", applies_to=has_press),
))

Y el calculador de precios, que antes tenía la cascada de if, ahora tiene esto:

# Archivo: pricing/calculator.py  — DESPUÉS

def total_for(order) -> Decimal:
    subtotal = sum(price_of(t) for t in order.tickets)
    discount = campaigns.discount_for(order.event_id)   # un Discount, cualquiera
    return subtotal - discount.amount_for(order, subtotal)

Dos líneas, y no preguntan si el descuento es uno o son cinco anidados. Esa es la ganancia del patrón, dicha con precisión.

Qué esperar de este refactor. Tres cosas, y la tercera es la que casi nunca se menciona.

Lo primero: agregar una campaña ya no es tocar código de descuentos. La campaña del Festival Cumbre es una expresión de datos armada con piezas que ya existen. La siguiente —"lo mejor entre el código de prensa y (estudiante + preventa + compra grande)"— tampoco necesita clases nuevas: es otro árbol con las mismas piezas. Las clases dejaron de crecer con las campañas.

Lo segundo: desapareció la duplicación. El diez por ciento de estudiante existe en un solo lugar. Si mañana pasa a doce, cambia una vez.

Y lo tercero, que es el beneficio escondido de este patrón: el árbol se puede recorrer para otras cosas además de calcular. Mira describe(): se compone igual que amount_for, así que sale gratis un desglose legible para el cliente. Con la cascada de if eso era imposible sin escribir un segundo if paralelo —y esos dos if se desincronizarían—. Cuando tienes un árbol de objetos, puedes recorrerlo para calcular, para explicar, para validar una campaña antes de publicarla, o para mostrarle al equipo comercial un resumen de qué promociones están activas. La estructura sirve para más de una pregunta:

# Un desglose para el cliente, gratis, del mismo árbol:
def breakdown(discount, order, subtotal) -> list[tuple[str, Decimal]]:
    """Qué descuentos se aplicaron y por cuánto. Solo los que dieron algo."""
    if isinstance(discount, (AllOf, BestOf)):
        return [line for child in discount.children
                for line in breakdown(child, order, subtotal)]
    amount = discount.amount_for(order, subtotal)
    return [(discount.describe(), amount)] if amount > 0 else []

Ese isinstance merece una nota, porque contradice lo que veníamos diciendo. Es aceptable aquí por una razón concreta: esta función vive fuera del árbol y su trabajo es justamente distinguir hojas de ramas. Si el isinstance apareciera dentro de amount_for, sería un error grave —significaría que el contrato no alcanza—. La regla: el isinstance sobre un Composite huele mal en el código que lo usa y es normal en el código que lo recorre.

Los tres problemas honestos del patrón

Ahora la parte que los materiales sobre Composite suelen saltarse.

Problema 1: el orden y la no conmutatividad

AllOf suma los descuentos calculados todos sobre el mismo subtotal. Esa fue una decisión, y la decisión contraria —aplicarlos en cadena, cada uno sobre el resultado del anterior— da un número distinto.

Con un subtotal de 1000 pesos, un 10% y 200 pesos fijos:

  • Sobre el mismo subtotal: 100 + 200 = 300 de descuento. Total: 700.
  • En cadena, porcentaje primero: 1000 − 100 = 900; 900 − 200 = 700. Total: 700. Igual.
  • En cadena, monto fijo primero: 1000 − 200 = 800; 800 − 10% = 720. Total: 720. Distinto.

Veinte pesos de diferencia por el orden. Con cien mil operaciones al año, eso es dinero real y es el tipo de cosa que descubre el área de finanzas seis meses después.

La lección no es "elige la opción A": es que esa decisión pertenece al negocio, no al programador, y tiene que estar escrita donde se vea. En el código de arriba está resuelta —todos sobre el mismo subtotal— y comentada en el docstring de AllOf. Lo que no es aceptable es que sea un accidente de cómo alguien escribió el bucle.

Y hay un corolario para el diseño: si tu combinación depende del orden, el Composite se vuelve frágil, porque la estructura de árbol no comunica el orden de forma evidente. Si el negocio de verdad necesita cadenas ordenadas, conviene que la clase se llame InSequence y no AllOf, para que el nombre grite lo que hace.

Problema 2: la asimetría entre hoja y rama

A una carpeta le agregas hijos; a un archivo, no. En software, esa grieta obliga a una decisión que el catálogo original discute y casi nadie menciona.

Opción A — transparencia: pones add(child) y remove(child) en el contrato común, para que hojas y ramas sean idénticas de verdad. El costo: las hojas tienen que implementar métodos que no tienen sentido, normalmente lanzando un error.

# ⚠️ Transparencia: la hoja tiene métodos que no puede cumplir.
class PercentageOff:
    def add(self, child):
        raise TypeError("Un descuento simple no tiene hijos")

Ese raise es un problema real: significa que el contrato promete algo que no todas las implementaciones cumplen, y quien lo use tiene que saber cuáles sí. Es una violación del principio de sustitución.

Opción B — seguridad: el contrato común solo tiene lo que ambos pueden cumplir —amount_for y describe— y solo los compuestos tienen la forma de recibir hijos, en su constructor. El costo: quien quiera manipular el árbol tiene que saber qué tipo tiene delante.

En Python la opción B es claramente mejor, y es la que usamos: los compuestos reciben sus hijos en el constructor y son inmutables (frozen=True, tuple en vez de list). Un árbol de descuentos se arma completo y no se modifica después, lo cual además elimina una categoría entera de errores: nadie va a agregarle un hijo a una campaña en producción a mitad de un cálculo.

La regla general: mete en el contrato común solo lo que hoja y rama pueden cumplir con sentido. Si te descubres poniendo métodos que una de las dos tiene que rechazar, el contrato es demasiado grande.

Problema 3: la profundidad

Un árbol de dos niveles es legible. Uno de cinco, no.

# ⚠️ Esto es sintácticamente válido y humanamente ilegible.
BestOf((
    AllOf((
        BestOf((PercentageOff(...), AllOf((FixedAmountOff(...), PercentageOff(...))))),
        FixedAmountOff(...),
    )),
    BestOf((AllOf((PercentageOff(...), PercentageOff(...))), FixedAmountOff(...))),
))

Nadie puede decir cuánto descuenta eso sin ejecutarlo, y si el número sale mal, nadie puede decir por qué. El patrón no pone ningún límite —esa es su gracia y su peligro—, así que el límite hay que ponerlo a mano.

Dos formas prácticas. La primera: una validación al armar la campaña, que rechace árboles más profundos de lo que el negocio necesita.

def depth_of(discount) -> int:
    if not isinstance(discount, (AllOf, BestOf)):
        return 1
    return 1 + max(depth_of(c) for c in discount.children)


def validate_campaign(discount) -> None:
    """Las campañas de Boletia no necesitan más de tres niveles. Un árbol más
    profundo casi siempre es un error de armado, no una campaña real."""
    if depth_of(discount) > 3:
        raise ValueError("La campaña es demasiado compleja para explicársela a un cliente")

La segunda, más de fondo: si las campañas se arman desde el panel de administración, quien las arma no escribe código sino que llena un formulario, y ese formulario es el límite. Cuando un Composite se alimenta de datos —un JSON de configuración, una tabla— la profundidad se controla en la interfaz que los produce, y eso suele ser mejor que validarla después.

Hay una regla que resume el problema y que conviene tener a mano: si no puedes explicarle el árbol a un cliente en una frase, el cliente tampoco va a entender su factura. Un descuento que nadie puede explicar es un problema de negocio antes que de código.

Cuándo NO usar Composite

Y ahora la parte que hace falta más que todo lo anterior.

Cuando no hay anidación. Este es el caso más común con diferencia. Si tus descuentos son siempre una lista plana —"aplica todos los que correspondan"— no tienes un árbol: tienes una lista. Y una lista se resuelve con una lista:

# Sin patrón. Y está bien.
def total_discount(discounts, order, subtotal):
    return min(sum(d.amount_for(order, subtotal) for d in discounts), subtotal)

Tres líneas, cero clases nuevas, y cualquiera lo entiende. La pregunta que decide es literal: ¿un grupo puede contener otro grupo? Si la respuesta es no —y hoy, no dentro de dos años— no hay árbol y Composite es peso muerto.

Cuando el grupo se comporta distinto y quien lo usa necesita saberlo. El patrón se paga con que quien lo usa nunca pregunte qué le tocó. Si en tu caso hay que preguntar —porque un grupo necesita parámetros extra, o porque su resultado significa otra cosa— la abstracción está mintiendo y vas a terminar con isinstance en el código que la usa. Eso es peor que no haberla puesto.

Cuando el árbol tiene exactamente dos niveles y siempre los mismos. Un evento tiene boletos, y los boletos no tienen boletos. Eso no es un árbol, es una relación de uno a muchos, y forzar un Composite ahí produce clases donde bastaba una lista. La señal: si tu "compuesto" nunca contiene otro compuesto, no es un compuesto.

Cuando lo que varía es un valor y no una estructura. Si todas tus "combinaciones" son en realidad la misma operación con un número distinto, lo que necesitas es una tabla de configuración, no una jerarquía de objetos. Es el mismo error que viste en el módulo 4 con los impuestos: convertir en clases lo que era un dato.

Un cierre honesto sobre el caso de Boletia. Los descuentos se ganan el patrón, y por una razón concreta que conviene poder decir en voz alta: el equipo comercial pide combinaciones nuevas cada trimestre, esas combinaciones anidan de verdad, y la alternativa —la cascada de if— ya estaba duplicando la lógica de cada regla en cada campaña. Si Boletia tuviera un solo descuento por evento y ninguna combinación, este archivo entero sería un error. El patrón no lo justifica su elegancia, lo justifica el árbol.

Errores comunes

Ver árboles donde hay listas (de criterio). Qué pasa: alguien aprende el patrón y lo aplica a la primera colección que encuentra. Los boletos de una orden se vuelven un TicketComposite; los canales de notificación, un ChannelGroup; los pasos de un formulario, un árbol. Ninguno de esos anida: una orden tiene boletos y los boletos no tienen boletos. El resultado es una jerarquía de clases donde bastaba una lista, con el costo de siempre —más archivos, más indirección, un salto más para entender qué pasa— y ninguna ganancia. Por qué pasa: la forma del patrón encaja con cualquier colección, así que la señal falsa es constante. Y una vez que aprendes el patrón, verlo se siente como criterio. Cómo detectarlo: la pregunta de un segundo es "¿un grupo puede contener otro grupo?". Si nunca ocurre en tu dominio, no hay árbol. Un chequeo complementario: mira tu código y busca si algún compuesto contiene a otro compuesto en la práctica. Si en todo el sistema la profundidad máxima es dos, tienes una lista con clases. Cómo corregirlo: reemplaza el compuesto por una lista y una función que la recorra. Vas a borrar dos clases y nadie va a notar la falta.

Poner en el contrato común métodos que la rama no puede cumplir (de implementación). Qué pasa: el contrato empieza con amount_for y describe, que ambos cumplen. Después alguien agrega percent —porque una pantalla quiere mostrar el porcentaje— y entonces AllOf tiene que inventarse qué devolver: ¿la suma de los porcentajes de sus hijos? ¿El primero? ¿None? Cualquier respuesta es mentira. Y a partir de ahí, quien use el descuento tiene que saber si le tocó una hoja o una rama, que es exactamente lo que el patrón venía a evitar. Por qué pasa: el contrato crece por pedidos concretos de la interfaz de usuario, que casi siempre piensan en el caso simple. Cómo detectarlo: para cada método del contrato, pregúntate qué devuelve un grupo de cinco descuentos anidados. Si la respuesta es "depende" o "no tiene sentido", ese método no pertenece al contrato. Cómo corregirlo: sácalo. Si la pantalla necesita el porcentaje, que lo obtenga recorriendo el árbol con una función externa —como breakdown— que sí puede distinguir hojas de ramas. El contrato común es la intersección de lo que hoja y rama pueden hacer, no la unión.

Dejar el árbol mutable (de implementación). Qué pasa: los compuestos se escriben con una list y un método add, porque así se ven en casi todos los ejemplos del catálogo. Meses después, alguien construye una campaña, la guarda en una caché en memoria para no reconstruirla en cada compra, y en algún punto del código otro add le agrega un hijo. A partir de ese momento todas las compras de ese evento reciben el descuento extra, y el error solo aparece después de un reinicio, cuando desaparece. Por qué pasa: los ejemplos clásicos del patrón vienen de interfaces gráficas, donde el árbol sí cambia —agregas un botón a un panel— y esa mutabilidad se copia a dominios donde no hace falta. Cómo detectarlo: si tus compuestos usan list y tienen add/remove, pregúntate si el árbol de verdad cambia después de construido. En reglas de negocio, casi nunca. Cómo corregirlo: frozen=True y tuple. Un árbol inmutable se puede cachear, comparar y compartir entre hilos sin pensarlo, y elimina una categoría entera de errores difíciles de reproducir.

Ejercicios

Ejercicio 1 — ¿Hay árbol? Para cada caso, di si corresponde un Composite y justifica con la pregunta de la lección: ¿un grupo puede contener otro grupo?

(a) Un evento de Boletia tiene varios tipos de boleto —general, VIP, cortesía— y cada tipo tiene un precio base y un cupo. (b) Los permisos del panel de administración: un usuario tiene roles, un rol puede incluir otros roles —"gerente" incluye "operador de taquilla" y "consulta de reportes"— y cada rol otorga permisos sueltos. (c) Un reporte de ventas muestra el total del evento, desglosado por día, y cada día desglosado por tipo de boleto. (d) La validación de una orden: hay que verificar que tenga boletos, que el evento no haya empezado, que el cliente no exceda el límite por persona y que los boletos sigan disponibles.

Ver solución

(a) No. Un tipo de boleto no contiene tipos de boleto. Es una relación de uno a muchos: un evento tiene una lista de tipos. Es el caso literal del primer error común.

(b) Sí, y es el ejemplo de libro después de las carpetas. Un rol puede contener roles, esos roles pueden contener otros, y "¿este usuario puede hacer X?" se responde igual preguntándole a un permiso suelto o a un rol con cinco niveles adentro. La anidación es real y viene del dominio, no del código. Ojo con lo que aparece gratis y hay que manejar: los ciclos —si "gerente" incluye "supervisor" y alguien hace que "supervisor" incluya "gerente", el recorrido no termina—. Un Composite sobre datos que edita un humano necesita validación contra ciclos.

(c) No, aunque parezca. Sí hay una estructura jerárquica en la presentación —total, días, tipos—, pero es una jerarquía de profundidad fija y siempre la misma: nunca un día contiene otro día. Eso se resuelve con agrupaciones y sumas, no con un árbol de objetos. La señal es la profundidad fija: un Composite se paga cuando la profundidad es variable.

(d) No: eso es una lista de validadores. Cada verificación es independiente y ninguna contiene a otra. Una lista y un bucle alcanzan. Ahora bien, hay una versión de este caso que pediría el patrón: si el negocio necesitara reglas como "(A y B) o (C y D)" con anidación libre —un motor de reglas—, ahí sí hay árbol. La diferencia entre "una lista de condiciones" y "un árbol de condiciones" es exactamente si el negocio necesita combinar con "o" anidado.

Por qué funciona: tres de los cuatro parecen candidatos y no lo son. La pregunta "¿un grupo puede contener otro grupo?" los descarta en un segundo, y es la única que hace falta. Nota además que en (c) y (d) hay versiones cercanas que sí pedirían el patrón: el caso no se decide por el dominio sino por si la anidación es real.

Ejercicio 2 — Agrega una combinación nueva. El equipo comercial pide una campaña que hoy no se puede expresar: "aplica el primer descuento que corresponda y ninguno más, en el orden que definimos". Es lo que hacen las promociones con prioridad: si el cliente tiene código de prensa, ese y nada más; si no, mira si es estudiante; si no, mira si compra en volumen.

Escribe FirstMatch y después arma con él la campaña. Presta atención a dos cosas: cómo sabes si un hijo "corresponde", y qué debe devolver describe().

Ver solución
# Archivo: pricing/combinations.py

@dataclass(frozen=True)
class FirstMatch:
    """El primer descuento que dé algo, en el orden dado. Los demás se ignoran.

    A diferencia de AllOf y BestOf, aquí el ORDEN de los hijos es la regla de
    negocio, así que el nombre lo dice y la tupla lo preserva.
    """
    children: tuple[Discount, ...]
    label: str = "Promoción aplicada"

    def amount_for(self, order, subtotal: Decimal) -> Decimal:
        for child in self.children:
            amount = child.amount_for(order, subtotal)
            if amount > 0:
                return amount
        return Decimal("0")

    def describe(self) -> str:
        return f"{self.label} (la primera que aplique de: " + \
               ", ".join(c.describe() for c in self.children) + ")"
# La campaña con prioridad:

campaign = FirstMatch((
    PercentageOff(Decimal("0.25"), "Código de prensa", applies_to=has_press),
    PercentageOff(Decimal("0.10"), "Estudiante", applies_to=is_student),
    FixedAmountOff(Decimal("200"), "Compra de 4 o más", applies_to=is_bulk),
))

Las dos decisiones:

Cómo saber si un hijo "corresponde". La solución de arriba usa amount > 0 como criterio, y eso es una decisión con un caso raro: un descuento que aplica pero da cero —un 0% promocional, o un monto fijo sobre un subtotal de cero— se considera "no aplica" y se pasa al siguiente. En la mayoría de los negocios eso está bien y es lo que un humano esperaría. Si tu negocio necesita distinguir "no aplica" de "aplica y da cero", el contrato tiene que decirlo, y ahí la solución honesta es que amount_for devuelva Decimal | None, o agregar un método applies_to(order) al contrato común. Las dos son defendibles; lo que no lo es es dejar la ambigüedad sin decidir. Este ejercicio existe para que te tropieces con el hecho de que el contrato del componente decide qué combinaciones son expresables.

Qué devuelve describe(). La solución de arriba describe la estructura —"la primera que aplique de: A, B, C"—, que es lo correcto para un método que no recibe la orden. Para mostrarle al cliente cuál se aplicó de verdad hace falta la orden, y eso es trabajo de breakdown, que sí la tiene. La distinción vale la pena: describe() explica la regla, breakdown() explica el resultado. Confundir las dos es lo que lleva a que describe empiece a recibir parámetros y el contrato crezca.

Si tu solución agregó FirstMatch sin tocar ninguna otra clase, ese es el punto del ejercicio: una combinación nueva es una clase nueva de veinte líneas, y las hojas y las otras combinaciones no se enteran. Eso es lo que la cascada de if no podía hacer.

Ejercicio 3 — Diagnostica este Composite. Este código llegó a una revisión. Encuentra al menos tres problemas.

class NotificationGroup:
    """Agrupa canales de notificación y los trata como uno solo."""

    def __init__(self):
        self.channels = []

    def add(self, channel):
        self.channels.append(channel)

    def send(self, customer, message):
        for channel in self.channels:
            if isinstance(channel, NotificationGroup):
                channel.send(customer, message)
            else:
                if channel.is_available_for(customer):
                    channel.send(customer, message)


class EmailChannel:
    def send(self, customer, message): ...
    def is_available_for(self, customer): return customer.email is not None
    def add(self, channel):
        raise NotImplementedError("Un canal no tiene hijos")
Ver solución

Problema 1: el isinstance está dentro del recorrido. La línea if isinstance(channel, NotificationGroup) es la señal de que el patrón no está funcionando: si el grupo tuviera que tratar igual a hojas y ramas, no necesitaría preguntar qué es cada hijo. La causa real está en el contrato: is_available_for es un método que las hojas tienen y los grupos no, así que el grupo tiene que esquivarlo. Consecuencia: el recorrido conoce los tipos concretos, y agregar un tercer tipo de nodo obliga a tocar este if. Corrección: o is_available_for entra al contrato común con un significado que la rama pueda cumplir —"alguno de mis hijos está disponible"— o sale del contrato y cada hoja decide adentro de su send si le corresponde actuar.

Problema 2: la hoja tiene un add que lanza. Es la opción de transparencia del catálogo, con su costo completo: EmailChannel promete un método que no puede cumplir. Consecuencia: nadie puede escribir código genérico que agregue hijos sin envolverlo en un try, y un verificador de tipos no ayuda porque la firma existe. Corrección: sacar add del contrato común. Los grupos reciben sus hijos en el constructor y las hojas no tienen ese método.

Problema 3: es mutable y se construye vacío. NotificationGroup() sin hijos y con add significa que un grupo puede quedarse a medio armar y que alguien puede modificarlo después de guardarlo en algún lado. Consecuencia: los errores de "a este cliente le llegaron dos correos" o "dejaron de llegar los avisos" se vuelven imposibles de reproducir, porque dependen del orden en que se ejecutó el código. Corrección: frozen=True, tuple, hijos en el constructor.

Y el cuarto, que es el más importante: probablemente no hay árbol. Los canales de notificación de Boletia son email, SMS y push. Un canal no contiene canales, y en la práctica nadie ha necesitado un grupo dentro de un grupo. Esto es el primer error común: una lista con clases. La solución más honesta es que este archivo desaparezca y quede una función:

def notify(channels, customer, message):
    for channel in channels:
        if channel.is_available_for(customer):
            channel.send(customer, message)

Cuatro líneas, cero clases, y los tres problemas anteriores dejan de existir porque desaparece la estructura que los causaba.

Por qué funciona: los tres primeros problemas son reales y se pueden corregir uno por uno, y esa es la trampa del ejercicio —es perfectamente posible dedicar una tarde a arreglar un patrón que no debía estar—. Antes de mejorar una abstracción conviene preguntarse si se gana su lugar. Es el módulo 2 apareciendo otra vez, y es la antesala exacta de la lección 7.

Resumen y siguiente paso

En esta lección definiste Composite: un objeto que contiene varios objetos del mismo tipo que él y se comporta como uno solo. Viste su propiedad definitoria —el compuesto ES un componente, así que quien lo usa nunca pregunta qué le tocó— y la regla que lo separa del Decorator: si contiene uno, es Decorator; si contiene varios, es Composite.

Hiciste el caso legítimo de Boletia: los descuentos, que pasaron de una cascada de if con la lógica de cada regla duplicada en cada campaña a un árbol de piezas reutilizables. Y viste que la ganancia principal no fue escribir menos, sino que las clases dejaron de crecer con las campañas: una campaña nueva es una expresión de datos, no código nuevo. Más el beneficio escondido: un árbol de objetos se puede recorrer para más de una pregunta —calcular, explicar, validar— y ese describe() que se compone solo era imposible con la cascada.

Viste los tres problemas honestos. El orden: si los descuentos se aplican sobre el mismo subtotal o en cadena cambia el resultado, y esa decisión pertenece al negocio y tiene que estar escrita. La asimetría: mete en el contrato común solo lo que hoja y rama pueden cumplir con sentido; el contrato es la intersección, no la unión. La profundidad: el patrón no pone límite y hay que ponérselo, porque un árbol que nadie puede explicar produce facturas que nadie puede explicar.

Y viste las cuatro situaciones donde no va: cuando no hay anidación real, cuando el grupo se comporta distinto y quien lo usa necesita saberlo, cuando el árbol tiene profundidad fija, y cuando lo que varía es un valor y no una estructura. La pregunta de un segundo que las descarta todas: ¿un grupo puede contener otro grupo?

Antes de avanzar deberías poder: distinguir Composite de Decorator por la cantidad de hijos; escribir una combinación nueva sin tocar las hojas; decidir qué entra al contrato común y qué no; y —sobre todo— reconocer una lista disfrazada de árbol.

Con esto cierra el catálogo del módulo: Adapter, Facade, Decorator y Composite. La lección 7 es el contrapeso, y llega en el momento exacto. Después de aprender cuatro formas de envolver cosas, la tentación de envolverlo todo es fuerte y se siente como buen criterio. Vamos a instalar el freno con tres preguntas que se contestan en un minuto —¿hay más de una implementación?, ¿necesitas sustituirla en pruebas?, ¿la interfaz externa es realmente inestable?— y con una regla que va a decidir la mitad de los casos que encuentres en tu carrera: si las tres son "no", envuelve simple y sigue.

Recursos