Módulo 2: Cuándo NO abstraer

3. La regla de tres

Descripción

Al terminar esta lección vas a tener la heurística que evita la mayor parte de la sobre-ingeniería del mundo, y —más importante— vas a entender por qué funciona, que es lo que te va a permitir saber cuándo no aplicarla. La regla en una frase: no abstraigas con dos casos; espera al tercero. Vas a ver de dónde sale ese número, qué es exactamente un eje de variación, por qué con dos casos siempre eliges el eje equivocado, y cómo Boletia se equivocó de eje con sus proveedores de pago y pagó la factura un año después.

Esto importa porque el momento de máximo riesgo en el diseño de software no es cuando tienes un caso —ahí nadie abstrae— ni cuando tienes cinco —ahí el patrón es evidente—. Es cuando tienes dos. Dos es el número que genera la sensación de haber visto un patrón sin haber visto suficiente para saber cuál es. Y esa sensación es fuerte, convincente y muchas veces está mal.

En la lección anterior aprendiste a poner precio a una abstracción. Esta lección responde la pregunta siguiente: dado que cuesta, ¿en qué momento tengo información suficiente para saber que vale la pena pagarlo? La respuesta que la industria encontró a base de tropezones es un número, y ese número no es dos.

Conexión con el módulo: la lección 2 te dio la cuenta del costo; esta te dice cuándo tienes derecho a hacerla. La lección 4 va a tomar el caso que aquí vamos a diagnosticar —la abstracción hecha con dos casos— y va a mostrar por qué el daño es peor que el de la duplicación que quería evitar, y por qué es tan difícil de deshacer. La lección 5 va a atacar la excusa más común para saltarse esta regla: "es que sé que van a venir más". Y la lección 6, el anti-patrón del plugin, es el caso extremo de esta misma lección: una abstracción hecha con cero casos de variación real.

Dos, cuatro… ¿y qué sigue?

Te doy dos números y te pido el siguiente: 2, 4, …

Casi todo el mundo dice 6. Algunos dicen 8. Los dos están perfectamente justificados: 6 si la regla es "suma 2", 8 si la regla es "duplica". Y hay infinitas reglas más que también producen 2 y 4 —"eleva al cuadrado el anterior menos algo", "los primeros dos números pares", "el número de patas de las mascotas de mi vecina"—. Con dos datos, cualquier hipótesis encaja.

Ahora te doy el tercero: 2, 4, 8. Ahora sí. La hipótesis "suma 2" queda descartada. El tercer número no confirmó tu idea: la puso a prueba. Y eso es algo que el segundo número nunca pudo hacer, porque el segundo número fue el que generó la hipótesis.

Lo mismo pasa geométricamente. Por dos puntos pasa exactamente una recta —y también infinitas curvas—. Tu cerebro elige la recta porque es la explicación más simple, y eso es razonable. Pero es una elección, no una observación. Con el tercer punto, o cae en la recta y ahora sabes algo, o no cae y descubres que la forma era otra.

Aquí está la traducción al código, y es literal:

Dos casos siempre parecen tener un patrón, porque dos de cualquier cosa siempre tienen algo en común. El tercero es el primero que puede decirte que estabas equivocado.

Esa asimetría entre confirmar y refutar es toda la lección. Con dos casos solo puedes confirmar —y confirmar lo que tú mismo inventaste no vale nada—. El tercero es el primer dato con poder de refutación. Por eso la regla dice tres y no dos, y por eso no es una superstición ni un número redondo elegido al azar.

Qué es un eje de variación

Antes de seguir necesitamos una palabra precisa, porque va a aparecer todo el tiempo en el resto de la guía.

Un eje de variación es la dimensión a lo largo de la cual tus casos se diferencian. Es la respuesta a la pregunta "¿en qué se distinguen estos casos entre sí?".

Su anatomía tiene tres partes:

  1. Lo que cambia entre un caso y otro.
  2. Lo que se queda igual en todos.
  3. El nombre que le das a la diferencia — y ese nombre es el que después queda escrito en tu interfaz.

Piensa en camisetas. Una camiseta varía en talla y varía en color. Son dos ejes distintos, y los dos son reales. Ahora imagina que organizas el almacén: puedes acomodarlo por talla —un pasillo de chicas, uno de medianas, uno de grandes— o por color. Las dos organizaciones son válidas. Pero solo una hace fácil el trabajo del día a día, y cuál sea depende de cómo pida la gente: si la mayoría de los pedidos dicen "una mediana de lo que sea", ordénalo por talla; si dicen "una negra de la que quepa", ordénalo por color.

Y aquí está el punto: si te equivocas de eje, no es que el almacén quede un poco peor. Es que cada pedido se vuelve un recorrido por todos los pasillos, y reordenarlo cuesta cerrar el almacén tres días.

En el código pasa exactamente eso. Cuando abstraes, estás eligiendo un eje: estás diciendo "lo que varía entre estos casos es esto, y lo que comparten es aquello". Esa elección queda escrita en la firma de un método, y todo el código que se escriba después se apoya en ella. Si elegiste mal, no tienes un problema local: tienes un problema que se propaga a cada sitio de llamada.

Con dos casos, la probabilidad de elegir bien el eje es baja, y no por falta de talento. Es un problema de información: cualquier par de cosas comparte más de una propiedad, y no tienes forma de saber cuál de ellas es la que importa.

Ejemplo trabajado: Boletia abstrajo con dos proveedores y erró el eje

Esta es la historia real de payments/ en Boletia, y es el ejemplo más útil de todo el módulo porque termina bien y mal a la vez.

Momento uno, hace tres años. Un solo proveedor.

# Archivo: checkout/checkout.py  (versión original, un solo proveedor)
def charge_order(order):
    client = StripeClient(api_key=settings.STRIPE_KEY)
    # Stripe trabaja en centavos, por eso multiplicamos por 100.
    charge = client.create_charge(amount=int(order.total * 100), currency="MXN")
    return charge.id

Sin variación, sin abstracción. Correcto.

Momento dos, hace dos años y medio. Llega MercadoPago.

Ahora hay dos formas de cobrar. Alguien —con muy buena intención y siguiendo lo que dice cualquier libro— crea la interfaz:

# Archivo: payments/provider.py   ← la versión que se escribió con DOS casos
from abc import ABC, abstractmethod

class PaymentProvider(ABC):
    @abstractmethod
    def charge(self, amount_cents: int, currency: str) -> str:
        """Cobra el monto indicado y devuelve el id de la transacción."""

Fíjate bien en esa firma, porque cada pedazo es una decisión sobre el eje y ninguna se tomó conscientemente:

  • amount_cents: int decide que todo monto se puede expresar en centavos como entero.
  • El nombre charge decide que la operación es un cobro.
  • -> str decide que toda operación produce un identificador de transacción.
  • Que no haya un tipo de retorno con estado decide que el resultado es binario: funcionó o lanzó una excepción.

Cuatro supuestos. Ninguno se discutió, porque con dos casos los cuatro parecían obvios.

Las dos implementaciones:

# Archivo: payments/stripe_provider.py
class StripeProvider(PaymentProvider):
    def charge(self, amount_cents, currency):
        client = StripeClient(api_key=settings.STRIPE_KEY)
        return client.create_charge(amount=amount_cents, currency=currency).id


# Archivo: payments/mercadopago_provider.py
class MercadoPagoProvider(PaymentProvider):
    def charge(self, amount_cents, currency):
        client = MercadoPagoClient(token=settings.MP_TOKEN)
        # MercadoPago quiere el monto como float, así que deshacemos
        # la conversión a centavos que la interfaz nos obligó a hacer.
        return client.pay(amount_cents / 100, currency=currency).payment_id

Detente en ese comentario. Ahí está la primera señal, y pasó desapercibida. La interfaz obligó a una unidad —centavos enteros— que a una de las dos implementaciones no le sirve, y esa implementación tiene que deshacer la conversión para poder trabajar. Con dos casos eso se ve como un detalle molesto. Es, en realidad, la primera evidencia de que el eje elegido no es el eje real: se eligió "cómo se expresa el monto" como parte del contrato, cuando eso era un detalle de cada proveedor.

Momento tres, hace un año. Llega el pago en efectivo.

Boletia agrega pago en tienda de conveniencia. El flujo es este: el sistema genera una referencia, el cliente va a la tienda, paga en el mostrador, y entre unas horas y tres días después llega un aviso del proveedor confirmando el pago.

Mira ese flujo contra los cuatro supuestos de la interfaz:

Supuesto de la interfazRealidad del pago en efectivo
La operación es un cobroNo cobra nada. Genera una referencia
Devuelve un id de transacciónNo hay transacción todavía. Hay una referencia
El resultado es inmediatoPuede tardar tres días
Éxito o excepciónEl resultado normal es "pendiente"

Los cuatro fallan. No uno: los cuatro.

Y aquí viene lo que de verdad pasa en los equipos cuando esto ocurre. Nadie rediseña la interfaz —eso implicaría tocar todo el código que ya la usa—. Lo que se hace es meter el caso nuevo a la fuerza en la forma existente:

# Archivo: payments/cash_provider.py
class CashProvider(PaymentProvider):
    def charge(self, amount_cents, currency):
        # Aquí no se cobra: se genera una referencia para pagar en tienda.
        # Como la interfaz exige devolver un string, devolvemos la referencia
        # con un prefijo para que el que llama sepa que esto NO está pagado.
        reference = generate_cash_reference(amount_cents)
        return f"pending:{reference}"

Y en el checkout, apareció esto:

# Archivo: checkout/checkout.py  (fragmento, hoy)
transaction_id = provider.charge(int(order.total * 100), "MXN")

if transaction_id.startswith("pending:"):        # ← el condicional por proveedor,
    order.status = "pending"                     #   de vuelta y peor disfrazado
    order.cash_reference = transaction_id.split(":", 1)[1]
else:
    order.status = "paid"
    order.transaction_id = transaction_id

Qué esperar de este desenlace. Vamos por partes, porque hay cuatro cosas que aprender y la última es la que importa.

Primero: la abstracción produjo el condicional que quería eliminar. El argumento entero para crear PaymentProvider era "así el checkout no tiene que saber cuál proveedor es". Y hoy el checkout tiene un if que distingue proveedores. La estructura está, el beneficio no.

Segundo: el condicional es peor que el original. Antes decía if provider == "cash", que cualquiera entiende. Ahora dice if transaction_id.startswith("pending:"), que es una convención no escrita: para entenderla hay que abrir cash_provider.py y descubrir que el prefijo es un código secreto entre dos archivos. Se cambió un condicional honesto por uno críptico.

Tercero: se propagó. Esa convención del prefijo "pending:" no vive solo en el checkout. Hoy hay catorce sitios en Boletia que la conocen: el checkout, el reintento de cobro, tres reportes, el panel de administración, el manejador del aviso de confirmación, dos tareas programadas y cinco tests. Arreglar la interfaz ahora significa tocar catorce lugares.

Y aquí está el número que quiero que veas: si nunca se hubiera abstraído, habría tres funciones duplicadas. Arreglarlas sería tocar tres lugares. La abstracción prematura no ahorró trabajo: lo multiplicó por cuatro y medio, y lo escondió.

Cuarto, y es el punto de la lección: el eje estaba mal desde el principio. El eje real —el que se ve cuando miras los tres casos juntos— no es "cómo se cobra". Es "cómo una orden llega a estar pagada", y eso admite dos modos: resolución inmediata y resolución diferida. Ese eje no era visible con Stripe y MercadoPago, porque los dos son inmediatos. Necesitabas el tercero para verlo.

Así se ve la interfaz que sale de mirar los tres casos:

# Archivo: payments/provider.py   ← la versión que sale de mirar los TRES
from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class PaymentResult:
    """Resultado de iniciar un pago. Puede quedar resuelto o quedar pendiente."""
    status: str                    # "paid" | "pending"
    transaction_id: str | None     # tiene valor si status == "paid"
    reference: str | None          # tiene valor si status == "pending"
    expires_at: str | None         # cuándo vence la referencia, si aplica


class PaymentProvider(ABC):
    @abstractmethod
    def start_payment(self, order) -> PaymentResult:
        """Inicia el pago de la orden. Cada proveedor decide cómo expresar el monto."""

Compara las dos firmas y verás que cada diferencia vino del tercer caso:

Antes (dos casos)Después (tres casos)Qué lo causó
chargestart_paymentEl efectivo no cobra: inicia un proceso
amount_cents: int, currency: strorderQue cada proveedor exprese el monto como quiera
-> str-> PaymentResultHay un estado que comunicar, no solo un id
implícito: síncronoexplícito: statusLa resolución puede ser diferida

Y fíjate en la fila del monto: la solución no fue elegir mejor entre centavos y float. Fue sacar esa decisión del contrato. Ese es un movimiento que se repite mucho cuando corriges un eje: la abstracción correcta suele decidir menos cosas que la incorrecta, no más.

Una nota final sobre este caso, para ser justos con quien escribió la primera versión: no fue un error de habilidad. Fue un error de información. Con Stripe y MercadoPago sobre la mesa, esa interfaz era una lectura razonable de los datos disponibles. El error no fue elegir mal el eje; fue elegirlo demasiado pronto, cuando todavía no había datos para elegir. Esa distinción importa porque significa que no te vas a salvar de esto siendo más inteligente. Te salvas esperando.

Cómo se aplica la regla, en la práctica

La regla de tres se enuncia en cuatro palabras y se ejecuta en tres pasos. El tercer paso es el que casi nadie hace bien.

Paso 1 — Primera vez: escríbelo directo. Sin ceremonia, sin preparar el terreno, sin "por si acaso". Una función con un buen nombre.

Paso 2 — Segunda vez: copia, pega y ajusta. Sí, en serio. Duplica. Y ahora la parte que convierte esto en ingeniería y no en descuido: deja registro de la duplicación. Un comentario basta:

# Archivo: reports/pdf_exporter.py
def export_pdf(event_id):
    # OJO: este flujo es casi idéntico al de reports/csv_exporter.py.
    # Duplicado a propósito: con dos casos todavía no sabemos qué es lo común.
    # Si aparece un tercer formato, unificar los tres (no meter el tercero aquí).
    rows = fetch_attendees(event_id)
    ...

Ese comentario hace tres cosas: le avisa a quien pase que la duplicación es deliberada y no un descuido; deja escrita la instrucción para el futuro; y —lo más útil— evita que alguien "arregle" la duplicación por su cuenta antes de tiempo.

La regla de tres no es "ignora la duplicación". Es "registra la duplicación y espera". Son cosas muy distintas y la diferencia está en ese comentario.

Paso 3 — Tercera vez: para, y mira los tres juntos. Aquí está el error que arruina la regla incluso a quien la conoce. Cuando llega el tercer caso, lo que la gente hace es meterlo en la forma de los dos primeros. Eso es exactamente lo que hizo Boletia con "pending:", y es lo que convierte una regla buena en un desastre con dos pasos de retraso.

Lo que hay que hacer en el paso 3 es distinto:

  1. Pon los tres casos lado a lado, literalmente, en tres ventanas.
  2. Pregunta: ¿qué comparten los tres? No "¿cómo meto el tercero en lo que tengo?".
  3. Pregunta también: ¿en qué NO se parecen? Los límites son tan informativos como el parecido, y son lo único que el tercer caso te podía enseñar.
  4. Diseña la abstracción sobre lo que comparten los tres, aunque eso signifique tirar la que hiciste con dos.

Ese "aunque signifique tirar" es la parte cara y es la parte correcta. Si te da pereza tirar una interfaz con dos implementaciones, imagina lo que va a costar tirarla con catorce sitios de llamada dentro de un año.

Por qué el número es tres, y no dos ni cinco

Vale la pena entender el razonamiento, porque el número no es mágico y saber de dónde sale te dice cuándo mover la línea.

Con un caso no hay variación. No hay nada que abstraer. Cualquier interfaz que crees aquí es pura imaginación; no está describiendo nada. Este es el terreno del anti-patrón de la lección 6.

Con dos casos hay variación, pero no hay información. Ya lo vimos: cualquier hipótesis encaja, y la que elijas será la más simple que se te ocurra, que suele ser también la más restrictiva. Y hay un agravante psicológico: como los dos casos encajan perfectamente en tu interfaz, obtienes una confirmación falsa. El diseño se siente correcto precisamente porque no puede fallar todavía.

Con tres casos hay poder de refutación. El tercero es el primero que puede no encajar, y por eso es el primero que enseña algo. Si encaja, aprendiste que tu hipótesis sobrevivió a una prueba real. Si no encaja, aprendiste el eje verdadero antes de haber escrito código encima del falso.

Y hay un segundo argumento, económico, que llega al mismo número. Con dos copias, el costo de la duplicación es bajo: si hay que cambiar algo, cambias dos lugares y en el peor caso uno se te olvida y el error es evidente. Con tres, empieza a doler de verdad. Así que tres es también el punto donde el beneficio de abstraer se vuelve tangible. Los dos argumentos —el de información y el de costo— apuntan al mismo lugar, y por eso la regla ha sobrevivido treinta años.

El contador correcto cuenta conocimiento, no texto

Este matiz es el que separa a quien aplica la regla mecánicamente de quien la entiende, y vale por sí solo el resto de la lección.

La regla de tres se aplica a repeticiones de una misma idea, no a repeticiones de un mismo texto.

Dos bloques de código idénticos que expresan reglas distintas no son duplicación. Imagina que en Boletia el descuento de early-bird es del 15% y la comisión que se le cobra al organizador también es del 15%. El código se ve igual. No es duplicación: son dos reglas de negocio independientes que hoy coinciden en un número. Si las unificas, el día que marketing cambie el descuento a 20% también vas a cambiar la comisión sin querer. Eso es acoplar dos cosas que nunca tuvieron nada que ver.

Y al revés: dos bloques que se ven distintos pueden ser la misma regla. Si la validación de "esta orden se puede cancelar" está escrita de una forma en el panel de administración y de otra en la API pública, se ven distintas y son la misma regla. Ahí sí hay duplicación, y del tipo caro: el día que una se actualice y la otra no, vas a tener dos respuestas distintas a la misma pregunta según por dónde entres.

La prueba práctica, y es una sola pregunta:

Si esta regla cambia, ¿tienen que cambiar los dos lugares a la vez, siempre?

Si la respuesta es sí, es duplicación de conocimiento y cuenta para la regla de tres. Si la respuesta es "no necesariamente, podrían evolucionar por separado", es coincidencia y no cuenta —y unificarla sería un error incluso al tercer caso—.

Cuándo la regla se rompe legítimamente

La regla de tres es una heurística, no una ley, y hay situaciones donde esperar al tercero es peor. Conviene conocerlas, porque quien solo sabe la regla la aplica mal en estos casos y pierde credibilidad.

Cuando el costo de una divergencia es catastrófico, no cosmético. Si la duplicación está en lógica de permisos, en cálculo de dinero con implicaciones fiscales, o en el código que decide si un boleto ya se usó, entonces que dos copias se desincronicen no produce un bug feo: produce una pérdida de dinero, un problema legal o un agujero de seguridad. En ese terreno, unifica desde el segundo caso. El criterio no es el número de casos: es qué pasa el día que una copia se actualice y la otra no.

Cuando el tercer caso ya está firmado, no imaginado. Si hay un contrato con un cliente que dice que en seis semanas Boletia tiene que soportar un tercer proveedor específico, entonces ya tienes tres casos —dos escritos y uno documentado—. Puedes diseñar con los tres sobre la mesa, y de hecho debes, porque el tercero es el que te va a dar el eje.

Pero cuidado con la trampa, que es enorme: "el contrato dice" no es lo mismo que "el roadmap dice", y ninguno de los dos es "a mí me parece que van a venir más". La diferencia es verificable: ¿puedes señalar el documento y la fecha? Si no, es especulación con ropa formal. La lección 5 se dedica entera a esa frontera.

Cuando la segunda implementación es un doble de prueba. Si necesitas sustituir un servicio externo por un simulador para poder probar sin llamar a la API de pagos de verdad, ya tienes dos implementaciones reales: la de producción y la de pruebas. Ese es un beneficio concreto y verificable, no una especulación. Ojo con no confundirlo con su versión inflada, que es meter una interfaz "por testabilidad" delante de código que no habla con nada externo.

Cuando el lenguaje o el framework lo exigen. Algunos entornos requieren declarar una interfaz para que un mecanismo funcione. Ahí no estás eligiendo: estás cumpliendo un requisito de la plataforma, y eso es un costo del entorno, no una decisión de diseño.

Y ahora la anti-excepción, la que hay que decir en voz alta:

"Es que sé que van a venir más" no es una excepción a la regla de tres. Es exactamente la creencia que hizo necesaria la regla.

Todo el mundo que abstrajo prematuramente creía que iban a venir más. En Boletia, quien montó el sistema de plugins de asientos estaba seguro de que llegarían más. Dos años después: cero. La regla no existe para gente que no piensa en el futuro; existe porque pensar en el futuro produce predicciones que fallan, y la regla convierte una predicción en una observación.

Errores comunes

Meter el tercer caso en la forma de los dos primeros (de ejecución). Qué pasa: alguien conoce la regla de tres, la respeta, duplica pacientemente el segundo caso… y cuando llega el tercero, en vez de rediseñar, lo encaja a martillazos en la abstracción que ya tenía. Aparece un parámetro opcional, un prefijo de string, un if dentro de la implementación, un **kwargs. Por qué pasa: cuando llega el tercer caso ya hay código escrito contra la interfaz, hay presión de tiempo, y encajar el caso es tres horas mientras rediseñar es tres días. La decisión racional a corto plazo es siempre encajar. Cómo detectarlo: la señal más limpia es una implementación que deshace algo que la interfaz le impuso —como MercadoPago dividiendo entre cien— o que devuelve un valor con formato especial que el llamador tiene que interpretar. Cómo corregirlo: cuando llegue el tercer caso, trata el momento como un evento de diseño, no como una tarea. Y si de verdad no hay tiempo, deja escrito en el código que la interfaz quedó mal y por qué. Un # TODO con el diagnóstico correcto vale mucho más que uno genérico.

Contar apariciones de texto en vez de apariciones de conocimiento (de diagnóstico). Qué pasa: alguien busca en el proyecto y encuentra tres bloques parecidos, aplica la regla de tres y los unifica. El resultado acopla tres reglas de negocio que solo coincidían. Meses después, una tiene que cambiar y las otras no, y la abstracción se llena de parámetros para distinguir casos que nunca debieron estar juntos. Por qué pasa: el texto se puede buscar automáticamente y el conocimiento no; una herramienta te encuentra código repetido en segundos y nadie tiene una herramienta que encuentre reglas de negocio repetidas. Cómo detectarlo: aplica la pregunta —si esta regla cambia, ¿tienen que cambiar los tres a la vez, siempre?—. Si dudas, no es duplicación. Cómo corregirlo: cuenta reglas, no líneas. Y desconfía especialmente de las coincidencias numéricas: dos porcentajes iguales casi nunca son la misma regla.

Usar la regla como excusa permanente (de abuso). Qué pasa: alguien se refugia en "todavía van dos" indefinidamente, y cuando llegan seis copias, nadie se acuerda de que había que unificar. La regla de tres se vuelve una licencia para no pensar nunca, que es el error opuesto al que la regla intenta corregir. Por qué pasa: no unificar es más fácil que unificar, y la regla ofrece una justificación cómoda. Además, la duplicación no duele de golpe: duele en cuotas. Cómo detectarlo: si hay más de tres copias y ninguna tiene un comentario que las relacione, la regla no se está aplicando, se está usando de coartada. Cómo corregirlo: el paso 2 es obligatorio. Duplicar con registro es aplicar la regla; duplicar en silencio es solo duplicar. Ese comentario es lo que convierte la espera en una decisión y no en un olvido.

Ejercicios

Ejercicio 1 — Dos hipótesis para los mismos dos casos. En Boletia hay dos exportadores de reportes: uno a CSV y otro a PDF. Los dos hacen lo mismo en el mismo orden: consultar los asistentes, ordenarlos por fecha de compra, darles formato y escribir un archivo en disco. Escribe dos hipótesis distintas sobre cuál es el eje de variación, de modo que las dos expliquen igual de bien los dos casos existentes. Después, para cada hipótesis, inventa un tercer caso que la refutaría.

Ver solución

Hipótesis A — el eje es el formato de salida. Lo que varía es cómo se serializan las filas; todo lo demás (consulta, orden, escritura) es común. La interfaz que sale de aquí es algo como format(rows) -> bytes, y un esqueleto común que se encarga del resto.

Hipótesis B — el eje es el destino. Lo que varía es dónde termina el archivo y con qué características; el formato es un detalle de cada destino. La interfaz que sale de aquí es algo como export(event_id) -> str devolviendo una ruta, con cada exportador resolviendo el flujo completo a su manera.

Las dos explican perfectamente CSV y PDF. Eso es lo que quería mostrar el ejercicio: con dos casos, tienes al menos dos lecturas y ninguna evidencia para elegir.

Un tercer caso que refuta la hipótesis A: un reporte que se envía por correo en el cuerpo del mensaje, sin archivo. Aquí no hay serialización a bytes ni escritura a disco; el eje "formato de salida" deja fuera la mitad del caso.

Un tercer caso que refuta la hipótesis B: un reporte en hoja de cálculo que se escribe en el mismo disco que el CSV, con la misma ruta y el mismo nombre, y que solo difiere en el formato. Aquí el destino es idéntico y lo único que varía es la serialización; el eje "destino" no explica nada.

Y el remate, que es lo interesante: con los tres casos reales de Boletia —CSV, PDF y hoja de cálculo, los tres a disco— gana claramente la hipótesis A, y además se revela algo más fino que ninguna de las dos hipótesis capturaba: los tres comparten el esqueleto completo y solo difieren en un paso. Eso tiene nombre propio y lo vas a ver en el módulo 3. No lo podías saber con dos.

Por qué funciona: el ejercicio te obliga a producir dos explicaciones válidas para los mismos datos. Una vez que has hecho eso, la sensación de "está clarísimo cuál es el patrón" con dos casos deja de ser convincente para siempre.

Ejercicio 2 — El eje ingenuo de las reglas de precio. Boletia empezó con dos tipos de boleto: general (precio base) y VIP (precio base más un 40%). Escribe: (a) cuál sería el eje de variación que casi cualquiera elegiría con esos dos casos, y qué firma de método saldría de ahí; (b) qué pasa cuando llega early-bird, que es un descuento del 20% pero solo si la compra ocurre antes de una fecha de corte; (c) qué pasa cuando llega cortesía, que es precio cero y tiene un tope de cuántas se pueden emitir por evento; (d) cuál es el eje real que se ve con los cuatro.

Ver solución

(a) El eje ingenuo: "cada tipo de boleto tiene un multiplicador sobre el precio base". La firma que sale es algo así:

class PricingRule(ABC):
    @abstractmethod
    def multiplier(self) -> float:
        """Factor por el que se multiplica el precio base."""

General devuelve 1.0, VIP devuelve 1.4. Encaja perfecto. Se siente correctísimo.

(b) Early-bird lo rompe a medias. El descuento sí es un multiplicador (0.8), pero depende de cuándo se compra, y multiplier() no recibe nada: no tiene forma de saber la fecha. La reacción típica es agregarle un parámetro: multiplier(purchase_date). Ahora todas las reglas reciben una fecha que a dos de las tres no les importa. Primera deformación.

(c) Cortesía lo rompe del todo. El precio no es un múltiplo de nada: es cero por definición. Y además tiene una regla que no es de precio: un tope de emisiones por evento. Eso no cabe en multiplier() de ninguna manera. La reacción típica es agregar un método más a la interfaz —max_issued()— que devuelve None en tres de las cuatro reglas. Segunda deformación, y la peor: la interfaz ahora tiene un método que casi nadie usa.

(d) El eje real: "dada una orden y un boleto, ¿cuánto se cobra y bajo qué condiciones se puede emitir?". Con los cuatro casos sobre la mesa se ve que hay dos preguntas distintas mezcladas: una de precio y una de elegibilidad. La abstracción correcta las separa:

class PricingRule(ABC):
    @abstractmethod
    def price_for(self, ticket, order) -> float:
        """Precio final del boleto, dado el contexto de la compra."""

    @abstractmethod
    def can_issue(self, ticket, event) -> bool:
        """¿Se puede emitir este boleto ahora mismo?"""

Fíjate en dos cosas. Primero: price_for recibe el contexto completo, así que early-bird puede mirar la fecha sin obligar a nadie más a conocerla. Segundo: la elegibilidad se separó en su propio método, así que el tope de cortesía deja de ser un caso especial. La abstracción correcta no es más grande: está mejor cortada.

Por qué funciona: este es el mismo error que el de los proveedores de pago, en otro rincón del sistema, y verlo dos veces te enseña a reconocer la forma. La señal es siempre la misma: un parámetro que a la mayoría no le sirve, o un método que la mayoría devuelve vacío. Cuando veas eso, el eje está mal.

Ejercicio 3 — ¿Duplicación o coincidencia? Para cada par, decide si es duplicación de conocimiento (cuenta para la regla de tres) o coincidencia (no cuenta, y unificar sería un error). Justifica con la pregunta "si esto cambia, ¿tienen que cambiar los dos a la vez, siempre?".

(a) El descuento de early-bird es 20% y la comisión que Boletia le cobra al organizador es 20%. (b) La validación "una orden se puede cancelar si está pendiente y el evento no ha empezado" está escrita en api/routes.py y otra vez en el panel de administración. (c) Tanto el exportador de CSV como el de PDF empiezan con rows = fetch_attendees(event_id) seguido de rows.sort(key=lambda r: r.purchased_at). (d) El límite de reintentos al llamar a la API de pagos es 3, y el límite de reintentos al enviar un SMS también es 3.

Ver solución

(a) Coincidencia. Si marketing sube el descuento de early-bird a 25%, la comisión del organizador no tiene por qué moverse: son decisiones de personas distintas por razones distintas. Unificarlas en una constante PERCENTAGE = 0.20 sería crear un acoplamiento entre dos áreas del negocio que no tienen relación. Las coincidencias numéricas son la trampa más común de todas, porque son las más fáciles de detectar automáticamente.

(b) Duplicación, y de la cara. Si la regla de cancelación cambia —digamos que ahora también se puede cancelar hasta dos horas después de que empezó el evento— tiene que cambiar en los dos lugares siempre, sin excepción. Si no, el sistema da dos respuestas distintas a la misma pregunta según por dónde entres, que es un bug que además es difícil de reproducir. Y nota que esta duplicación cae en la categoría de "el costo de la divergencia es alto": no esperes al tercero, unifícala ya.

(c) Duplicación, pero de la barata. Sí es la misma idea —"los reportes se arman con los asistentes ordenados por fecha de compra"— y sí cambiaría a la vez. Ahora bien: son dos líneas. Con dos casos, extraerlas todavía no vale la ceremonia. Este es el caso de manual para el paso 2 de la regla: duplícalas, deja el comentario que dice que están duplicadas a propósito, y unifica cuando llegue el tercer exportador. Que —te adelanto— llegó, y por eso reports/ de Boletia tiene hoy una abstracción bien puesta.

(d) Coincidencia. Reintentar un cobro y reintentar un SMS son problemas distintos con consecuencias distintas: reintentar un cobro de más puede cobrar dos veces, reintentar un SMS de más solo molesta. Que hoy los dos números valgan 3 es casualidad. Unificarlos en una constante MAX_RETRIES significa que el día que alguien suba los reintentos de SMS a 5, va a subir también los de cobro sin darse cuenta. Ese es un bug que cuesta dinero de verdad.

Por qué funciona: tres de estos cuatro casos se ven idénticos si tu criterio es "código repetido", y la clasificación correcta es distinta en cada uno. La pregunta —¿tienen que cambiar a la vez, siempre?— es lo único que los separa, y es una pregunta sobre el negocio, no sobre el código. Por eso la regla de tres no se puede automatizar.

Resumen y siguiente paso

En esta lección viste la regla que evita la mayor parte de la sobre-ingeniería: no abstraigas con dos casos; espera al tercero. Y viste de dónde sale el número. Con dos datos cualquier hipótesis encaja —2, 4 puede ser "suma 2" o "duplica"— y la hipótesis que elijas te va a dar una confirmación falsa, porque los dos casos que la generaron no pueden refutarla. El tercero es el primer dato con poder de refutación.

Definiste eje de variación: la dimensión a lo largo de la cual tus casos se diferencian, con sus tres partes —lo que cambia, lo que se queda, el nombre que le pones—. Y viste por qué elegir mal el eje no es un error local: queda escrito en una firma y se propaga a cada sitio de llamada.

Recorriste el caso de Boletia con sus tres proveedores de pago. La interfaz charge(amount_cents, currency) -> str, escrita con dos casos, tomó cuatro decisiones implícitas que el tercer proveedor rompió todas. Y en vez de rediseñarse, se deformó: apareció el prefijo "pending:", el condicional por proveedor volvió peor disfrazado, y hoy hay catorce sitios que conocen esa convención en lugar de las tres funciones duplicadas que habría habido sin abstraer.

Aprendiste los tres pasos de la ejecución —escribir, duplicar con registro, y en el tercer caso rediseñar sobre los tres en vez de encajar el tercero en la forma de los dos— y el matiz que separa a quien entiende la regla de quien la recita: el contador cuenta conocimiento, no texto. Dos porcentajes iguales no son duplicación; dos validaciones escritas distinto sí lo son.

Y viste las excepciones legítimas —costo catastrófico de divergencia, tercer caso ya firmado, doble de prueba, exigencia del entorno— junto con la que no lo es: "sé que van a venir más" es precisamente la creencia que hizo necesaria la regla.

Antes de avanzar deberías poder: explicar por qué tres y no dos sin recurrir a "es lo que dicen"; identificar el eje de variación implícito en una firma de método; y aplicar la pregunta del cambio simultáneo para separar duplicación de coincidencia.

Nos queda una cuenta pendiente. En el caso de Boletia dijimos que sin la abstracción habría habido tres funciones duplicadas, y con ella hay catorce sitios acoplados a una convención escondida. Eso suena a que la duplicación habría sido mejor, y eso choca de frente con todo lo que se enseña sobre no repetirse. La lección 4 se ocupa de ese choque: compara la duplicación con la abstracción equivocada, explica por qué una se ve y se arregla mientras la otra se hereda y nadie se atreve a tocarla, y llega a una conclusión que suena a herejía y no lo es.

Recursos