Módulo 3: Patrones para el comportamiento que varía

4. Template Method: el mismo esqueleto, pasos distintos

Descripción

Al terminar esta lección vas a saber reconocer y aplicar el patrón Template Method, que es el que sirve cuando el orden de los pasos es sagrado y lo que cambia es el contenido de algunos. Vas a verlo sobre el rincón de reportes de Boletia, donde tres exportadores —CSV, PDF y hoja de cálculo— hacen exactamente lo mismo, en exactamente el mismo orden, y solo se diferencian en cómo escriben cada pieza.

Vas a salir con cuatro cosas. La anatomía del patrón: el método plantilla que fija el orden y los pasos que las variantes rellenan. La diferencia con Strategy en una frase, que es la pregunta que más confunde a quien está aprendiendo esta familia. El riesgo real del mecanismo —porque Template Method se construye con herencia, y la herencia tiene letra chica que casi nadie lee—. Y la alternativa moderna: la misma idea armada con composición o con funciones, que en Python suele ser mejor.

Esto importa porque Template Method es el patrón que más veces vas a encontrar ya escrito en código ajeno, aunque no lo escribas tú. Está por todos lados: en los frameworks de pruebas (setUp, el test, tearDown), en los de web (los ganchos antes y después de cada petición), en los ORM, en los procesadores de datos. Si trabajas con cualquier framework, estás rellenando pasos de un método plantilla que alguien más escribió, probablemente sin haberlo notado.

Conexión con el módulo: las lecciones 2 y 3 te dieron Strategy, donde varía el algoritmo completo y quien llama elige cuál usar. Esta lección presenta el caso vecino, donde el algoritmo es uno solo y lo que varía son pedazos suyos. La lección 5 completa la familia con State, donde el que elige es el propio objeto. La lección 6 va a poner en duda la maquinaria de clases de las tres, y esta lección es la que más va a sufrir esa duda —con razón—. Y la lección 7 te va a recordar que buena parte del código que se parece a esto no necesita ningún patrón.

El tour guiado que siempre hace el mismo recorrido

Piensa en una empresa de tours a pie por el centro de una ciudad. El recorrido está fijado y no lo cambia nadie: se sale de la plaza principal, se pasa por la catedral, se sube al mirador, se baja por el mercado y se termina donde se empezó. Ese orden es el tour: está en el folleto, en el permiso municipal y en el cálculo de las dos horas que dura.

Lo que sí cambia es qué cuenta cada guía en cada parada. En la catedral, uno habla de arquitectura y de por qué las torres son de alturas distintas; otro cuenta la leyenda del fantasma de la nave lateral; un tercero, que da el tour gastronómico, aprovecha para explicar de dónde salía el pan que se bendecía ahí. Tres guías, tres tours distintos en experiencia, exactamente el mismo recorrido.

Fíjate en cómo está repartida la autoridad, porque es el corazón del patrón. La empresa fija el esqueleto: las paradas y su orden. El guía llena los huecos: qué se dice en cada una. Y —esto es lo importante— un guía no puede saltarse el mirador ni pasar primero por el mercado. Si pudiera, no sería el mismo tour, y el folleto estaría mintiendo.

Esa asimetría es exactamente la de Template Method: hay una parte del algoritmo que no está en discusión y otra que sí. Compáralo con Strategy, donde quien llama entrega un objeto que hace el trabajo completo a su manera: ahí sería como decirle a alguien "llévalos a conocer la ciudad" y dejar que decida ruta, duración y paradas. Los dos son razonables; resuelven problemas distintos.

Y una cosa más que el tour deja ver y que vamos a necesitar: la empresa puede dejar paradas opcionales. "Si el grupo trae niños, pueden hacer la parada del carrusel; si no, sigan de largo". Esa parada tiene un comportamiento por defecto —no hacer nada— y cada guía decide si la usa. En el patrón eso se llama un gancho, y es una de sus piezas más útiles.

Ejemplo trabajado: los tres exportadores de reportes de Boletia

Vamos al rincón reports/ de Boletia. El organizador de un evento pide la lista de asistentes: la quiere en CSV para su hoja de cálculo, administración la quiere en PDF para archivarla, y contabilidad la quiere en XLSX. Así está el código hoy:

# Archivo: reports/csv_exporter.py

def export_csv(event_id, out_path):
    attendees = fetch_attendees(event_id)             # 1. traer los datos
    attendees = sorted(attendees, key=lambda a: a.name)  # 2. ordenarlos
    f = open(out_path, "w", newline="")               # 3. abrir el destino
    writer = csv.writer(f)
    writer.writerow(["Nombre", "Correo", "Tipo", "Precio"])   # 4. encabezado
    for a in attendees:                                # 5. una fila por asistente
        writer.writerow([a.name, a.email, a.kind, f"{a.price:.2f}"])
    f.close()                                          # 6. cerrar
    log.info("reporte csv generado event=%s filas=%s", event_id, len(attendees))
# Archivo: reports/pdf_exporter.py

def export_pdf(event_id, out_path):
    attendees = fetch_attendees(event_id)             # 1. traer los datos
    attendees = sorted(attendees, key=lambda a: a.name)  # 2. ordenarlos
    doc = PdfDocument(out_path)                       # 3. abrir el destino
    doc.add_title(f"Asistentes del evento {event_id}")
    doc.add_row(["Nombre", "Correo", "Tipo", "Precio"], bold=True)  # 4. encabezado
    for a in attendees:                                # 5. una fila por asistente
        doc.add_row([a.name, a.email, a.kind, f"${a.price:,.2f}"])
    doc.save()                                         # 6. cerrar
    log.info("reporte pdf generado event=%s filas=%s", event_id, len(attendees))
# Archivo: reports/xlsx_exporter.py

def export_xlsx(event_id, out_path):
    attendees = fetch_attendees(event_id)             # 1. traer los datos
    attendees = sorted(attendees, key=lambda a: a.name)  # 2. ordenarlos
    book = Workbook()                                 # 3. abrir el destino
    sheet = book.active
    sheet.append(["Nombre", "Correo", "Tipo", "Precio"])   # 4. encabezado
    for a in attendees:                                # 5. una fila por asistente
        sheet.append([a.name, a.email, a.kind, a.price])
    book.save(out_path)                                # 6. cerrar
    log.info("reporte xlsx generado event=%s filas=%s", event_id, len(attendees))

Léelos uno junto a otro y mira la forma. Los tres tienen seis pasos, en el mismo orden. Los pasos 1, 2 y 6 —traer los datos, ordenarlos y registrar el log— son idénticos línea por línea. Los pasos 3, 4 y 5 hacen lo mismo conceptualmente pero con la sintaxis de cada librería.

Esto es un tipo de duplicación distinto al que vimos en la lección 3. Allá se repetían dos líneas de cálculo. Aquí se repite la estructura del procedimiento, y ese tipo de duplicación tiene una consecuencia específica y muy incómoda: cuando la estructura tiene que cambiar, hay que cambiarla en todos lados.

Un ejemplo concreto de lo que eso cuesta. Llega el requerimiento: "los reportes no deben incluir a los asistentes con boleto de cortesía, salvo que se pida explícitamente". Ese cambio no es de formato: es del procedimiento. Hay que abrir los tres archivos, meter el mismo filtro en el mismo lugar, y confiar en no haberlo escrito distinto en alguno. Si alguien agregó un cuarto exportador el mes pasado y no lo sabes, lo dejas roto. Este síntoma tiene nombre —shotgun surgery, cirugía de escopeta— y el módulo 7 lo trata en serio.

El paso 1: separar lo que se queda de lo que cambia.

Escribe el esqueleto una sola vez, y deja huecos con nombre donde cada formato pone lo suyo.

# Archivo: reports/exporter.py
from abc import ABC, abstractmethod


class ReportExporter(ABC):
    """El esqueleto de exportar un reporte de asistentes.

    El ORDEN de los pasos vive aquí y no se negocia. Cada formato solo
    rellena qué hace en cada paso.
    """

    def export(self, event_id, out_path):
        """El método plantilla. NO se sobrescribe: es el contrato del flujo."""
        attendees = self._fetch(event_id)
        attendees = self._sort(attendees)
        attendees = [a for a in attendees if self._should_include(a)]

        self._open(out_path)                  # paso que cada formato define
        self._write_header(HEADERS)           # paso que cada formato define
        for attendee in attendees:
            self._write_row(self._to_row(attendee))   # paso que cada formato define
        self._close()                         # paso que cada formato define

        log.info("reporte %s generado event=%s filas=%s",
                 self.name, event_id, len(attendees))

    # --- Pasos con comportamiento por defecto: iguales para todos ---

    def _fetch(self, event_id):
        return fetch_attendees(event_id)

    def _sort(self, attendees):
        return sorted(attendees, key=lambda a: a.name)

    def _to_row(self, attendee):
        # Los datos crudos de una fila. El FORMATO de cada celda lo pone
        # cada exportador; aquí solo se decide qué columnas van.
        return [attendee.name, attendee.email, attendee.kind, attendee.price]

    # --- Gancho: opcional, con comportamiento por defecto ---

    def _should_include(self, attendee) -> bool:
        """Por defecto entran todos. Un formato puede filtrar si lo necesita."""
        return True

    # --- Pasos obligatorios: cada formato TIENE que definirlos ---

    @property
    @abstractmethod
    def name(self) -> str: ...

    @abstractmethod
    def _open(self, out_path): ...

    @abstractmethod
    def _write_header(self, headers): ...

    @abstractmethod
    def _write_row(self, values): ...

    @abstractmethod
    def _close(self): ...


HEADERS = ["Nombre", "Correo", "Tipo", "Precio"]

Detente en la forma de esa clase, porque tiene tres tipos de método y confundirlos es el error más común del patrón:

  • El método plantilla (export). Es concreto, escribe el orden, y no se sobrescribe. Es la única puerta de entrada.
  • Los pasos abstractos (_open, _write_header, _write_row, _close). No tienen cuerpo. Cada variante está obligada a definirlos; si olvida uno, Python no la deja instanciar y el error sale en el momento de crear el objeto, no a mitad de un reporte en producción.
  • Los pasos con comportamiento por defecto y los ganchos (_fetch, _sort, _to_row, _should_include). Tienen cuerpo. Una variante puede aceptarlos tal cual o cambiarlos. _should_include es un gancho puro: por defecto no hace nada interesante, y existe solo para que alguien pueda intervenir ahí.

El paso 2: cada formato rellena sus huecos.

# Archivo: reports/csv_exporter.py
import csv
from reports.exporter import ReportExporter


class CsvExporter(ReportExporter):
    name = "csv"

    def _open(self, out_path):
        # Se guarda el archivo en el objeto porque _write_row y _close
        # lo van a necesitar. Es estado que vive entre pasos.
        self._file = open(out_path, "w", newline="")
        self._writer = csv.writer(self._file)

    def _write_header(self, headers):
        self._writer.writerow(headers)

    def _write_row(self, values):
        # En CSV todo es texto: el precio se formatea con dos decimales.
        name, email, kind, price = values
        self._writer.writerow([name, email, kind, f"{price:.2f}"])

    def _close(self):
        self._file.close()
# Archivo: reports/pdf_exporter.py
from reports.exporter import ReportExporter


class PdfExporter(ReportExporter):
    name = "pdf"

    def _open(self, out_path):
        self._doc = PdfDocument(out_path)
        self._doc.add_title("Asistentes")

    def _write_header(self, headers):
        # El PDF marca el encabezado en negritas; los otros formatos no
        # tienen ese concepto, y por eso el paso está separado.
        self._doc.add_row(headers, bold=True)

    def _write_row(self, values):
        name, email, kind, price = values
        self._doc.add_row([name, email, kind, f"${price:,.2f}"])

    def _close(self):
        self._doc.save()
# Archivo: reports/xlsx_exporter.py
from reports.exporter import ReportExporter


class XlsxExporter(ReportExporter):
    name = "xlsx"

    def _open(self, out_path):
        self._book = Workbook()
        self._sheet = self._book.active
        self._out_path = out_path

    def _write_header(self, headers):
        self._sheet.append(headers)

    def _write_row(self, values):
        # A diferencia de CSV y PDF, aquí el precio se guarda como NÚMERO:
        # la hoja de cálculo tiene que poder sumar la columna.
        self._sheet.append(values)

    def _close(self):
        self._book.save(self._out_path)

Mira XlsxExporter._write_row. Es la línea que justifica que el formato de cada celda sea responsabilidad de cada exportador y no del esqueleto: CSV y PDF quieren texto con símbolo de moneda, la hoja de cálculo quiere un número para poder sumarlo. Si el esqueleto hubiera formateado los precios, habrías roto la única función que la gente de contabilidad usa de verdad.

Qué esperar. Al usarlo:

CsvExporter().export(event_id=77, out_path="/tmp/asistentes.csv")
PdfExporter().export(event_id=77, out_path="/tmp/asistentes.pdf")
XlsxExporter().export(event_id=77, out_path="/tmp/asistentes.xlsx")

# En el log:
# reporte csv generado event=77 filas=412
# reporte pdf generado event=77 filas=412
# reporte xlsx generado event=77 filas=412

Las tres llamadas se ven iguales y producen tres archivos distintos. Hasta aquí es lo mismo que daría una Strategy. La diferencia aparece cuando llega un cambio del procedimiento.

Volvamos al requerimiento de antes: "no incluir cortesías salvo que se pida". Ahora es un cambio en un lugar:

# Archivo: reports/exporter.py  — el método plantilla, versión final

def export(self, event_id, out_path, include_courtesies=False):
    attendees = self._fetch(event_id)
    attendees = self._sort(attendees)
    if not include_courtesies:
        # Se filtra ANTES de escribir para que el conteo del log
        # coincida con las filas que de verdad salieron.
        attendees = [a for a in attendees if a.kind != "courtesy"]
    attendees = [a for a in attendees if self._should_include(a)]
    ...

Un archivo. Los tres exportadores no se abren. Y —lo más valioso— es imposible que un formato se quede sin el filtro, porque el filtro no vive en los formatos.

Compara eso con la situación de partida, donde el mismo cambio eran tres ediciones idénticas y un riesgo de olvido. Ese es el retorno concreto del patrón: cuando lo que cambia es el procedimiento, cambia en un solo sitio.

Y el otro lado, porque siempre hay otro lado. Agregar un formato nuevo —digamos JSON— ahora exige entender la clase base antes de escribir una línea: qué métodos son obligatorios, en qué orden se llaman, qué recibe cada uno. Antes bastaba copiar export_csv, cambiarle las tres líneas del medio y listo. El patrón bajó el costo de cambiar el procedimiento y subió el costo de entrar por primera vez. Con tres formatos y un procedimiento que se toca, el intercambio conviene. Con dos formatos y un procedimiento congelado, no.

La diferencia con Strategy, en una frase

Aquí está la frase, y conviene que te la aprendas porque es la pregunta que aparece en toda entrevista sobre esta familia:

En Strategy delegas el algoritmo completo; en Template Method conservas el algoritmo y delegas solo algunos pasos.

Todo lo demás se deduce de ahí. Vamos a desdoblarlo en la tabla que te va a servir para decidir:

StrategyTemplate Method
Qué varíaEl algoritmo enteroPasos sueltos dentro de un algoritmo fijo
Quién manda sobre el ordenLa implementación: cada una hace lo suyo como quieraLa clase base: el orden es intocable
Mecanismo típicoComposición: se pasa un objeto (o una función)Herencia: se define una subclase
Cuándo se decideEn tiempo de ejecución, se puede cambiar sobre la marchaAl elegir la clase; una instancia no cambia de variante
Qué ve quien llamaUn objeto que sabe hacer la cosaUn objeto de una subclase, con una puerta de entrada
Señal de que es el adecuadoLas variantes no se parecen en su estructura internaLas variantes repiten el mismo esqueleto

La señal de la última fila es la más práctica de todas. Ponlo así: mira dos de tus variantes una junto a otra. Si tienen la misma forma —las mismas líneas de estructura, los mismos pasos en el mismo orden— y solo difieren en el contenido de dos o tres de ellas, tienes un candidato a Template Method. Si tienen formas completamente distintas —una hace tres cosas, otra hace una consulta y devuelve, otra recorre una lista—, tienes un candidato a Strategy.

Aplícalo a lo que ya hiciste en este módulo. Las cuatro reglas de precio de la lección 3 no comparten forma: cortesía consulta un tope y puede fallar; early-bird compara fechas; general mira una cantidad. No hay esqueleto común que valga la pena escribir una vez. Strategy fue la respuesta correcta. Los tres exportadores, en cambio, son la misma función con tres huecos rellenados distinto. Template Method encaja mejor.

Y —esto lo vamos a ver enseguida— también se pueden combinar. Un método plantilla puede delegar un paso en una Strategy inyectada. De hecho, esa combinación suele ser mejor que la herencia pura.

El riesgo de la herencia como mecanismo

Template Method es el patrón de esta familia que más ha envejecido, y la razón es el mecanismo con el que se construye. Cuatro problemas concretos, en orden de gravedad.

1. La subclase y la clase base quedan pegadas para siempre. Con Strategy, la implementación y quien la usa se comunican por un contrato estrecho: un método, unos argumentos, un valor de retorno. Con herencia, la subclase queda expuesta a todo lo que hay en la base: sus atributos, sus métodos privados, su orden interno. Y ese acoplamiento va en las dos direcciones. Si alguien cambia el método plantilla —agrega un paso, reordena dos—, todas las subclases se enteran quieran o no. Este fenómeno tiene nombre desde hace treinta años: el problema de la clase base frágil. Un cambio inocente arriba rompe cosas abajo, y las cosas de abajo pueden estar en otro módulo, escritas por otra persona.

2. No puedes cambiar de variante en tiempo de ejecución. Un CsvExporter es un CsvExporter desde que nace hasta que muere. Si necesitaras que el mismo objeto exportara primero en CSV y después en PDF, no se puede: hay que construir otro objeto. Con Strategy sí puedes, porque la variante es un colaborador y se sustituye. Para reportes esto da igual —nadie necesita que un exportador cambie de formato a media vida—, pero es una limitación real y conviene saberla antes de elegir el mecanismo.

3. El flujo se lee en dos lugares y en direcciones opuestas. Para saber qué pasa cuando llamas a CsvExporter().export(...), tienes que leer la clase base —que te dice el orden— y luego saltar a la subclase por cada paso. El código que se ejecuta está repartido entre dos archivos y no se lee de arriba abajo. Se lee de arriba, luego abajo, luego arriba. Se le llama flujo de control invertido, y es exactamente el costo que el módulo 2 llamó indirección. Con tres pasos es manejable; con una jerarquía de tres niveles —base, base intermedia, hoja— es donde nacen los rincones que nadie entiende.

4. Es fácil de romper sin darse cuenta. Nada le impide a una subclase sobrescribir export(), que era justo lo que no debía tocarse. En Python no hay final, así que el contrato es una convención sostenida por comentarios y revisiones. Una subclase que sobrescribe el método plantilla rompe la única garantía que el patrón daba —que el orden es el mismo— y el resto del sistema sigue creyendo que se cumple.

La alternativa: la misma idea con composición. El esqueleto sigue escrito una sola vez, pero los pasos se entregan en vez de heredarse.

# Archivo: reports/exporter.py  — versión con composición
from typing import Protocol


class ReportWriter(Protocol):
    """Quien sabe escribir en un formato concreto. Nada más."""
    def open(self, out_path): ...
    def write_header(self, headers): ...
    def write_row(self, values): ...
    def close(self): ...


def export_report(event_id, out_path, writer: ReportWriter,
                  include_courtesies: bool = False):
    """El esqueleto: el orden vive aquí y solo aquí.

    En vez de heredar, se RECIBE quien escribe. El writer no sabe que
    existe este flujo; solo sabe escribir.
    """
    attendees = sorted(fetch_attendees(event_id), key=lambda a: a.name)
    if not include_courtesies:
        attendees = [a for a in attendees if a.kind != "courtesy"]

    writer.open(out_path)
    writer.write_header(HEADERS)
    for a in attendees:
        writer.write_row([a.name, a.email, a.kind, a.price])
    writer.close()

    log.info("reporte generado event=%s filas=%s", event_id, len(attendees))
# Uso
export_report(77, "/tmp/asistentes.csv", writer=CsvWriter())
export_report(77, "/tmp/asistentes.pdf", writer=PdfWriter())

Compara las dos versiones y fíjate en lo que ganaste. CsvWriter no hereda de nada: es una clase pequeña que sabe escribir CSV y ya. Puedes probarla sola, sin reportes de por medio. Puedes usarla en otro flujo que no sea este. Y el orden de los pasos está en una función que se lee de arriba abajo, sin saltos. El acoplamiento pasó de "todo lo que hay en la clase base" a "cuatro métodos".

¿Se perdió algo? Un poco. La versión con herencia permitía que una subclase cambiara un paso con comportamiento por defecto —por ejemplo, un exportador que ordenara distinto— sin tocar nada más. En la versión con composición, para eso hay que agregar otro parámetro. Es un intercambio: menos flexibilidad automática a cambio de menos acoplamiento y menos magia.

La recomendación práctica, que es la que yo aplicaría en un proyecto nuevo: empieza por composición y usa herencia solo cuando la jerarquía ya está ahí y es la convención del entorno. Si trabajas dentro de un framework que te pide heredar de una clase base y rellenar métodos, hazlo: es su forma de trabajar y pelearse con ella cuesta más de lo que devuelve. Si estás escribiendo código tuyo desde cero, la función que recibe colaboradores envejece mejor casi siempre.

Y si el paso variable es uno solo, ni siquiera hace falta una clase:

def export_report(event_id, out_path, write_row, open_file, close_file):
    ...

Cuando ves esa firma, ya estás en el territorio de la lección 6.

Errores comunes

Convertir el método plantilla en un formulario de veinte huecos (de diseño). Qué pasa: el patrón funciona con tres o cuatro pasos, y como funciona se le van agregando ganchos: _before_open, _after_header, _transform_row, _on_error, _finalize. Al año la clase base tiene quince métodos sobrescribibles, cada subclase usa tres distintos, y para entender qué hace una variante hay que revisar cuáles de los quince redefinió. Por qué pasa: cada gancho nuevo resuelve un caso real y cuesta poco en ese momento; el costo es acumulativo y no se ve hasta que ya está. Cómo detectarlo: si necesitas leer la clase base con el dedo para saber en qué orden se llaman los ganchos, ya son demasiados. Cómo corregirlo: pon un techo —tres o cuatro pasos variables— y cuando lo superes, pregúntate si de verdad es un solo procedimiento con variantes o si son dos procedimientos parecidos que conviene separar. La respuesta suele ser la segunda.

Sobrescribir el método plantilla (de implementación). Qué pasa: una subclase necesita algo que el esqueleto no permite —saltarse el encabezado, escribir dos veces— y la salida rápida es redefinir export() copiando el original con el cambio. A partir de ahí el patrón está muerto: el orden ya no es único, y cuando alguien modifique la plantilla, esa subclase se va a quedar atrás en silencio. Por qué pasa: en Python nada lo impide, y en el momento se ve como la solución de menor fricción. Cómo detectarlo: busca subclases que definan el mismo nombre que el método plantilla. Es una búsqueda de treinta segundos y vale la pena hacerla en cualquier jerarquía que heredes. Cómo corregirlo: si un caso no cabe en el esqueleto, el esqueleto está mal cortado. Agrega un gancho explícito en la clase base —o admite que ese caso no es una variante del mismo procedimiento y sácalo de la jerarquía—.

Usar Template Method con dos variantes (de criterio). Qué pasa: hay dos exportadores, comparten estructura, y alguien monta la clase base con sus abstractos. El código queda más largo que las dos funciones originales, con un archivo más, y sin ninguna ganancia, porque con dos casos el procedimiento cambia igual de fácil en dos lugares que en uno. Por qué pasa: la duplicación estructural se ve muy fea —dos funciones con la misma forma piden a gritos ser unificadas— y ese impulso estético llega antes que el cálculo. Cómo detectarlo: cuenta las variantes reales de hoy, no las imaginadas. Dos es el número donde la regla de tres del módulo 2 dice que esperes. Cómo corregirlo: deja las dos funciones. Cuando llegue la tercera, la forma correcta del esqueleto va a ser evidente —y va a ser distinta de la que habrías escrito con dos, porque la tercera casi siempre trae la sorpresa que revela dónde está el hueco de verdad—.

Ejercicios

Ejercicio 1 — Decide entre Strategy y Template Method. Para cada situación, di cuál de los dos encaja mejor y justifica en una línea con la señal de "misma forma / formas distintas".

(a) Tres formas de calcular el costo de envío de los boletos físicos: mensajería nacional, entrega en taquilla y envío internacional. Cada una consulta cosas distintas y devuelve un monto. (b) El proceso de importar asistentes desde un archivo: validar el formato, leer las filas, normalizar los datos, insertar en la base, escribir un resumen. Cambia según venga de CSV, de Excel o de la API de otro sistema de boletos. (c) Tres formas de ordenar la lista de eventos en la página de inicio: por fecha, por popularidad, por cercanía geográfica. (d) El envío de un correo transaccional: armar el asunto, armar el cuerpo, adjuntar el boleto en PDF, mandarlo, registrar el envío. Cambia el contenido según sea confirmación, recordatorio o cancelación.

Ver solución

(a) Strategy. Formas distintas: la mensajería nacional consulta una tabla de tarifas por código postal, la taquilla devuelve cero sin consultar nada, y el envío internacional habla con una API externa y puede fallar. No hay esqueleto común que valga la pena escribir una vez; lo único compartido es la pregunta y el tipo de la respuesta.

(b) Template Method. Misma forma: los cinco pasos ocurren siempre, en ese orden, sin importar el origen. Lo que cambia es cómo se validan y cómo se leen las filas. Además el procedimiento va a cambiar como un todo —el día que se agregue "deduplicar antes de insertar", se agrega para todos los orígenes—, que es exactamente el argumento a favor del patrón.

(c) Ninguno de los dos, probablemente. Es un key= de sorted(). Tres funciones de una línea y un diccionario. Técnicamente es una Strategy —lo es, en su forma más ligera— pero no necesita clases, ni contrato escrito, ni archivos nuevos. Si tu respuesta fue "Strategy con tres clases", vuelve a la sección de "cuándo el if es mejor" de la lección 2: cuando el cuerpo de cada variante es una expresión, tienes datos, no comportamiento.

(d) Template Method, con un matiz que vale más que la respuesta. La forma es la misma y el orden es fijo, así que encaja. Pero fíjate en que lo que varía —asunto, cuerpo, si lleva adjunto— es sobre todo contenido, no comportamiento. Antes de montar una jerarquía, pregúntate si tres plantillas de texto y una tabla resolverían lo mismo. Muy a menudo sí, y entonces la respuesta correcta no es un patrón sino un directorio de plantillas.

Por qué funciona: dos de las cuatro no piden el patrón que parecía obvio. Esa proporción es la realista, y la habilidad que estás entrenando es la de descartar.

Ejercicio 2 — Convierte la herencia en composición. Toma la versión con ReportExporter y CsvExporter y reescríbela con composición: una función export_report que reciba un writer, y una clase CsvWriter que no herede de nada. Después responde: (a) ¿qué se volvió más fácil de probar?, (b) ¿qué perdiste?, (c) ¿dónde vive ahora el orden de los pasos?

Ver solución
# Archivo: reports/csv_writer.py
import csv


class CsvWriter:
    """Sabe escribir CSV. No sabe nada de reportes ni de asistentes."""

    def open(self, out_path):
        self._file = open(out_path, "w", newline="")
        self._writer = csv.writer(self._file)

    def write_header(self, headers):
        self._writer.writerow(headers)

    def write_row(self, values):
        name, email, kind, price = values
        self._writer.writerow([name, email, kind, f"{price:.2f}"])

    def close(self):
        self._file.close()

(a) Qué se volvió más fácil de probar. Dos cosas, y en dos direcciones.

CsvWriter se prueba solo: se construye, se le llama, se lee el archivo. No hay evento, ni base de datos, ni reporte. Antes, para probar el _write_row de CsvExporter había que instanciar una subclase que arrastraba toda la clase base.

Y el esqueleto también se prueba solo, que es la ganancia menos evidente y la más útil: puedes pasarle un FakeWriter que solo guarde en una lista lo que le pidieron escribir, y verificar el orden y el contenido sin generar ningún archivo:

class FakeWriter:
    def __init__(self):
        self.calls = []
    def open(self, out_path):     self.calls.append(("open", out_path))
    def write_header(self, h):    self.calls.append(("header", h))
    def write_row(self, values):  self.calls.append(("row", values))
    def close(self):              self.calls.append(("close",))


def test_courtesies_are_excluded_by_default():
    writer = FakeWriter()
    export_report(event_id=77, out_path="/tmp/x", writer=writer)
    rows = [c for c in writer.calls if c[0] == "row"]
    assert all("courtesy" not in r[1] for r in rows)

Esa prueba verifica el procedimiento, que es lo que el patrón protege, y no tiene nada que ver con formatos. Con herencia era mucho más incómoda de escribir.

(b) Qué perdiste. La posibilidad de que una variante cambie un paso con comportamiento por defecto sin avisar. Con herencia, un exportador que necesitara otro criterio de orden solo sobrescribía _sort. Con composición hay que agregar un parámetro (sort_key=...), lo cual es más explícito y más ruidoso a la vez. Y perdiste el mecanismo automático que obliga a implementar todos los pasos: ABC con @abstractmethod fallaba al instanciar; un Protocol solo te avisa si corres un verificador de tipos.

(c) Dónde vive el orden. En export_report, una función que se lee de arriba abajo sin saltar a ningún lado. Ese es el cambio más importante de los tres: el flujo dejó de estar invertido.

Por qué funciona: haces el mismo patrón con dos mecanismos y comparas. Herencia y composición no son bandos: son dos formas de conseguir lo mismo con acoplamientos distintos. Saber traducir de una a otra es una habilidad concreta que vas a usar mucho más que el nombre del patrón.

Ejercicio 3 — Diagnostica una jerarquía que se salió de control. Encuentras esto en un proyecto. Di qué tres problemas tiene y qué harías, en orden.

class BaseImporter(ABC):
    def run(self, path):
        self._before_validate()
        rows = self._validate(path)
        self._after_validate(rows)
        rows = self._transform(rows)
        if self._should_deduplicate():
            rows = self._deduplicate(rows)
        self._before_insert(rows)
        self._insert(rows)
        self._after_insert(rows)
        self._notify()
    # …y trece métodos más, ocho de ellos con cuerpo por defecto


class CsvImporter(BaseImporter): ...
class ExcelImporter(CsvImporter): ...          # hereda de CsvImporter
class LegacyExcelImporter(ExcelImporter): ...  # tres niveles
Ver solución

Problema 1: demasiados ganchos. Nueve puntos de intervención en el método plantilla, ocho métodos con cuerpo por defecto. Para saber qué hace LegacyExcelImporter hay que revisar cuáles de los diecisiete métodos redefinió y en qué nivel de la jerarquía. Eso no es un procedimiento con variantes: es un lenguaje de configuración escrito con clases.

Problema 2: herencia de tres niveles, y del tipo peor. ExcelImporter hereda de CsvImporter, no de BaseImporter. Es decir: alguien heredó para reutilizar dos métodos, no porque un importador de Excel sea un caso de importador de CSV —que no lo es—. Ese es el uso de la herencia que más daño hace, porque crea una relación conceptual falsa. Ahora un cambio en CsvImporter afecta a Excel, y nadie lo espera al leer el nombre.

Problema 3: el método plantilla decide con un gancho booleano. if self._should_deduplicate() significa que el esqueleto ya no es fijo: tiene una variante interna. Es un if disfrazado de patrón, y es la puerta por la que entran los siguientes.

Qué haría, en orden. Primero, medir antes de tocar: cuáles de los diecisiete métodos redefine de verdad cada subclase. Casi siempre resulta que la mayoría de los ganchos no los usa nadie, y borrar un gancho muerto es el cambio más barato y más seguro que existe. Segundo, romper la herencia falsa: que ExcelImporter herede de BaseImporter y que lo que compartía con CSV pase a ser una función auxiliar compartida. Tercero, convertir los ganchos que quedan en colaboradores: un validator, un transformer y un sink que se pasan al constructor. Ahí el método plantilla vuelve a ser una función legible, y las tres piezas se prueban solas.

Y una nota sobre el orden de esos pasos, porque es la parte que se aprende peleando: no empieces por el rediseño bonito. Empieza por borrar lo muerto. Después de esa limpieza, el rediseño correcto suele ser mucho más chico de lo que parecía, y a veces ya no hace falta.

Por qué funciona: casi todo el código con Template Method que vas a encontrar está en algún punto de este deterioro, no en la versión limpia de los libros. Saber diagnosticarlo y ordenarlo por dónde empezar vale más que saber escribir el patrón desde cero.

Resumen y siguiente paso

En esta lección conociste Template Method con la imagen del tour guiado: la empresa fija las paradas y su orden, cada guía llena el contenido de cada parada, y nadie puede saltarse el mirador. Esa asimetría —una parte del algoritmo que no se negocia y otra que sí— es el corazón del patrón.

Lo aplicaste al rincón reports/ de Boletia, donde tres exportadores repetían el mismo procedimiento de seis pasos. Viste los tres tipos de método de la clase base: el método plantilla que fija el orden y no se sobrescribe, los pasos abstractos que cada variante está obligada a definir, y los pasos con comportamiento por defecto y los ganchos. Y viste el retorno concreto: cuando llegó un cambio del procedimiento —excluir cortesías— fue un archivo en vez de tres, sin riesgo de olvidar ninguno.

Te llevas la frase que separa esta familia: en Strategy delegas el algoritmo completo; en Template Method conservas el algoritmo y delegas solo algunos pasos. Y la señal práctica para decidir: si dos variantes tienen la misma forma y solo cambia el contenido de unos pasos, es Template Method; si tienen formas distintas, es Strategy.

Y viste la letra chica del mecanismo. La herencia pega la subclase a todo lo que hay en la base —el problema de la clase base frágil—, impide cambiar de variante en ejecución, parte el flujo en dos archivos que se leen en direcciones opuestas, y no tiene forma de impedir que alguien sobrescriba justo lo que no debía. Por eso construiste la misma idea con composición: un esqueleto que se lee de arriba abajo y recibe a quien escribe, con la recomendación práctica de empezar siempre por ahí y usar herencia cuando el entorno ya la impone.

Antes de avanzar deberías poder: distinguir los tres tipos de método de una clase base con método plantilla; decir la frase que separa Strategy de Template Method y aplicarla a un caso; y traducir una jerarquía chica a composición explicando qué ganas y qué pierdes.

Nos queda el tercer miembro de la familia, y es el más distinto de los tres. En Strategy elige quien llama; en Template Method elige quien construye el objeto. Pero hay un caso donde elige el objeto mismo: el Order de Boletia se comporta distinto según esté pendiente, pagada, cancelada o reembolsada, y ese estado cambia como consecuencia de lo que le va pasando. Hoy eso vive como un enredo de banderas y condicionales repetidos en cuatro métodos. La lección 5 lo convierte en transiciones explícitas —y dice también cuándo eso es exagerado y una tabla simple alcanza—.

Recursos