Módulo 4: Patrones para crear objetos

4. Builder: armar algo complicado por partes

Descripción

Al terminar esta lección vas a saber reconocer el problema del constructor de diez parámetros —esa llamada donde cinco None seguidos deciden el comportamiento del sistema y nadie recuerda cuál es cuál— y vas a tener tres soluciones distintas para él, ordenadas de menor a mayor costo. La tercera es el Builder. Las dos primeras son argumentos por nombre y un dataclass, y son las que vas a usar casi siempre. Vas a salir sabiendo dibujar la frontera exacta: el Builder se gana su lugar cuando la construcción tiene pasos con lógica propia o requiere validar el conjunto al final; si solo tiene muchos campos, Python ya lo resolvió.

Esto importa porque el Builder es el patrón de este módulo con peor relación entre lo mucho que se enseña y lo poco que se necesita en Python. En Java tiene todo el sentido del mundo —el lenguaje no tiene argumentos por nombre, así que armar un objeto con quince campos opcionales de verdad requiere un aparato—. En Python, ese aparato duplica lo que el lenguaje ya trae, y quien lo escribe termina con dos clases donde bastaba una. Esa es exactamente la forma de sobre-ingeniería que el módulo 2 te enseñó a detectar, y aquí la vas a ver en su versión más tentadora: la que se siente profesional.

Conexión con el módulo: las lecciones 2 y 3 resolvieron la primera pregunta —qué construir, cuando hay varias opciones—. Esta resuelve la segunda: cómo construirlo, cuando la opción es una sola pero armarla es complicado. Fíjate en que son problemas independientes: la Order de Boletia siempre es una Order, no hay familia de implementaciones, no hay nada que elegir. Una Factory aquí no arreglaría nada, y meterla es uno de los errores comunes de la lección 1. Después de esta, la lección 5 pasa al sospechoso —Singleton— y la 6 a la tercera pregunta, quién construye. Y la lección 7 va a volver sobre esta con la vacuna del módulo, porque el Builder es el patrón que más veces se aplica sin hacer falta.

Armar una hamburguesa en el mostrador

Piensa en cómo se pide una hamburguesa en un local donde todo se arma a gusto. Nadie dice: "quiero una hamburguesa doble, con queso, sin cebolla, con tocino, sin jitomate, término tres cuartos, pan de papa, papas grandes, sin refresco" de un tirón y en ese orden exacto. Sería un desastre: quien escucha tendría que retener nueve datos en el aire y una equivocación de orden cambia el pedido.

Lo que pasa de verdad es una conversación por pasos. "Doble." La persona anota. "Con queso y tocino." Anota. "Sin cebolla." Anota. "Papas grandes." Anota. Y al final: "¿Algo más? Listo, son doscientos veinte."

Fíjate en las cuatro cosas que hace ese mostrador, porque son exactamente las cuatro que hace un Builder.

Recibe la información por partes, en el orden que le convenga a quien pide. Puedes decir primero el tamaño o primero los ingredientes; da igual.

Cada parte se identifica sola. Cuando dices "sin cebolla", nadie se confunde con "sin jitomate". No hay una posición que recordar. Compara eso con un formulario donde tuvieras que llenar nueve casillas en orden fijo.

Acumula un estado intermedio que todavía no es una hamburguesa. Mientras hablas, lo que existe es una comanda: un papel con anotaciones. La hamburguesa no existe hasta el final. Esa distinción entre "las instrucciones" y "la cosa" es el corazón del patrón.

Al final valida y entrega. El "¿algo más?" es el momento de cierre. Si pediste una hamburguesa vegetariana con tocino, ese es el momento en que alguien te lo dice. Antes no podía saberlo: la contradicción solo se ve cuando el pedido está completo.

Ese último punto es la parte que la gente no ve del Builder y la que decide si vale la pena. Un Builder existe para poder validar el conjunto, no solo para escribir bonito. Si tu objeto no tiene reglas que involucren varios campos a la vez, probablemente no necesites un Builder.

El constructor de diez parámetros

Vamos al caso de Boletia. Así se construye una Order hoy:

# Archivo: models/order.py

class Order:
    def __init__(
        self,
        customer_id,
        ticket_ids,
        total,
        discount_code,
        discount_amount,
        billing_name,
        billing_tax_id,
        billing_address,
        note,
        currency,
        created_at,
    ):
        self.customer_id = customer_id
        self.ticket_ids = ticket_ids
        self.total = total
        self.discount_code = discount_code
        self.discount_amount = discount_amount
        self.billing_name = billing_name
        self.billing_tax_id = billing_tax_id
        self.billing_address = billing_address
        self.note = note
        self.currency = currency
        self.created_at = created_at

Once parámetros. Y así se ve una llamada real en api/routes.py:

order = Order(
    142,
    [4471, 4472],
    1740.0,
    None,
    0.0,
    None,
    None,
    None,
    None,
    "MXN",
    datetime.now(),
)

Lee esa llamada e intenta responder, sin volver a mirar la definición: ¿qué es el 1740.0? ¿El cuarto None es la dirección de facturación o la nota? Si alguien intercambia por error billing_tax_id y billing_address —los dos son texto, los dos pueden ser None—, nada falla: la factura sale con el RFC en el campo de dirección y alguien lo descubre en un mes.

Este es el problema, y tiene tres partes que conviene separar:

  1. Ilegible en el punto de llamada. Hay que abrir otro archivo para entender qué se está pasando.
  2. Frágil ante el orden. Dos parámetros del mismo tipo intercambiados no producen ningún error.
  3. Los opcionales contaminan. Cinco None seguidos que están ahí solo para llegar a currency.

Ahora bien —y esto es lo importante— hay tres soluciones para esto, y la mayoría de los materiales salta directo a la tercera.

Solución 1: argumentos por nombre y valores por defecto

Python resuelve dos de los tres problemas con sintaxis que ya tienes.

# Archivo: models/order.py

class Order:
    def __init__(
        self,
        customer_id: int,
        ticket_ids: list[int],
        total: float,
        *,                              # ← todo lo que sigue es SOLO por nombre
        currency: str = "MXN",
        discount_code: str | None = None,
        discount_amount: float = 0.0,
        billing_name: str | None = None,
        billing_tax_id: str | None = None,
        billing_address: str | None = None,
        note: str | None = None,
        created_at: datetime | None = None,
    ):
        ...

Ese asterisco solo es la pieza que casi nadie usa y que arregla el problema de fondo. Significa: de aquí en adelante, ningún argumento se puede pasar por posición. Quien llame tiene que nombrarlo.

# Ahora la llamada dice qué es cada cosa, y solo menciona lo que no es el default.
order = Order(
    customer_id=142,
    ticket_ids=[4471, 4472],
    total=1740.0,
)

# Y una con factura, sin ningún None de relleno:
order = Order(
    customer_id=142,
    ticket_ids=[4471, 4472],
    total=1740.0,
    billing_name="Ana Ruiz",
    billing_tax_id="RUAN850312AB1",
    billing_address="Av. Reforma 222, CDMX",
)

Compara esa segunda llamada con la de once posiciones. Es la misma información, y ahora se lee sola. Los tres problemas: el primero resuelto —cada valor dice qué es—, el segundo resuelto —el orden ya no importa y no se pueden intercambiar dos textos por accidente—, el tercero resuelto —los opcionales que no usas simplemente no se escriben—.

En muchísimos casos, aquí termina el problema. No hace falta ningún patrón. Si sales de esta lección aplicando el * en tus constructores con más de tres o cuatro campos, ya ganaste más de lo que gana quien memoriza el diagrama del Builder.

Solución 2: un dataclass

Si el objeto es principalmente datos —guarda campos, no orquesta comportamiento—, Python trae algo aún más directo:

# Archivo: models/order.py

from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class Order:
    """Una orden de compra de Boletia.

    Como dataclass, Python nos genera solo el __init__, el __repr__ y el __eq__.
    Nos ahorramos las once líneas de self.x = x, que eran ruido puro.
    """
    customer_id: int
    ticket_ids: list[int]
    total: float
    currency: str = "MXN"
    discount_code: str | None = None
    discount_amount: float = 0.0
    billing_name: str | None = None
    billing_tax_id: str | None = None
    billing_address: str | None = None
    note: str | None = None
    # Ojo: para valores mutables o calculados se usa field(default_factory=...),
    # nunca un default directo, porque se compartiría entre todas las instancias.
    created_at: datetime = field(default_factory=datetime.now)

Tres regalos que vienen incluidos y que valen la pena nombrar, porque son parte del argumento de "no necesitas un Builder":

El __repr__. Cuando imprimes una orden en un log o en la consola, ves Order(customer_id=142, ticket_ids=[4471, 4472], total=1740.0, currency='MXN', ...) en vez de <Order object at 0x7f8b1c>. Depurar con eso es otra vida.

El __eq__. Dos órdenes con los mismos datos son iguales. Eso hace que las pruebas se escriban en una línea: assert order == expected, en vez de comparar campo por campo.

Validación en un solo lugar, si la necesitas. El método __post_init__ corre después de construir y es donde van las reglas que involucran varios campos:

    def __post_init__(self):
        # Reglas que involucran a más de un campo. Aquí, y en un solo lugar.
        if self.discount_code and self.discount_amount <= 0:
            raise ValueError("Un código de descuento debe traer un monto mayor a cero")
        if self.billing_tax_id and not self.billing_address:
            # Si el cliente pide factura, el SAT exige domicilio fiscal.
            raise ValueError("La facturación requiere domicilio fiscal")
        if not self.ticket_ids:
            raise ValueError("Una orden sin boletos no tiene sentido")

Mira bien ese __post_init__, porque acaba de hacer una de las dos cosas por las que existía el Builder: validar el conjunto al final. Y lo hizo en cinco líneas, dentro de la misma clase, sin agregar ningún objeto nuevo al sistema.

Con las soluciones 1 y 2 —el *, los defaults y el dataclass con __post_init__— ya cubriste el 85% de los casos donde alguien escribiría un Builder. Vale la pena decirlo antes de enseñar el patrón, porque después de enseñarlo la tentación de usarlo aumenta.

Solución 3: el Builder, y cuándo se gana su lugar

Entonces, ¿cuándo hace falta de verdad? Cuando aparece al menos una de estas tres cosas:

Uno: la construcción tiene pasos con lógica propia. No es "asignar un campo", es "hacer algo que modifica el estado acumulado". Agregar un boleto a una orden no es guardar un dato: es buscar su precio, aplicar su regla, recalcular el total y verificar que siga habiendo disponibilidad. Eso no cabe en un parámetro del constructor.

Dos: la misma información llega en momentos distintos. El carrito de Boletia es el caso: el cliente agrega un boleto, navega, agrega otro, aplica un cupón, quince minutos después pide factura. La orden se va formando a lo largo de una sesión. Un constructor exige tener todo junto en un instante.

Tres: el objeto final debe ser inmutable y consistente. Si quieres que una Order ya creada no se pueda modificar —porque es un hecho contable— pero necesitas armarla por partes, necesitas algo mutable durante el armado y algo inmutable al final. Dos objetos, dos ciclos de vida distintos.

Boletia cumple las tres. Vamos a escribirlo.

Ejemplo trabajado: el OrderBuilder del carrito de Boletia

# Archivo: checkout/order_builder.py

class OrderBuilder:
    """Acumula lo que va a ser una orden, mientras el cliente arma su compra.

    Es la 'comanda' del mostrador: no es una Order todavía. Va guardando
    decisiones y recalculando el total a medida que llegan los boletos.
    """

    def __init__(self, customer_id: int, event):
        self._customer_id = customer_id
        self._event = event
        self._tickets: list[Ticket] = []
        self._subtotal = 0.0
        self._discount_code: str | None = None
        self._discount_amount = 0.0
        self._billing: BillingInfo | None = None
        self._note: str | None = None

    # ── Los pasos ────────────────────────────────────────────────────────

    def add_ticket(self, ticket: Ticket) -> "OrderBuilder":
        """Agrega un boleto y recalcula. Esto NO es asignar un campo.

        Aquí se junta con el módulo 3 y con la lección anterior: pedimos
        la regla de precio a su factory, y la regla calcula lo que toca.
        """
        if ticket.status != "available":
            raise TicketNotAvailableError(ticket.id)

        rule = get_pricing_rule(ticket.kind, self._event)
        price = rule.apply(ticket.base_price, purchased_at=datetime.now())

        self._tickets.append(ticket)
        self._subtotal += price
        return self          # devolvemos self para poder encadenar

    def with_discount(self, code: str) -> "OrderBuilder":
        """Aplica un cupón. Depende del subtotal, así que el ORDEN importa:
        tiene que llamarse después de agregar los boletos."""
        coupon = find_coupon(code)
        if coupon is None:
            raise UnknownCouponError(code)
        if self._subtotal < coupon.minimum_purchase:
            raise CouponNotApplicableError(code, coupon.minimum_purchase)

        self._discount_code = code
        self._discount_amount = coupon.discount_for(self._subtotal)
        return self

    def with_billing(self, name: str, tax_id: str, address: str) -> "OrderBuilder":
        self._billing = BillingInfo(name=name, tax_id=tax_id, address=address)
        return self

    def with_note(self, note: str) -> "OrderBuilder":
        self._note = note
        return self

    # ── El cierre ────────────────────────────────────────────────────────

    def build(self) -> Order:
        """El '¿algo más?' del mostrador: valida el CONJUNTO y entrega la orden.

        Estas reglas no se podían verificar en ningún paso individual,
        porque cada una involucra a varios.
        """
        if not self._tickets:
            raise EmptyOrderError("Una orden necesita al menos un boleto")

        # Las cortesías no se pagan, y por lo tanto no se combinan con cupones.
        # Esta regla necesita ver los boletos Y el descuento al mismo tiempo.
        if self._discount_code and all(t.kind == "courtesy" for t in self._tickets):
            raise InvalidOrderError("Una orden de solo cortesías no admite cupón")

        # El aforo se verifica al final, con el total de boletos de esta orden,
        # no de a uno: si no, dos personas podrían pasarse del límite juntas.
        if len(self._tickets) > self._event.max_tickets_per_order:
            raise TooManyTicketsError(self._event.max_tickets_per_order)

        return Order(
            customer_id=self._customer_id,
            ticket_ids=[t.id for t in self._tickets],
            total=round(self._subtotal - self._discount_amount, 2),
            discount_code=self._discount_code,
            discount_amount=self._discount_amount,
            billing_name=self._billing.name if self._billing else None,
            billing_tax_id=self._billing.tax_id if self._billing else None,
            billing_address=self._billing.address if self._billing else None,
            note=self._note,
        )

Y así se usa:

# Archivo: api/routes.py

order = (
    OrderBuilder(customer_id=142, event=event)
    .add_ticket(vip_ticket)
    .add_ticket(general_ticket)
    .with_discount("VERANO26")
    .with_billing(name="Ana Ruiz", tax_id="RUAN850312AB1", address="Av. Reforma 222")
    .build()
)

Esa forma encadenada —donde cada paso devuelve self y se puede seguir escribiendo— se llama interfaz fluida, y es lo que hace que la construcción se lea como una frase. No es obligatoria: si prefieres, cada llamada puede ir en su propia línea sin encadenar y el patrón funciona igual. La usamos porque en el caso del carrito el orden importa —el cupón depende del subtotal— y encadenar hace visible esa secuencia.

Qué esperar de esto. Vamos por lo que se ganó, por lo que costó y por lo que no se ganó.

Lo que se ganó, primero: las tres validaciones del build() no se podían escribir en ningún otro lado. Piénsalo. La regla de "una orden de solo cortesías no admite cupón" involucra la lista completa de boletos y el código de descuento. En el __post_init__ del dataclass sí cabría —el objeto ya tiene todo— pero entonces el error aparecería después de haber construido la orden, y en el carrito el cliente necesita enterarse antes, cuando aplica el cupón. El Builder da un lugar donde el estado está a medias y donde se puede razonar sobre él.

Segundo: add_ticket es un paso con lógica. Consulta el estado del boleto, pide su regla de precio, calcula y acumula. Esa no es una asignación disfrazada, y no hay forma de meterla en un parámetro del constructor. Este es el criterio más claro que te puedes llevar: si tus "pasos" son solo self._x = x, no necesitas un Builder; si hacen trabajo, quizá sí.

Tercero, y es el que se nota en las pruebas: el builder es un lugar excelente para armar datos de prueba. OrderBuilder(customer_id=1, event=e).add_ticket(t).build() es más legible que construir una Order con once campos, y sobre todo no se rompe cuando la Order gana un campo nuevo.

Ahora lo que costó, sin maquillaje:

  • Una clase más, con casi tantos atributos como la Order. Hay duplicación de estructura entre el builder y lo que construye, y es real: si agregas un campo a Order, casi siempre tienes que tocar el builder también.
  • Un orden implícito. with_discount solo funciona bien después de los add_ticket, y nada en el código lo obliga. Un usuario del builder puede llamarlos al revés y obtener un descuento calculado sobre cero. Este es el problema de fondo de los builders fluidos, y no tiene solución elegante —se documenta y se prueba, o se rediseña para que el cupón se aplique en el build()—.
  • Un objeto que puede quedar a medias. Alguien puede olvidar llamar a build() y quedarse con un OrderBuilder donde esperaba una Order. Con tipos declarados el verificador avisa; sin ellos, el error llega en tiempo de ejecución y es confuso.

Y lo que no se ganó, que es lo que quiero que quede grabado: si Boletia armara la orden de un solo golpe —todos los datos llegan juntos en la petición HTTP y no hay carrito—, este builder no habría agregado nada que el dataclass con __post_init__ no diera. Sería una clase de sesenta líneas duplicando una de quince.

El Builder que casi nunca vale la pena, y por qué

Vale la pena mostrar el anti-caso, porque es el que más se escribe.

# ⚠️ Esto es un Builder que no se gana su lugar.

class TicketBuilder:
    def __init__(self):
        self._event_id = None
        self._kind = "general"
        self._base_price = 0.0
        self._seat = None

    def with_event(self, event_id):
        self._event_id = event_id
        return self

    def with_kind(self, kind):
        self._kind = kind
        return self

    def with_price(self, price):
        self._base_price = price
        return self

    def with_seat(self, seat):
        self._seat = seat
        return self

    def build(self):
        return Ticket(
            event_id=self._event_id,
            kind=self._kind,
            base_price=self._base_price,
            seat=self._seat,
        )

Se usa así:

ticket = TicketBuilder().with_event(9).with_kind("vip").with_price(1200.0).build()

Y esto hace exactamente lo mismo, sin la clase:

ticket = Ticket(event_id=9, kind="vip", base_price=1200.0)

Cuenta lo que costó el builder: treinta líneas, una clase más, un archivo más, un concepto más que explicarle a quien llegue. ¿Qué compró? Nada. Cada with_x es una asignación, no hay validación de conjunto, no hay pasos con lógica, y el objeto se construye de un golpe de todos modos.

La prueba de un segundo, que puedes correr sobre cualquier builder que veas: lee los métodos. Si todos son self._x = x; return self, y el build() solo copia campos, ese builder es un constructor con más pasos. Bórralo.

Esta es la forma que toma la sobre-ingeniería en el mundo real, y por eso quiero insistir. Nadie escribe un builder innecesario por descuido. Se escribe porque se siente ordenado, porque se ve profesional, porque "así lo hacen en los ejemplos". Es el síndrome del martillo nuevo del módulo 1, con una herramienta que además tiene buena prensa.

La tabla de decisión

Guárdate esto, porque resume la lección en una pantalla:

Tu situaciónLo que necesitas
Dos o tres campos, todos obligatoriosUn constructor normal. Nada más
Muchos campos, varios opcionales, todos llegan juntosdataclass con defaults y * para forzar el nombre
Lo anterior, más reglas que involucran varios camposLo mismo, con __post_init__
Los datos llegan en momentos distintos (un carrito, un formulario por pasos)Builder
Agregar cada pieza requiere lógica: calcular, consultar, verificarBuilder
El objeto final debe ser inmutable pero armarse por partesBuilder
Tienes que elegir entre varios tipos de objetoEso no es Builder: es Factory (lecciones 2 y 3)

Y la pregunta de bolsillo, para cuando dudes: ¿mis pasos hacen algo, o solo guardan? Si solo guardan, Python ya lo resolvió.

Errores comunes

Escribir un Builder para lo que un dataclass resuelve (de criterio). Qué pasa: alguien tiene una clase con ocho campos, siente —con razón— que el constructor es incómodo, y escribe un builder de setenta líneas con ocho métodos with_x. El código de llamada mejora, sí, pero mejoraría igual con dataclass y argumentos por nombre, sin una clase extra. Y ahora cada campo nuevo hay que agregarlo en dos lugares. Por qué pasa: el Builder es el patrón que mejor se ve en los ejemplos, y la mayoría de esos ejemplos están escritos en lenguajes sin argumentos por nombre, donde el patrón sí hace falta. Cómo detectarlo: la prueba de un segundo —¿todos los métodos son asignaciones?—. Y una segunda señal: si tu builder tiene exactamente un método por cada campo del objeto, es una copia con pasos. Cómo corregirlo: dataclass, * para forzar nombres, __post_init__ para las reglas. Si después de eso todavía te falta algo, entonces sí hablamos de Builder.

Que el builder deje construir objetos inválidos (de implementación). Qué pasa: se escribe el builder para tener validación, pero la validación se pone en cada with_x y no en el build(). Entonces cada paso valida lo suyo y nadie valida el conjunto —que era el punto—. Se puede terminar con una orden sin boletos, o con facturación incompleta, y el error aparece tres capas más adelante. Por qué pasa: validar donde llega el dato es el instinto correcto en general, y aquí choca con el propósito del patrón. Cómo detectarlo: mira tu build(). Si solo tiene un return, tu builder no está validando nada que un dataclass no validara. Cómo corregirlo: reparte con criterio. Lo que se puede juzgar con un solo dato —"el cupón existe"— va en su paso, porque conviene fallar temprano. Lo que necesita ver varios —"cortesías con cupón", "aforo por orden"— va en el build(). Y si el build() termina vacío, es una señal más de que no hacía falta el patrón.

Confundir Builder con Factory porque los dos "crean cosas" (conceptual). Qué pasa: alguien tiene que crear un objeto, sabe que hay patrones creacionales, y elige según cuál recuerda mejor. Termina con una OrderFactory que recibe once parámetros —una factory que no decide nada— o con un PaymentProviderBuilder que arma un proveedor paso a paso cuando lo único que había que hacer era elegir uno de tres. Por qué pasa: los dos viven en el mismo capítulo del libro y los dos terminan devolviendo un objeto. Cómo detectarlo: pregúntate cuántos tipos distintos puede devolver tu función. Si son varios, es Factory. Si es siempre el mismo tipo y lo difícil es llenarlo, es Builder —o, más probable, ninguno de los dos—. Cómo corregirlo: vuelve a las tres preguntas de la lección 1. Factory responde qué; Builder responde cómo. Un detalle útil: se combinan sin problema. Una factory puede devolver un builder ya configurado —get_order_builder_for(event)— cuando distintos tipos de evento arman sus órdenes con reglas distintas.

Ejercicios

Ejercicio 1 — Elige la herramienta para cuatro casos. Para cada uno, di si usarías constructor normal, dataclass con defaults, dataclass con __post_init__, o Builder. Justifica en dos líneas.

(a) PaymentResult de la lección 3: cuatro campos, todos llegan juntos cuando el proveedor responde, y es inmutable. (b) Un EventDraft en el panel de organizadores: el organizador llena el nombre, después sube la imagen, después define los tipos de boleto uno por uno con su precio y aforo, y puede guardar a medias y seguir mañana. (c) Un ReportRequest: tiene formato, rango de fechas, evento, y tres banderas opcionales (incluir cancelados, incluir cortesías, agrupar por día). Todo llega en la misma petición HTTP. (d) Un Coupon: código, porcentaje de descuento, monto mínimo de compra, fecha de expiración. Y una regla: si el porcentaje es mayor a 50, la fecha de expiración es obligatoria.

Ver solución

(a) dataclass con frozen=True, que es exactamente como quedó en la lección 3. Cuatro campos, todos juntos, sin reglas cruzadas. Un builder aquí sería cuatro métodos de asignación —el anti-caso literal—.

(b) Builder, y es el caso más claro de los cuatro. Los datos llegan a lo largo de días, agregar un tipo de boleto requiere lógica —verificar que el aforo total no supere la capacidad del recinto—, y el borrador se guarda a medias, que es precisamente lo que un builder representa. Fíjate además en algo que este caso hace evidente: el builder es serializable. Puedes guardar el estado del borrador en la base y reconstruirlo mañana. Un constructor no te da un lugar donde vivir "a medias".

(c) dataclass con defaults, y las tres banderas en False. Todo llega junto, ningún campo requiere lógica. Si además quieres que nadie pueda pasar las tres banderas por posición —son tres booleanos seguidos, la receta perfecta para un error silencioso—, agrega el *. Ese detalle vale más que cualquier patrón aquí: ReportRequest(fmt, start, end, True, False, True) es ilegible y include_cancelled=True no lo es.

(d) dataclass con __post_init__. La regla cruza dos campos, que es justo lo que __post_init__ resuelve. No hace falta Builder porque los cuatro datos llegan juntos —el formulario de creación de cupón se envía completo— y ningún campo requiere lógica al asignarse. Este caso está puesto para mostrar que "hay una regla que involucra varios campos" no basta por sí sola para justificar un Builder; lo que lo justifica es que los datos lleguen en momentos distintos o que los pasos hagan trabajo.

Por qué funciona: de cuatro casos que "crean objetos complicados", uno solo pide Builder. Esa proporción es aproximadamente la que vas a encontrar en la práctica.

Ejercicio 2 — Arregla el constructor sin escribir un Builder. Este es el constructor real del exportador de reportes de Boletia. Mejóralo usando solo herramientas del lenguaje, y explica qué error concreto evita cada cambio.

class ReportRequest:
    def __init__(self, event_id, fmt, start, end, cancelled, courtesy, group, tz):
        self.event_id = event_id
        self.fmt = fmt
        self.start = start
        self.end = end
        self.cancelled = cancelled
        self.courtesy = courtesy
        self.group = group
        self.tz = tz

# Una llamada real, tomada del código:
req = ReportRequest(9, "csv", d1, d2, False, True, False, "America/Mexico_City")
Ver solución
from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class ReportRequest:
    """Los parámetros de un reporte. Es un dato, no un objeto con comportamiento.

    frozen=True porque una petición de reporte es un hecho: se arma una vez
    y se pasa a quien genera. Que nadie pueda modificarla a medio camino
    elimina una clase entera de bugs difíciles de rastrear.
    """
    event_id: int
    output_format: str
    start: date
    end: date
    *,                                    # ← nota: esto va en el __init__ generado
    timezone: str = "America/Mexico_City"
    include_cancelled: bool = False
    include_courtesy: bool = True
    group_by_day: bool = False

    def __post_init__(self):
        if self.start > self.end:
            raise ValueError("El rango del reporte empieza después de terminar")
        if self.output_format not in available_formats():
            raise UnknownFormatError(self.output_format)

(Nota técnica: la sintaxis del * dentro de un dataclass requiere @dataclass(kw_only=True) en Python 3.10 o superior, que hace que todos los campos sean solo por nombre. La versión de arriba está escrita así para que se vea la intención; en código real usarías @dataclass(frozen=True, kw_only=True) y quitarías el asterisco.)

Los cambios y el error que evita cada uno:

Nombres completos en vez de abreviaturas. fmtoutput_format, cancelledinclude_cancelled, groupgroup_by_day. El error que evita: cancelled=True se lee como "el reporte está cancelado"; include_cancelled=True se lee como lo que es. Un nombre ambiguo en un booleano provoca que alguien pase el valor contrario, y nada falla.

Argumentos solo por nombre. El error que evita es el de la llamada original: ..., False, True, False, .... Tres booleanos consecutivos son la configuración perfecta para un error silencioso, porque intercambiarlos no produce ningún fallo, solo un reporte con los datos equivocados. Es el mismo tipo de bug que el RFC en el campo de dirección.

Defaults sensatos. Ocho parámetros obligatorios se convierten en cuatro. La llamada típica queda ReportRequest(event_id=9, output_format="csv", start=d1, end=d2). El error que evita: que alguien pase un valor cualquiera solo para llegar al que le importa.

frozen=True. El error que evita es sutil y caro: que una función intermedia modifique la petición —req.include_courtesy = False "solo para este caso"— y que el reporte final no coincida con lo que el usuario pidió. Un objeto inmutable no puede sufrir eso.

__post_init__ con las dos reglas. El error que evita: un rango invertido que produce un reporte vacío sin explicación, y un formato inexistente que revienta cincuenta líneas después, lejos de donde se originó.

Y lo que NO se hizo: ningún Builder. Ocho campos, cinco opcionales, dos reglas de validación, y el lenguaje lo resolvió todo. Si este ejercicio te dejó con la sensación de "esto ya estaba bien así", esa es exactamente la sensación que quiero que tengas la próxima vez que estés a punto de escribir un builder.

Ejercicio 3 — Encuentra el problema de orden. El OrderBuilder de la lección tiene un defecto: with_discount() calcula el descuento sobre el subtotal del momento, así que si se llama antes de los add_ticket(), el descuento sale cero y nadie se entera. Propón dos formas de arreglarlo y di cuál elegirías y por qué.

Ver solución

Forma 1 — Guardar el código y calcular en el build().

def with_discount(self, code: str) -> "OrderBuilder":
    """Guarda el cupón. El monto se calcula al final, cuando el subtotal es definitivo."""
    coupon = find_coupon(code)
    if coupon is None:
        raise UnknownCouponError(code)   # esto SÍ se puede validar ya
    self._coupon = coupon
    return self

def build(self) -> Order:
    ...
    discount_amount = 0.0
    if self._coupon:
        if self._subtotal < self._coupon.minimum_purchase:
            raise CouponNotApplicableError(self._coupon.code, self._coupon.minimum_purchase)
        discount_amount = self._coupon.discount_for(self._subtotal)
    ...

El orden deja de importar. Cada paso guarda una intención y el build() resuelve todo con la información completa.

Forma 2 — Prohibir el orden incorrecto.

def with_discount(self, code: str) -> "OrderBuilder":
    if not self._tickets:
        raise BuilderUsageError(
            "Aplica el cupón después de agregar los boletos: "
            "el descuento se calcula sobre el subtotal"
        )
    ...

El orden sigue importando, pero ahora el error es inmediato y el mensaje dice qué hacer.

Yo elegiría la primera, y la razón vale más que el ejercicio: una interfaz donde el orden importa es una interfaz que va a usarse mal. No por descuido de nadie —quien la usa no puede saber, desde afuera, que el descuento se calcula al vuelo—. La segunda forma convierte un bug silencioso en un error ruidoso, que es una mejora enorme, pero deja intacta la trampa: alguien que agregue un tercer paso dependiente del subtotal tendrá que descubrir la misma regla otra vez.

Hay un matiz práctico a favor de la segunda, y por eso el ejercicio no tiene una respuesta única: en el carrito de Boletia, el cliente quiere enterarse en el momento de que su cupón no aplica por monto mínimo, no al pagar. Si mueves esa validación al build(), el aviso llega tarde. La salida honesta es combinar: el build() calcula —eso arregla el orden— y el carrito llama a un método aparte, preview_discount(), para mostrar el aviso mientras el cliente compra. Dos necesidades distintas, dos métodos, sin mezclarlas.

Por qué funciona: el problema del orden implícito es el defecto estructural de los builders fluidos y casi ningún material lo menciona. Que lo detectes al leer un builder ajeno —"¿qué pasa si llamo estos dos al revés?"— es una de las preguntas más útiles que puedes llevar a una revisión de código.

Resumen y siguiente paso

En esta lección viste el problema del constructor de diez parámetros —ilegible en el punto de llamada, frágil ante el orden, contaminado por opcionales— y sus tres soluciones, ordenadas de menor a mayor costo.

La primera, argumentos por nombre con el *, resuelve los tres problemas con sintaxis que el lenguaje ya trae. La segunda, el dataclass, agrega el __repr__ que hace legibles los logs, el __eq__ que hace legibles las pruebas, y el __post_init__ donde caben las reglas que cruzan varios campos. Con esas dos ya cubres la gran mayoría de los casos donde alguien escribiría un Builder.

La tercera es el Builder, y se gana su lugar cuando aparece al menos una de tres cosas: los pasos hacen trabajo de verdad —como add_ticket, que consulta la regla de precio y recalcula—, los datos llegan en momentos distintos —el carrito, el borrador que se guarda a medias—, o el objeto final debe ser inmutable pero armarse por partes. Lo escribiste completo para el carrito de Boletia, con la validación del conjunto en el build(), que es la única cosa que ningún constructor puede hacer. Y viste su defecto estructural: el orden implícito entre pasos, que nadie que use el builder desde afuera puede adivinar.

Viste también el anti-caso —el TicketBuilder de treinta líneas que hacía lo mismo que una llamada de una línea— y la prueba de un segundo que lo detecta: si todos los métodos son self._x = x; return self, ese builder es un constructor con más pasos.

Antes de avanzar deberías poder: arreglar un constructor incómodo sin escribir ninguna clase nueva; nombrar las tres condiciones que justifican un Builder; explicar por qué la validación va en el build() y no en cada paso; y —lo más importante— decir por qué en Python este patrón hace falta mucho menos que en los lenguajes donde se popularizó.

La lección 5 cambia de tono. Vamos con el cuarto patrón del módulo, y es el único que no vas a aplicar nunca: Singleton. Vas a ver qué problema creía resolver, por qué la industria se movió en contra durante veinte años, y cómo reconocerlo en el código heredado que te va a tocar mantener. Es una lección de diagnóstico, no de técnica.

Recursos

  • Refactoring Guru — Builder — el patrón del catálogo con su diagrama completo, incluyendo la figura del "director" que en Python casi nunca se usa.
  • python-patterns.guide — The Builder Pattern — Brandon Rhodes explica por qué en Python el Builder rara vez es el patrón del catálogo y sí es, en cambio, una interfaz fluida.
  • Documentación de Python — dataclasses — la referencia oficial. Presta atención a frozen, kw_only, field(default_factory=...) y __post_init__: son las cuatro piezas que sustituyen a un Builder en la mayoría de los casos.
  • PEP 3102 — Keyword-Only Arguments — la propuesta que introdujo el * en las firmas de función. Es de 2006 y sigue siendo una de las herramientas menos usadas del lenguaje frente a lo mucho que resuelve.