Módulo 4: Patrones para crear objetos

2. Factory: decidir qué crear en un solo lugar

Descripción

Al terminar esta lección vas a poder escribir una Factory en su forma útil —que es mucho más modesta de lo que sugiere el catálogo— y, más importante, vas a poder decidir si hace falta. Vas a tener su anatomía desarmada en cuatro piezas, vas a saber exactamente qué gana el código que la usa (una cosa concreta y medible: deja de saber qué implementaciones existen), y vas a conocer la diferencia entre la factory function simple, que resuelve el 90% de los casos reales, y las variantes ceremoniales del catálogo —Factory Method, Abstract Factory— que en Python rara vez se ganan su lugar.

Esto importa porque Factory es, junto con Strategy, uno de los dos patrones que más aparecen en código real, y también uno de los dos que más se aplican mal. Se aplican mal en dos direcciones opuestas. Por exceso: alguien lee "Factory Method" en el libro, ve la jerarquía de clases abstractas del diagrama y construye ese aparato completo para elegir entre dos cosas —cuando una función de seis líneas hacía lo mismo—. Y por defecto: alguien escribe la función de seis líneas, funciona, y nunca se entera de que eso ya era el patrón, así que no puede nombrarlo en una revisión ni reconocerlo en código ajeno. Las dos fallas se arreglan con lo mismo: entender qué problema resuelve el patrón, en vez de qué forma tiene el diagrama.

Conexión con el módulo: la lección 1 te dejó el problema —la decisión de qué construir, repartida en cuatro archivos de Boletia— y las tres preguntas que ordenan el módulo. Esta lección responde la primera: qué construir. Aquí trabajamos el patrón en abstracto y con ejemplos cortos, para que la idea quede limpia. La lección 3 lo lleva al caso completo de Boletia, mide el antes y el después en archivos tocados, y muestra cómo Factory se combina con la Strategy que armaste en el módulo 3. Después la lección 4 pasa al segundo problema —cómo construir—, y la 6 al tercero —quién construye—. La lección 7 vuelve sobre esta con la pregunta incómoda: la mayoría de las veces, ninguna de las tres hacía falta.

El mostrador de renta de autos

Llegas al aeropuerto y vas al mostrador de la agencia de autos. No dices: "quiero el Nissan Versa blanco del cajón 14, con la llave que está en el gancho tres". Dices: "reservé un compacto". La persona del mostrador mira su sistema, va al estacionamiento y te trae un auto que cumple lo que pediste.

Piensa en lo que acaba de pasar, porque es exactamente el patrón.

no sabes qué modelos tiene la agencia. Ni te importa. Lo único que te importa es que lo que te entreguen tenga volante, cuatro ruedas y arranque —o sea, que cumpla el contrato de "auto"—. Si mañana la agencia cambia toda su flota de Nissan a Chevrolet, tu experiencia en el mostrador es idéntica: pides un compacto, te dan un compacto. Nada de lo que tú haces tiene que cambiar.

Y del otro lado, el mostrador sabe todo eso. Sabe qué modelos hay, cuáles están disponibles, cuál corresponde a cada categoría, dónde están las llaves. Ese conocimiento vive concentrado en un lugar, y ese lugar es el único que hay que actualizar cuando entra un modelo nuevo.

Una Factory es el mostrador. Concentra el conocimiento de "qué existe y cómo se construye" en un solo punto, y le entrega a quien lo pide algo que cumple un contrato conocido. Quien lo pide se vuelve más tonto a propósito, y eso es una ganancia, no una pérdida: mientras menos sepa el checkout sobre los proveedores de pago que existen, menos motivos hay para tocarlo.

Fíjate también en la parte que la analogía deja ver y que el diagrama del libro esconde: el mostrador no fabrica autos. No tiene una línea de ensamblaje. Solo elige y entrega. Una Factory de software tampoco "fabrica" gran cosa: casi siempre se limita a decidir qué clase instanciar y a pasarle los parámetros correctos. Es una decisión con un return, no una maquinaria.

Y una última cosa de la analogía, para la lección 7. Si la agencia tuviera un solo modelo de auto, el mostrador seguiría siendo útil para el papeleo, pero la parte de "elegir" no serviría de nada. Una Factory que siempre devuelve lo mismo no es una Factory: es un constructor con un paso extra.

Qué es una Factory, en una frase

Una Factory es una función o una clase cuyo único trabajo es decidir qué objeto construir y devolverlo ya listo para usar.

Nada más. Si la frase te parece decepcionantemente simple, es porque lo es. La complejidad de este patrón en los libros no viene de la idea, viene de las cuatro o cinco variantes con las que se implementa en lenguajes que no tienen funciones de primera clase.

Ahora la anatomía. Toda Factory —da igual en qué lenguaje o con cuánta ceremonia— tiene exactamente cuatro piezas:

  1. El contrato. Lo que todos los objetos posibles tienen en común: los mismos métodos, con los mismos parámetros. En Boletia es PaymentProvider: todos saben charge(order) y refund(order, amount). Sin contrato no hay Factory posible, porque quien recibe el objeto no sabría qué hacer con él.
  2. Las implementaciones. Las clases concretas que cumplen ese contrato: StripeProvider, MercadoPagoProvider, CashProvider. Cada una en su archivo, cada una probable por separado. Estas son —y esto es el puente con el módulo 3— las Strategy.
  3. La llave. El dato con el que se decide. Casi siempre un texto o un enum que viene de la base de datos, de la petición HTTP o de la configuración. En Boletia es order.provider, ese str de texto libre que ya notamos en el módulo 1.
  4. La función que traduce llave → objeto. El mostrador. Recibe la llave, decide, construye y devuelve.
# Archivo: payments/factory.py
# Esta es la Factory completa. Sí, es esto.

def get_payment_provider(name: str) -> PaymentProvider:
    """Devuelve el proveedor de pago que corresponde al nombre dado.

    Este es el ÚNICO lugar del sistema que sabe qué proveedores existen.
    Si mañana entra uno nuevo, se agrega aquí y en ningún otro lado.
    """
    if name == "stripe":
        return StripeProvider(api_key=settings.STRIPE_KEY)
    if name == "mercadopago":
        return MercadoPagoProvider(token=settings.MP_TOKEN)
    if name == "cash":
        return CashProvider(store_chain=settings.CASH_STORE_CHAIN)
    raise UnknownProviderError(name)

Y del lado de quien la usa:

# Archivo: checkout/checkout.py

def charge_order(order):
    # El checkout ya no sabe qué proveedores existen. Pide uno y cobra.
    provider = get_payment_provider(order.provider)
    return provider.charge(order)

Detente en esas dos líneas del checkout, porque ahí está todo el valor del patrón. Antes, esa función tenía treinta líneas y sabía que Stripe cobra en centavos enteros y que el efectivo no cobra nada. Ahora tiene dos líneas y no sabe nada. Si mañana entran tres proveedores nuevos, esta función no cambia.

Una observación honesta antes de seguir: sí, el if/elif sigue existiendo. No desapareció; se mudó. Y eso está bien, porque el problema nunca fue que existiera un condicional —el problema era que existieran cuatro copias del mismo condicional en archivos que no tenían nada que ver entre sí—. Un condicional en un solo lugar, cuyo único trabajo es traducir un nombre a un objeto, es código correcto y legible. Esta es una idea que vale la pena que te lleves del módulo: muchos refactores no eliminan la complejidad, la reubican en el lugar donde duele menos.

Ejemplo trabajado: de la decisión repartida a la decisión concentrada

Vamos a hacer el refactor completo sobre un caso pequeño, para que la mecánica quede clara antes de aplicarla al caso grande de la lección 3. Usemos los exportadores de reportes de Boletia, que son más simples que los pagos: el organizador quiere CSV, administración quiere PDF y contabilidad quiere una hoja de cálculo.

Así está el código hoy. Dos archivos que saben lo mismo:

# Archivo: reports/generate.py  — ANTES
# Genera el reporte que pidió el usuario desde el panel.

def generate_report(report_kind, event_id, output_format):
    rows = fetch_rows(report_kind, event_id)

    # Aquí decidimos el formato... y de paso lo escribimos.
    if output_format == "csv":
        exporter = CsvExporter(delimiter=",", encoding="utf-8")
        return exporter.write(rows)
    elif output_format == "pdf":
        exporter = PdfExporter(template=settings.PDF_TEMPLATE, page_size="Letter")
        return exporter.write(rows)
    elif output_format == "xlsx":
        exporter = XlsxExporter(sheet_name="Asistentes")
        return exporter.write(rows)
    else:
        raise ValueError(f"Formato no soportado: {output_format}")
# Archivo: api/routes.py  — ANTES
# El endpoint que recibe la petición del panel.

def get_report(request):
    fmt = request.args.get("format", "csv")

    # La misma lista, otra vez, escrita de otra forma.
    if fmt not in ("csv", "pdf", "xlsx"):
        return response(400, {"error": f"Formato inválido: {fmt}"})

    # Y el content-type también depende del formato: tercera copia del conocimiento.
    content_type = {
        "csv": "text/csv",
        "pdf": "application/pdf",
        "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    }[fmt]

    body = generate_report(request.args["kind"], request.args["event_id"], fmt)
    return response(200, body, content_type=content_type)

Tres copias del conocimiento "qué formatos existen", en dos archivos. Vamos por partes.

Paso 1 — Deja claro el contrato. Antes de concentrar la decisión, todos los objetos posibles tienen que verse iguales desde afuera. Si uno se usa con .write(rows) y otro con .export(rows, path), no hay Factory que valga: quien la use tendría que preguntar qué le tocó, y estaríamos otra vez donde empezamos.

# Archivo: reports/exporter.py
from typing import Protocol

class ReportExporter(Protocol):
    """El contrato común: todo exportador sabe escribir filas y decir su content-type.

    Usamos Protocol (tipado estructural) en vez de una clase base abstracta porque
    en Python no hace falta heredar para cumplir un contrato: basta con tener
    los métodos. Es menos ceremonia y el verificador de tipos igual te avisa.
    """
    def write(self, rows: list[dict]) -> bytes: ...

    @property
    def content_type(self) -> str: ...

Nota lo que acabamos de hacer con el content_type: era conocimiento que vivía suelto en routes.py, en un diccionario que había que mantener sincronizado a mano con la lista de formatos. Lo movimos a donde pertenece —cada exportador sabe cuál es el suyo—. Cuando concentras una decisión, aparecen otros pedazos de conocimiento que estaban desperdigados y que quieren mudarse con ella. Es una señal de que vas bien.

Paso 2 — Escribe la factory. Un archivo, una función, todo el conocimiento junto:

# Archivo: reports/factory.py  — DESPUÉS

class UnknownFormatError(ValueError):
    """Error propio, para que quien llame pueda distinguirlo de cualquier otro ValueError."""
    def __init__(self, fmt: str):
        self.format = fmt
        super().__init__(
            f"Formato de reporte no soportado: {fmt!r}. "
            f"Disponibles: {', '.join(available_formats())}"
        )


# El registro: la única lista de la verdad sobre qué formatos existen.
# Guardamos la CLASE, no una instancia, porque cada exportador se construye
# con parámetros propios y no queremos crearlos todos si solo se usa uno.
_EXPORTERS: dict[str, type] = {
    "csv": CsvExporter,
    "pdf": PdfExporter,
    "xlsx": XlsxExporter,
}


def available_formats() -> list[str]:
    """Los formatos disponibles. Ahora se pueden PREGUNTAR, no hay que saberlos de memoria."""
    return sorted(_EXPORTERS)


def get_exporter(fmt: str) -> ReportExporter:
    """Devuelve el exportador para el formato pedido, ya construido y listo."""
    try:
        exporter_class = _EXPORTERS[fmt]
    except KeyError:
        raise UnknownFormatError(fmt) from None
    return exporter_class()

Paso 3 — Reemplaza cada copia por una llamada. El generador queda así:

# Archivo: reports/generate.py  — DESPUÉS

def generate_report(report_kind, event_id, output_format):
    rows = fetch_rows(report_kind, event_id)
    exporter = get_exporter(output_format)   # ya no sabe qué formatos existen
    return exporter.write(rows)

Y el endpoint:

# Archivo: api/routes.py  — DESPUÉS

def get_report(request):
    fmt = request.args.get("format", "csv")

    try:
        exporter = get_exporter(fmt)
    except UnknownFormatError as err:
        # El mensaje de error ya viene armado y con la lista real de formatos.
        return response(400, {"error": str(err)})

    rows = fetch_rows(request.args["kind"], request.args["event_id"])
    return response(200, exporter.write(rows), content_type=exporter.content_type)

Qué esperar de este refactor. Vamos por lo concreto primero y por lo interesante después.

Lo concreto: agregar un formato nuevo —digamos JSON, porque el organizador quiere consumir la lista desde su propio sistema— es ahora crear reports/json_exporter.py y agregar una línea a _EXPORTERS. Dos archivos, uno nuevo. Antes eran tres lugares en dos archivos existentes, más el diccionario de content-types que había que recordar. Y si te olvidas del registro, el sistema falla de inmediato y con un mensaje claro —UnknownFormatError— en vez de fallar en silencio.

Lo que hay que mirar con lupa: el endpoint dejó de tener su lista de validación. Antes había un if fmt not in ("csv", "pdf", "xlsx") que había que mantener sincronizado a mano con la otra lista, y esa sincronización manual entre dos listas es justamente el tipo de cosa que se desincroniza. Ahora la validación es un efecto secundario de pedir el objeto: si existe, te lo dan; si no, te dan un error que ya trae la lista real de opciones. Una sola fuente de la verdad, consultada en vez de duplicada.

Y ahora lo interesante, que es lo que casi nadie señala. Compara los dos except. Antes, el mensaje de error se escribía a mano en cada lugar y decía cosas distintas —"Formato no soportado" en un archivo, "Formato inválido" en el otro—. Después, hay un tipo de error propio con un mensaje que se arma solo a partir del registro. Los mensajes de error de un sistema son un espejo bastante fiel de cuántas copias tiene el conocimiento. Si en tu código el mismo problema se reporta con tres textos distintos, hay tres copias. Es un diagnóstico gratis que puedes correr sobre cualquier base de código: busca los mensajes parecidos.

Un matiz honesto para cerrar. Fíjate que en generate.py ahora hay que llamar a fetch_rows y a get_exporter por separado, y en routes.py también. Ese pequeño reacomodo es real: el refactor movió una responsabilidad y eso siempre reordena un poco a los vecinos. No es gratis, es barato. La diferencia importa: en el módulo 2 aprendiste que las abstracciones se pagan, y esta se paga con un archivo más y un salto más al leer. Lo que compra —agregar formatos sin tocar código existente y sin poder olvidarse de un lugar— vale más que eso cuando de verdad se agregan formatos. Si Boletia solo hubiera exportado CSV para siempre, este refactor habría sido una pérdida neta.

Diccionario o condicional: qué registro te conviene

Viste dos formas de escribir el mostrador: la cadena de if y el diccionario. Las dos son Factory; ninguna es más "correcta". Se eligen por criterio.

La cadena de if conviene cuando la construcción de cada opción es distinta y con detalle. Mira otra vez la de pagos: StripeProvider necesita una clave de API, MercadoPagoProvider un token, CashProvider la cadena de tiendas. Cada rama construye a su manera. Meter eso en un diccionario obligaría a guardar funciones anónimas o a inventar una firma común artificial, y quedaría menos legible que el if. Además el if permite condiciones que no son igualdad simple —un proveedor solo disponible en ciertos países, un modo de pruebas—.

def get_payment_provider(name: str) -> PaymentProvider:
    if name == "stripe":
        return StripeProvider(api_key=settings.STRIPE_KEY)
    if name == "mercadopago":
        return MercadoPagoProvider(token=settings.MP_TOKEN)
    if name == "cash":
        # El efectivo solo tiene sentido en México, donde está la red de tiendas.
        if settings.COUNTRY != "MX":
            raise ProviderNotAvailableError(name, settings.COUNTRY)
        return CashProvider(store_chain=settings.CASH_STORE_CHAIN)
    raise UnknownProviderError(name)

El diccionario conviene cuando todas las opciones se construyen igual, como los exportadores. Y trae dos ventajas que el if no tiene: puedes preguntarle qué opciones existen —eso fue available_formats()— y puedes recorrerlo. Esa capacidad de preguntar es más útil de lo que parece: sirve para armar el menú desplegable de la interfaz, para validar en la entrada, para el mensaje de error, y para escribir una prueba que verifique que todas las opciones registradas cumplen el contrato.

# Una prueba que solo es posible con el registro en diccionario:
# verifica que TODO lo registrado cumpla el contrato, sin listarlos a mano.

def test_every_registered_exporter_honors_the_contract():
    for fmt in available_formats():
        exporter = get_exporter(fmt)
        assert hasattr(exporter, "write")
        assert isinstance(exporter.content_type, str)

Esa prueba es un pequeño lujo: cuando alguien agregue el exportador de JSON y olvide implementar content_type, la prueba falla sin que nadie haya escrito una prueba nueva.

Una advertencia sobre el diccionario, porque es un tropiezo real. Si guardas instancias en vez de clases, las estás construyendo todas al importar el módulo, aunque el sistema use solo una:

# ⚠️ Ojo con esto: construye los tres exportadores al importar el archivo.
_EXPORTERS = {
    "csv": CsvExporter(),
    "pdf": PdfExporter(template=load_template()),   # ← lee un archivo del disco al importar
    "xlsx": XlsxExporter(),
}

Con exportadores baratos no pasa nada. Con proveedores de pago que abren una conexión o leen una credencial de un servicio de secretos, esto se convierte en un arranque lento y en un error confuso —falla al importar, no al usar—. Guarda la clase o una función que la construya, y deja que la factory instancie solo lo que se pide. Es la diferencia entre tener el estacionamiento lleno de autos y tener los autos con el motor encendido todo el día.

La forma simple contra las variantes del catálogo

Ahora la parte que conviene decir en voz alta, porque la confusión que produce es responsable de mucho código sobre-diseñado.

Cuando buscas "Factory pattern" encuentras al menos tres cosas distintas con nombres parecidos:

Factory function (o simple factory). Lo que acabas de escribir: una función que, dado un dato, devuelve el objeto correcto. Estrictamente hablando, esta no está en el catálogo original del libro de 1994 —y ese es justo el dato que explica todo lo demás—. No está porque en los lenguajes de aquel entonces no se podía escribir así de simple. Es, con enorme diferencia, la forma que más vas a usar y la que casi siempre es correcta.

Factory Method. La variante del catálogo. La idea: una clase base define un método que crea algo, y las subclases deciden qué crea ese método. La decisión no se toma con un if sino eligiendo qué subclase instancias.

# Factory Method — la variante del catálogo, con herencia.

class ReportJob:
    """Un trabajo de reporte: obtiene las filas y las exporta.

    El esqueleto está aquí; QUÉ exportador se usa lo decide cada subclase.
    """
    def run(self, event_id):
        rows = fetch_rows(event_id)
        exporter = self.create_exporter()   # ← el "factory method"
        return exporter.write(rows)

    def create_exporter(self) -> ReportExporter:
        raise NotImplementedError


class CsvReportJob(ReportJob):
    def create_exporter(self):
        return CsvExporter()


class PdfReportJob(ReportJob):
    def create_exporter(self):
        return PdfExporter(template=settings.PDF_TEMPLATE)

¿Cuándo se gana su lugar esto? Cuando ya tienes una jerarquía de clases por otras razones y la creación es una de las cosas que varía entre ellas. Si notas que en el ejemplo run() es idéntico en todas las subclases y lo único que cambia es qué se construye, entonces la jerarquía existe solo para elegir el exportador, y eso es exactamente el caso donde una factory function hace lo mismo con dos clases menos. Nota además que esto es primo hermano del Template Method que viste en el módulo 3 —mismo esqueleto, un paso distinto—; la diferencia es que aquí el paso que varía es una construcción.

Abstract Factory. La más ceremoniosa. Resuelve un problema específico y poco frecuente: cuando hay que construir familias de objetos que tienen que ser coherentes entre sí. El caso clásico son las interfaces gráficas —si estás en modo oscuro, quieres el botón oscuro y el menú oscuro y el campo de texto oscuro, y mezclarlos sería un error visible—.

¿Existe ese problema en Boletia? Con esfuerzo, sí: en el entorno de pruebas quieres el proveedor de pago falso y el canal de notificación falso y el exportador que escribe a memoria; nunca quieres el proveedor falso con el enviador de correos real, porque eso manda correos de verdad desde una prueba. Pero mira cómo se resuelve eso en Python sin Abstract Factory:

# El "conjunto coherente de dependencias" — sin patrón, solo un dataclass.

@dataclass
class Services:
    """Las piezas externas que la aplicación necesita, agrupadas.

    En producción se arman con las de verdad; en pruebas, con las falsas.
    Nada garantiza la coherencia por magia: la garantiza que las armas
    juntas en un solo lugar y no las mezclas.
    """
    payments: PaymentProvider
    notifications: NotificationChannel
    exporter: ReportExporter


def build_production_services() -> Services:
    return Services(
        payments=get_payment_provider(settings.DEFAULT_PROVIDER),
        notifications=EmailChannel(smtp_host=settings.SMTP_HOST),
        exporter=get_exporter("pdf"),
    )


def build_test_services() -> Services:
    return Services(
        payments=FakePaymentProvider(),
        notifications=RecordingChannel(),      # guarda los avisos en una lista
        exporter=InMemoryExporter(),
    )

Dos funciones y un dataclass. Eso es una Abstract Factory, sin las cuatro clases abstractas del diagrama. La diferencia entre esto y el patrón del libro no es conceptual: es que Python te deja escribir la idea directamente, mientras que un lenguaje sin funciones de primera clase necesita envolverla en clases para poder pasarla de un lado a otro.

La recomendación práctica, dicha sin rodeos: empieza siempre por la factory function. Es la que resuelve el problema real —la decisión repartida— con el mínimo de piezas. Si algún día te encuentras con que de verdad necesitas más ceremonia, el camino de la función simple hacia una variante mayor es fácil; el camino de vuelta, desde una jerarquía de clases hacia una función, casi nunca se recorre porque a nadie le da tiempo. Es el mismo argumento del módulo 2 con otro traje: es más barato agregar estructura después que quitarla después.

Qué gana exactamente el que la usa

Vale la pena ser preciso sobre el beneficio, porque "desacopla" es una palabra que se dice mucho y se define poco.

Lo que gana el código que usa una Factory es esto: deja de saber qué implementaciones existen. Nada más, y es suficiente. Vamos a verlo como una lista de cosas que el checkout sabía antes y ya no sabe:

  • Que existe una clase llamada StripeProvider. Ahora ni siquiera puede nombrarla.
  • Que se construye con api_key, y que esa clave sale de settings.STRIPE_KEY.
  • Que hay exactamente tres proveedores, y cuáles son.
  • Que el efectivo no cobra sino que genera una referencia.

Cada una de esas cuatro cosas era un motivo por el que alguien podía tener que abrir y modificar el archivo del checkout. Cuatro motivos menos para tocar el corazón del sistema. En el vocabulario que ya traes de las fundaciones: bajó el acoplamiento del checkout hacia el módulo de pagos, y de paso subió su cohesión —ahora el checkout habla solo de orquestar una compra, no de credenciales de terceros—.

Hay un segundo beneficio, menos citado y muy práctico: el punto único se vuelve un lugar donde poner cosas. Cuando toda la creación pasa por una función, esa función es el lugar natural para agregar un registro de auditoría, una métrica, una validación de configuración al arrancar, o una envoltura con reintentos —que es lo que hará el Decorator del módulo 5—. Sin ese punto único, cualquiera de esos agregados vuelve a ser una modificación en cuatro archivos.

Y un tercero, que es el que más se agradece en el día a día: puedes sustituir lo que devuelve la factory en las pruebas. Aunque eso se hace mucho mejor con inyección de dependencias —lección 6— y no reemplazando la factory por debajo, que es un truco frágil del que hablaremos ahí.

Ahora el costo, porque en esta guía nunca se presenta un patrón sin él. Una Factory cuesta:

  • Un archivo más y un salto más al leer. Quien lee get_payment_provider(order.provider) y quiere saber qué pasa en realidad, tiene que abrir otro archivo. Con tres proveedores es trivial; con veinte factories anidadas es el infierno de indirección del módulo 2.
  • Un poco de opacidad en el rastreo de errores. Cuando algo falla dentro de StripeProvider, el rastro pasa por la factory, y quien no conoce el sistema tarda un momento más en entender de dónde salió ese objeto.
  • La tentación de crecer. Las factories tienen una tendencia documentada a acumular lógica que no les toca: "ya que estamos aquí, aprovechemos para validar la orden", "ya que construimos el proveedor, registremos el intento". Una factory que hace más que decidir y construir dejó de ser una factory y empezó a ser un God object chiquito.

Errores comunes

La factory de una sola implementación (de criterio). Qué pasa: alguien escribe def get_notifier(): return EmailChannel(...). No recibe ningún dato, no decide nada, siempre devuelve lo mismo. Es un constructor con un nombre más largo y un archivo más. Peor: como se llama "factory", el siguiente que llegue va a suponer que hay varias implementaciones y va a perder tiempo buscándolas. Por qué pasa: es abstracción especulativa pura —"algún día habrá otro canal"—, el error exacto que el módulo 2 desarma en su lección 4. Cómo detectarlo: la prueba es de un segundo. ¿Tu factory tiene una rama sola, o ninguna? ¿Recibe un dato para decidir? Si no decide nada, no es una factory. Cómo corregirlo: llama al constructor directo y ya. Cuando aparezca la segunda implementación —de verdad, no imaginada—, extraer la factory te va a costar diez minutos, y para entonces vas a saber cuál es el eje de variación real.

Que la factory devuelva cosas que no cumplen el mismo contrato (de implementación). Qué pasa: la factory devuelve StripeProvider y MercadoPagoProvider, que tienen charge(), pero también devuelve CashProvider, que en vez de cobrar genera una referencia y no tiene refund(). Entonces quien la usa vuelve a preguntar: if isinstance(provider, CashProvider): .... La decisión regresó, disfrazada. Por qué pasa: casi siempre porque el contrato se diseñó mirando dos implementaciones y la tercera no encajaba, y en vez de revisar el contrato se metió el caso raro a la fuerza. Cómo detectarlo: busca isinstance o comprobaciones de tipo después de una llamada a una factory. Es el olor más fiable de este error. Cómo corregirlo: revisa el contrato hasta que las tres implementaciones lo cumplan de verdad. En Boletia, eso significa que charge() no devuelva "el cobro" sino un PaymentResult que puede estar succeeded o pending con una referencia; y que refund() exista en las tres, aunque en efectivo lo que haga sea agendar una devolución manual. Si de plano una implementación no puede cumplir el contrato, quizá no pertenece a esa familia.

Confundir la factory con el lugar donde se decide cuál se usa (conceptual). Qué pasa: alguien escribe la factory, la llama desde el checkout con order.provider, y da el trabajo por terminado. Pero la elección de qué proveedor va en order.provider se sigue tomando en tres lugares distintos —el formulario web, la app móvil, el proceso de renovaciones automáticas—, cada uno con su propia lógica de "cuál conviene". La factory concentró cómo se construye, no cómo se elige. Por qué pasa: son dos decisiones parecidas y en los ejemplos de libro coinciden, porque la llave viene de un solo lugar. En sistemas reales casi nunca coinciden. Cómo detectarlo: pregúntate quién decide el valor de la llave. Si la respuesta son varios lugares con reglas propias, tienes un segundo problema que la factory no toca. Cómo corregirlo: separa las dos preguntas explícitamente. Una función choose_provider(customer, order, country) que decida cuál conviene, y la factory que construya el que se eligió. Y sé honesto sobre si esa segunda función hace falta: a veces la elección es simplemente "lo que el usuario apretó en la pantalla", y ahí no hay nada que concentrar.

Ejercicios

Ejercicio 1 — Decide si estos tres casos piden una Factory. Para cada uno, responde sí o no y justifica con el criterio del módulo 2 —la regla de tres, el costo de la indirección, cuántas implementaciones existen hoy—.

(a) Boletia tiene tres canales de notificación (EmailChannel, SmsChannel, PushChannel) y el notifier.py los construye a los tres con un if sobre lo que el cliente tenga disponible. Es el único lugar del sistema donde se construyen. (b) Boletia guarda archivos —los PDF de los boletos— y hoy los escribe en el disco local. En el plan del próximo trimestre está moverlos a almacenamiento en la nube. Hay un solo lugar que escribe archivos. (c) Boletia calcula impuestos y hoy hay dos casos: México (16% de IVA) y "sin impuesto" para eventos gratuitos. Se usa en el cálculo del total y en la generación de la factura.

Ver solución

(a) No, todavía no. Hay tres implementaciones —la regla de tres se cumple— pero la decisión está en un solo lugar, y ese lugar es precisamente el que tiene que tomarla. Una factory aquí agregaría un archivo y un salto sin quitar ninguna duplicación, porque no hay duplicación que quitar. Fíjate en la distinción, que es la más importante del ejercicio: la regla de tres habla de cuántas implementaciones hay; el problema que resuelve la Factory es cuántos lugares deciden. Tres implementaciones y un solo lugar de decisión no piden Factory. (Nota: en el proyecto de la lección 8 vas a mirar este caso otra vez, y ahí vas a descubrir que el notifier.py de verdad no es el único lugar. La respuesta cambia con el dato.)

(b) No. Hay una implementación. La segunda está planeada, que es distinto de existir. Este es el caso literal de YAGNI y de la abstracción prematura del módulo 2: si construyes hoy la factory con una sola rama, estás fijando el eje de variación con la información de hoy, y lo más probable es que cuando llegue el almacenamiento en la nube descubras que la diferencia real no era "dónde se guarda" sino "cómo se generan las URLs firmadas" o "qué pasa con los archivos grandes". Deja el código directo y extrae la abstracción el día que exista la segunda implementación, con la información completa. Ese día te va a costar veinte minutos.

(c) Probablemente no, y aquí lo interesante es por qué. Hay dos implementaciones, no tres —regla de tres, no se cumple— y además el "sin impuesto" no es realmente otra implementación: es la misma con tasa cero. La estructura que emerge de verdad no es una jerarquía sino un dato: tax_rate. Antes de aplicar cualquier patrón de creación, revisa si lo que varía es comportamiento o es un valor. Si es un valor, la respuesta es una tabla de configuración, no una familia de clases. Este error —convertir en clases lo que era una tabla— es de los más comunes y de los más caros, y lo vas a ver otra vez en el módulo 7.

Por qué funciona: los tres casos parecen candidatos a Factory a primera vista, y ninguno lo es. Ese es el punto. El módulo 2 no fue un paréntesis: es el filtro que se aplica antes de cada patrón de aquí en adelante.

Ejercicio 2 — Escribe la factory de canales de notificación. Boletia necesita que un NotificationChannel se elija por su nombre ("email", "sms", "push"). Cada canal se construye distinto: el de correo necesita el servidor SMTP, el de SMS necesita una clave de API y un remitente, el de push necesita las credenciales de la app. Escribe la factory, decide si usas condicional o diccionario, y justifica tu elección. Incluye el manejo del nombre desconocido.

Ver solución
# Archivo: notifications/factory.py

class UnknownChannelError(ValueError):
    def __init__(self, name: str):
        self.name = name
        super().__init__(
            f"Canal de notificación desconocido: {name!r}. "
            f"Disponibles: {', '.join(available_channels())}"
        )


def available_channels() -> list[str]:
    return ["email", "push", "sms"]


def get_notification_channel(name: str) -> NotificationChannel:
    """Construye el canal pedido. Único lugar que sabe qué canales existen."""
    if name == "email":
        return EmailChannel(
            smtp_host=settings.SMTP_HOST,
            smtp_user=settings.SMTP_USER,
            smtp_password=settings.SMTP_PASSWORD,
        )
    if name == "sms":
        # El remitente es un dato regulado: en México debe ser un alfanumérico
        # registrado ante el operador, por eso viene de configuración y no fijo.
        return SmsChannel(api_key=settings.SMS_API_KEY, sender=settings.SMS_SENDER)
    if name == "push":
        return PushChannel(app_credentials=settings.PUSH_CREDENTIALS)
    raise UnknownChannelError(name)

Por qué condicional y no diccionario: porque cada canal se construye con parámetros distintos. Para meterlos en un diccionario habría que guardar funciones sin argumentos —{"email": lambda: EmailChannel(...)}— y eso no gana nada en legibilidad; solo cambia un if legible por una sintaxis más densa. El diccionario paga cuando las opciones se construyen igual, y aquí no es el caso.

El detalle que separa una buena solución de una regular: available_channels() está escrita a mano, y eso es una segunda copia del conocimiento —justo lo que vinimos a eliminar—. Si alguien agrega un canal al if y olvida la lista, el mensaje de error miente. Hay dos salidas honestas. La primera es aceptar el diccionario después de todo, con la construcción envuelta:

_CHANNELS = {
    "email": lambda: EmailChannel(smtp_host=settings.SMTP_HOST, ...),
    "sms": lambda: SmsChannel(api_key=settings.SMS_API_KEY, sender=settings.SMS_SENDER),
    "push": lambda: PushChannel(app_credentials=settings.PUSH_CREDENTIALS),
}

def available_channels() -> list[str]:
    return sorted(_CHANNELS)   # ahora sí, una sola fuente de la verdad

def get_notification_channel(name: str) -> NotificationChannel:
    try:
        build = _CHANNELS[name]
    except KeyError:
        raise UnknownChannelError(name) from None
    return build()

La segunda es dejar el if y escribir una prueba que recorra available_channels() y verifique que cada uno se construye sin explotar. Las dos son razonables; la primera evita el problema, la segunda lo detecta. Lo que no es razonable es dejar las dos listas sin nada que las mantenga sincronizadas.

Si llegaste al if con la lista a mano y no viste el problema, no pasa nada —es exactamente el tropiezo que este ejercicio buscaba provocar—. Lo importante es la señal: cada vez que escribas una factory, pregúntate si quedó alguna otra lista de lo mismo en algún lado.

Ejercicio 3 — Encuentra la factory disfrazada. Este código de Boletia no menciona la palabra "factory" en ningún lado. ¿Hay una? ¿Dónde? ¿Está bien resuelta o le falta algo?

# Archivo: pricing/calculator.py

def price_for(ticket, purchased_at):
    rule = RULES_BY_KIND.get(ticket.kind, GeneralRule())
    return rule.apply(ticket.base_price, purchased_at)


RULES_BY_KIND = {
    "general": GeneralRule(),
    "vip": VipRule(surcharge=0.30),
    "early_bird": EarlyBirdRule(cutoff=date(2026, 3, 1), discount=0.20),
    "courtesy": CourtesyRule(),
}
Ver solución

Sí, hay una factory, y está en la línea RULES_BY_KIND.get(...). Tiene las cuatro piezas: el contrato (PricingRule, todos saben apply), las implementaciones (las cuatro reglas del módulo 3), la llave (ticket.kind) y la traducción llave → objeto (el .get sobre el diccionario). Que no se llame get_pricing_rule no cambia lo que es. Esto es lo que la lección 5 del módulo 1 te enseñó a hacer: reconocer el patrón en código que nadie etiquetó.

Ahora las tres cosas que le faltan, en orden de gravedad:

Primera y más seria: el .get con valor por defecto esconde los errores. Si mañana alguien inserta en la base un boleto con kind = "vp" —un dedazo—, este código no falla: le aplica silenciosamente la regla general y cobra el precio equivocado. Un boleto VIP vendido a precio general es un problema de dinero, no de código, y nadie se entera hasta que alguien revise los números. Compáralo con la factory de la lección: raise UnknownProviderError(name). Una factory debe fallar cuando le piden algo que no conoce; el default silencioso es cómodo hoy y caro después. Si el default es una decisión deliberada de negocio —"cualquier tipo desconocido se cobra como general"— entonces al menos debería registrarse en el log, y estar comentado como decisión, no como descuido.

Segunda: guarda instancias, no clases o constructores. Las cuatro reglas se construyen al importar el módulo. Con estas reglas no hay drama porque son objetos baratos y sin estado. Pero hay una trampa escondida en la tercera línea: EarlyBirdRule(cutoff=date(2026, 3, 1)) fija la fecha de corte en el código, al importar. Eso significa que cada evento con una fecha de corte distinta no cabe en esta estructura, y que cambiar la fecha exige un despliegue. El objeto compartido y construido una sola vez parece eficiencia; aquí es una limitación de negocio disfrazada de detalle técnico. Si las reglas tuvieran estado mutable, además, todas las órdenes del sistema estarían compartiendo el mismo objeto —con las consecuencias que vas a ver en la lección 5—.

Tercera, la más leve: no se puede preguntar bien qué tipos existen. Se puede hacer RULES_BY_KIND.keys(), sí, pero como el diccionario es público y mutable, cualquier parte del sistema puede agregarle o quitarle entradas. Un _RULES_BY_KIND privado con una función available_kinds() es más honesto sobre quién manda.

Por qué funciona: el 90% de las factories que vas a encontrar en código real están así, disfrazadas y a medio hacer. No hace falta reescribirlas; hace falta verlas y saber qué les falta. Con esas dos capacidades ya puedes escribir un comentario de revisión de dos líneas que valga más que un refactor de un día: "esto es una factory y me gusta; lo que me preocupa es el default silencioso —un kind con dedazo se cobra como general y nadie se entera—. ¿Lo hacemos fallar?".

Resumen y siguiente paso

En esta lección definiste Factory en la única forma que vas a necesitar la mayor parte del tiempo: una función que, dado un dato, devuelve el objeto correcto. Desarmaste su anatomía en cuatro piezas —el contrato, las implementaciones, la llave y la función que traduce— y viste que sin contrato no hay Factory posible, porque quien recibe el objeto no sabría qué hacer con él.

Hiciste el refactor completo sobre los exportadores de reportes de Boletia y mediste el resultado: tres copias del conocimiento en dos archivos, convertidas en un registro único que además se puede consultar. Viste que el if no desapareció —se mudó al lugar donde duele menos— y que cuando concentras una decisión, otros pedazos de conocimiento sueltos —el content_type, los mensajes de error, la lista de validación— quieren mudarse con ella. Aprendiste a elegir entre condicional y diccionario según cómo se construyan las opciones, y a guardar clases o constructores en vez de instancias.

Y viste la diferencia entre la factory function simple —que ni siquiera está en el catálogo original, porque en 1994 no se podía escribir así— y las variantes ceremoniales: Factory Method, que se gana su lugar solo si ya hay una jerarquía por otras razones, y Abstract Factory, que en Python se resuelve con un dataclass y dos funciones. La recomendación de bolsillo: empieza siempre por la función; agregar estructura después es barato, quitarla después casi nunca ocurre.

Antes de avanzar deberías poder: escribir una factory function con manejo de la llave desconocida; explicar en una frase qué gana el código que la usa; reconocer una factory disfrazada en código que nadie etiquetó; y —sobre todo— decir por qué tres implementaciones en un solo lugar de decisión no piden una Factory.

La lección 3 lleva esto al caso grande. Vamos a tomar los cuatro archivos de Boletia que viste en la lección 1, a centralizar la elección del PaymentProvider, y a medir con precisión qué cambia cuando entra PayPal. Y vamos a ver la parte que los libros separan artificialmente: cómo esta Factory se combina con la Strategy que armaste en el módulo 3, porque en código real los patrones nunca vienen de a uno.

Recursos