Módulo 2: Cuándo NO abstraer

6. El anti-patrón del plugin para una sola implementación

Descripción

Al terminar esta lección vas a reconocer de inmediato el olor más útil de todo este módulo —una interfaz con un solo implementador— y vas a tener las nueve señales que lo acompañan, para detectarlo sin depender de la intuición. Vas a ver el código completo del rincón plugins/ de Boletia, los cinco archivos enteros, y vas a entender cómo se llegó ahí sin que nadie hiciera nada estúpido. Vas a conocer las cinco situaciones en las que una interfaz con un solo implementador se justifica, para que no confundas el olor con una regla. Y vas a salir con el procedimiento de desmontaje en seis pasos, cada uno de los cuales deja el sistema funcionando.

Esta es la lección donde el módulo aterriza. Las cuatro anteriores construyeron el criterio en piezas: el costo de una capa, el momento en que tienes información suficiente, el daño de equivocarse de eje, y la motivación que produce el error. Aquí las cuatro se aplican al mismo caso, y el caso es el extremo: no es una abstracción hecha con dos casos, es una hecha con cero casos de variación real.

Y es la lección que prepara directamente el proyecto. El procedimiento de desmontaje que vas a ver aquí es literalmente el que vas a ejecutar en la lección 8, sobre este mismo código.

Conexión con el módulo: la lección 2 te dio la prueba de una línea —¿qué dejo de tener que saber gracias a esta capa?— y aquí la respuesta va a ser "nada". La 3 te dio la pregunta de cuántas implementaciones existen, y aquí la respuesta es "una". La 4 te dio el procedimiento de salida —volver a duplicar, limpiar, re-decidir— y aquí lo vas a ver en su versión completa y con red de seguridad. La 5 te dio la prueba de las tres preguntas, y este caso falla las tres a la vez. La lección 7 va a subir un nivel y mostrar que todo esto era un solo tradeoff visto desde ángulos distintos. Y la 8 te pone a hacerlo.

Marta es la única empleada

Llamas al servicio al cliente de una empresa. Contesta una grabación:

"Gracias por comunicarse. Para ventas, marque uno. Para soporte técnico, marque dos. Para facturación, marque tres. Para hablar con un representante, permanezca en la línea."

Marcas dos. Música de espera. Cuarenta segundos después contesta Marta.

Marcas uno la siguiente vez, por curiosidad. Música de espera. Contesta Marta.

Marcas tres. Marta.

Marta es la única empleada. El menú existe, el sistema de enrutamiento existe, la música de espera existe, y los tres caminos llevan al mismo lugar. Cada persona que llama paga cuarenta segundos y tres decisiones para llegar a alguien con quien podría haber hablado directamente.

Fíjate en que el menú no está mal hecho. Está bien grabado, las opciones son claras, el enrutamiento funciona sin fallas. Si contrataran a tres personas mañana, el sistema estaría listo. Llevan tres años con Marta.

Y hay un detalle más, que es el que más se parece al software: cuando algo sale mal —la llamada se corta, o el menú se cuelga— nadie sabe si el problema es de Marta o del conmutador. Antes había un lugar donde algo podía fallar. Ahora hay dos, y uno de ellos no aporta nada.

Eso es una arquitectura de plugins con un solo plugin. No es un error de ejecución: es un error de proporción. Y como el menú se ve profesional —las empresas grandes tienen menú— nadie propone quitarlo.

El olor, en una frase

Una interfaz con un solo implementador es una pregunta, no un pecado. La pregunta es: ¿por qué está aquí?

Digo "pregunta" y no "error" a propósito, porque hay cinco casos donde la respuesta es buena y los vamos a ver. Lo que no puede pasar es que la pregunta no se haga.

Las nueve señales

El olor casi nunca viene solo. Estas son las señales que lo acompañan, ordenadas de más a menos evidente:

#SeñalQué te está diciendo
1Una clase base abstracta o interfaz con un implementadorLa variación es imaginaria
2Una fábrica que siempre construye lo mismoLa decisión que la fábrica toma no es una decisión
3Un supports() o can_handle() que devuelve True sin condicionesConfesión escrita de que no hay competencia
4Un campo de configuración cuyo único valor observado es unoLa configurabilidad es teórica
5Un registro, mapa o catálogo con una sola entradaEstás recorriendo una lista de un elemento
6Una rama else o un raise NotFound que nunca se ejecutaCódigo muerto disfrazado de robustez
7Un directorio impls/, providers/, handlers/ con un solo archivoEl plural es una predicción, no una descripción
8Nombres genéricos —Manager, Engine, Handler, Processor— para algo que hace una cosaEl nombre describe la categoría imaginada, no el trabajo real
9Documentación de "cómo agregar un nuevo X" sin ningún X agregadoEl mecanismo se documentó y nunca se usó

Y la prueba definitiva, que no está en el código sino en el historial:

# ¿Cuántas implementaciones se agregaron desde que existe el mecanismo?
git log --diff-filter=A --name-only -- 'boletia/plugins/impls/*'

Si en dos, tres o cinco años nadie agregó una segunda, la predicción falló. Y eso ya no es una opinión tuya contra la de quien lo escribió: es un dato. La lección 3 decía que abstraer temprano convierte una observación en una apuesta; el historial de Git es donde se ve el resultado de la apuesta.

Ejemplo trabajado: el rincón plugins/ completo

Aquí está el código entero. Léelo con calma; después lo vamos a contar.

# Archivo: plugins/base.py                                        (38 líneas)
from abc import ABC, abstractmethod


class SeatingPlugin(ABC):
    """Contrato que debe cumplir cualquier estrategia de asignación de asientos."""

    @abstractmethod
    def name(self) -> str:
        """Nombre único con el que el plugin se registra."""

    @abstractmethod
    def supports(self, event) -> bool:
        """¿Este plugin sabe manejar este evento?"""

    @abstractmethod
    def assign_seat(self, order, ticket) -> str | None:
        """Asigna un asiento al boleto y devuelve su etiqueta, o None si no aplica."""

    @abstractmethod
    def release_seat(self, ticket) -> None:
        """Libera el asiento cuando se cancela una orden."""

    @abstractmethod
    def render_map(self, event) -> dict:
        """Devuelve el mapa de asientos para pintarlo en el front."""
# Archivo: plugins/registry.py                                    (61 líneas)
import importlib
import pkgutil
import logging

from plugins.config import get_configured_name

log = logging.getLogger(__name__)

_REGISTRY: dict[str, "SeatingPlugin"] = {}
_DISCOVERED = False


class SeatingPluginNotFound(Exception):
    pass


def register(plugin_cls):
    """Decorador: cada implementación se anota con @register para darse de alta."""
    instance = plugin_cls()
    _REGISTRY[instance.name()] = instance
    return plugin_cls


def discover():
    """Importa todos los módulos de plugins/impls/ para que se auto-registren."""
    global _DISCOVERED
    if _DISCOVERED:
        return
    import plugins.impls as impls_pkg
    for _, module_name, _ in pkgutil.iter_modules(impls_pkg.__path__):
        log.debug("Descubriendo plugin de asientos: %s", module_name)
        importlib.import_module(f"plugins.impls.{module_name}")
    _DISCOVERED = True


def get_for(event):
    """Devuelve el plugin que corresponde a este evento."""
    discover()
    preferred = get_configured_name(event)
    if preferred and preferred in _REGISTRY:
        candidate = _REGISTRY[preferred]
        if candidate.supports(event):
            return candidate
    for plugin in _REGISTRY.values():
        if plugin.supports(event):
            return plugin
    raise SeatingPluginNotFound(f"Ningún plugin soporta el evento {event.id}")
# Archivo: plugins/config.py                                       (9 líneas)
DEFAULT_PLUGIN = "default"


def get_configured_name(event) -> str:
    """Nombre del plugin configurado para este evento.

    `event.seating_plugin` es una columna de la tabla `events`.
    En los tres años que lleva existiendo, siempre ha valido "default".
    """
    return getattr(event, "seating_plugin", None) or DEFAULT_PLUGIN
# Archivo: plugins/impls/default_seating.py                       (71 líneas)
from plugins.base import SeatingPlugin
from plugins.registry import register
from db import query, execute


@register
class DefaultSeatingPlugin(SeatingPlugin):
    """Asignación por defecto: el primer asiento libre, en orden alfabético."""

    def name(self) -> str:
        return "default"

    def supports(self, event) -> bool:
        return True                      # ← acepta cualquier evento. Siempre.

    def assign_seat(self, order, ticket) -> str | None:
        # Si el boleto ya trae asiento (el cliente lo eligió en el mapa), respétalo.
        if ticket.seat is not None:
            return ticket.seat
        row = query(
            "SELECT label FROM seats WHERE event_id = ? AND taken = 0 ORDER BY label LIMIT 1",
            ticket.event_id,
        )
        if row is None:
            return None                  # evento de admisión general: no hay asientos
        execute(
            "UPDATE seats SET taken = 1 WHERE event_id = ? AND label = ?",
            ticket.event_id, row.label,
        )
        return row.label

    def release_seat(self, ticket) -> None:
        if ticket.seat is None:
            return
        execute(
            "UPDATE seats SET taken = 0 WHERE event_id = ? AND label = ?",
            ticket.event_id, ticket.seat,
        )

    def render_map(self, event) -> dict:
        rows = query("SELECT label, taken FROM seats WHERE event_id = ?", event.id)
        return {"seats": [{"label": r.label, "taken": bool(r.taken)} for r in rows]}

Y así se usa, desde el corazón del sistema:

# Archivo: checkout/checkout.py   (fragmento)
from plugins.registry import get_for as get_seating_plugin

def checkout(order):
    ...
    event = load_event(order)
    plugin = get_seating_plugin(event)
    for ticket in tickets:
        ticket.seat = plugin.assign_seat(order, ticket)
    ...

Qué esperar de este código. Vamos a contarlo, porque el conteo es el argumento.

El inventario. Cinco archivos, ciento ochenta y tres líneas. Una interfaz de cinco métodos. Un decorador de registro. Un descubrimiento dinámico. Un mecanismo de configuración por evento. Y una implementación.

El trabajo real. Las nueve líneas de assign_seat, más las cinco de release_seat y las tres de render_map. Diecisiete líneas de trabajo dentro de ciento ochenta y tres. Diez líneas de andamiaje por cada línea que hace algo, y si cuentas solo la asignación —que es lo que el checkout llama—, veinte por una.

Las señales, marcadas una por una. De las nueve de la tabla, este rincón tiene siete:

  1. Interfaz con un implementador. ✓
  2. supports() devuelve True sin condiciones. ✓ — es la señal número tres y es la más elocuente de todas. Ese método existe para que varios plugins compitan por un evento. Devolver True incondicionalmente significa: "no hay competencia, y quien lo escribió lo sabía".
  3. Campo de configuración con un solo valor observado. ✓ — event.seating_plugin siempre ha valido "default" en ciento ochenta mil eventos.
  4. Registro con una sola entrada. ✓ — el for plugin in _REGISTRY.values() recorre una lista de un elemento.
  5. Rama que nunca se ejecuta. ✓ — el raise SeatingPluginNotFound es inalcanzable: supports() siempre devuelve True, así que el bucle siempre encuentra algo.
  6. Directorio impls/ con un solo archivo. ✓
  7. Documentación de "cómo agregar un plugin" en el README interno, con cero plugins agregados. ✓

El costo de leer. Para responder "¿cómo se elige el asiento?" hay que dar seis saltos y abrir cuatro archivos, tal como vimos en la lección 1. Y el salto seis tiene el detalle que más duele con las manos en el teclado: cuando le pides al editor "llévame a la definición de assign_seat", te lleva a base.py, al método abstracto que no tiene cuerpo. El tipo estático de la variable es la interfaz. La herramienta que existe para ahorrarte navegación te lleva al único lugar donde no hay nada.

El costo de mantener. Cuatro tests, sesenta y siete líneas, que prueban el registro y el descubrimiento —ninguno prueba que un asiento se asigne bien—. Tres commits en dos años, ninguno de funcionalidad: un renombre de carpeta, un arreglo de pkgutil tras subir a Python 3.11, y un log.debug para investigar por qué la primera compra después de cada despliegue tardaba medio segundo más. Y un incidente de cuarenta minutos con el checkout caído por un borrador que nadie usaba.

Y ahora el remate, que es lo más incómodo de esta lección. Aplica la prueba de la lección 2: ¿qué deja de tener que saber el checkout gracias a esta capa?

Nada. El checkout llama plugin.assign_seat(order, ticket). Si llamara directamente a una función assign_seat(ticket), sabría exactamente lo mismo. La capa no oculta un detalle incompatible, no protege de una API externa desordenada, no permite probar sin base de datos —la implementación habla con la base igual—. Redirige. No oculta. Ciento ochenta y tres líneas de redirección.

Cómo se llega ahí

Esta sección importa tanto como el diagnóstico, porque si crees que esto lo hace gente descuidada, no vas a reconocerlo cuando lo hagas tú.

Nadie escribió plugins/ en un arranque de vanidad. Se llegó por la suma de cinco cosas, y las cinco son razonables por separado.

Uno: una semilla real. Un organizador —el Teatro Metropolitan— preguntó en una llamada si algún día podría usar su propio sistema de butacas, porque tenían uno heredado. Fue una pregunta, no un requisito. Nadie firmó nada. Pero quedó en la cabeza de quien estaba escribiendo el código de asientos esa semana, y eso es suficiente. Las abstracciones especulativas casi siempre tienen un origen verdadero; lo que falta no es el dato, es la calibración del dato.

Dos: material técnico recién leído. El fin de semana anterior, esa persona leyó sobre el principio abierto-cerrado —un sistema debe estar abierto a la extensión y cerrado a la modificación— y sobre programar contra interfaces. Los dos son buenos principios. Los dos se enuncian sin su condición, que es: donde el cambio sea probable. Sin esa condición, "abierto a la extensión" se lee como "en todos lados", que es imposible y carísimo.

Tres: alguien dijo "hazlo extensible". Sin especificar cuánto, y sin que nadie preguntara qué significaba. "Extensible" es como "flexible" en la lección 2: una palabra con carga positiva y sin unidades. Nadie va a decir "no, hazlo rígido".

Cuatro, y este es el mecanismo más subestimado: el sesgo de simetría. Boletia ya tenía payments/, con su interfaz PaymentProvider y tres implementaciones reales, y ya tenía notifications/ con tres canales. Esas dos carpetas se ganaron su estructura porque de verdad hay tres cosas distintas en cada una. Cuando llegó el turno de los asientos, la forma ya estaba en el aire del proyecto: "así es como hacemos las cosas aquí".

Ese es el mecanismo por el que la sobre-ingeniería se propaga dentro de un mismo código base: se copia la forma sin copiar la razón. Y es especialmente difícil de resistir, porque quien lo hace está siendo consistente, que es una virtud. Cuando veas un rincón sobre-estructurado, busca a su lado el rincón bien estructurado del que se copió; casi siempre está.

Cinco: nadie preguntó cuántos hay hoy. Y no por descuido: porque preguntarlo, en una revisión de código, suena a estar en contra de la calidad. Decir "¿de verdad necesitamos un registro dinámico?" cuando la otra persona está claramente esforzándose por hacerlo bien es incómodo. Es más fácil aprobar.

Suma las cinco: una semilla verdadera, un principio bien intencionado sin su condición, una instrucción sin unidades, un patrón local que se copió sin su razón, y una pregunta que no se hizo por incomodidad social. Ninguna de las cinco es un error de habilidad técnica, y por eso este anti-patrón sobrevive en equipos buenos.

Cuándo una interfaz con un implementador SÍ se justifica

Ahora la parte que evita que conviertas el olor en una regla, porque hay cinco casos donde la respuesta a "¿por qué está aquí?" es buena.

Uno: la frontera de un sistema externo, para poder probar. Si detrás de la interfaz hay entrada y salida de verdad —una llamada de red, disco, el reloj, un generador aleatorio— entonces la segunda implementación sí existe: es el doble que usas en los tests. Dos implementaciones, no una.

La condición es estricta y hay que respetarla: solo cuenta si lo de adentro es efecto real. Poner una interfaz delante de código puro "por testabilidad" no compra nada, porque el código puro ya es perfectamente testeable. Si tu justificación es la testabilidad, la pregunta de control es: ¿qué hace esto que un test no pueda ejecutar directamente? Si la respuesta es "nada", la justificación no aplica.

Dos: una frontera pública de paquete o librería. Si tu código es una librería que otros repositorios consumen, la interfaz es el contrato, y romperla no cuesta un refactor: cuesta coordinar con gente que no controlas. Aquí toda la cuenta de la lección 2 cambia, porque el "costo de quitar" incluye a terceros. La pregunta de control: ¿alguien fuera de mi repositorio puede depender de esto?

Tres: cuando el entorno lo exige. Algunos frameworks requieren que implementes una interfaz para registrar algo. Ahí no estás eligiendo un diseño: estás cumpliendo un requisito de la plataforma, y ese es un costo del entorno, no una decisión tuya.

Cuatro: cuando la segunda implementación existe, pero no en tu repositorio. El caso típico es una versión de código abierto y una comercial, o un núcleo compartido entre dos productos. Hay dos implementaciones reales; simplemente no las ves las dos al mismo tiempo. La pregunta de control: ¿puedo señalar dónde vive la segunda? Si puedes, cuenta.

Cinco: cuando la variación es de entorno y ya está en uso. Un Clock que en producción devuelve la hora real y en los tests devuelve una hora congelada. Un almacenamiento que en producción es un bucket y en desarrollo es una carpeta local. Son dos implementaciones y las dos se ejecutan, cada una en su entorno.

Y ahora la anti-excepción, la más común de todas:

"Es para cumplir con el principio abierto-cerrado."

El principio no dice que todo tenga que estar abierto a la extensión. Es imposible: un sistema abierto a toda extensión posible es un sistema sin ninguna decisión tomada, es decir, no es un sistema. El principio dice que el código debe estar abierto a la extensión en los ejes donde el cambio es probable, y cerrado en los demás. Elegir esos ejes es exactamente el trabajo de la lección 3. Invocar el principio sin nombrar el eje es invocar la mitad del principio.

Cómo se desmonta sin romper nada

Aquí está el procedimiento. La regla que lo gobierna es una sola y es innegociable:

Cada paso deja el sistema funcionando y los tests en verde. Si un paso no se puede hacer sin romper algo, pártelo en dos.

Y el orden general: de afuera hacia adentro. Primero cortas el uso, después borras lo que quedó sin usar. Al revés no funciona: si empiezas borrando la interfaz, todo se cae a la vez y pierdes la capacidad de avanzar en pasos pequeños.

Paso 0 — Pon la red. Antes de tocar una línea, escribe un test que fije el comportamiento observable, no la estructura:

# Archivo: tests/test_seating_behavior.py
# Este test no menciona plugins, registro ni interfaces. Solo qué debe pasar.
# Por eso sigue sirviendo después del desmontaje: es la red, no el andamio.

def test_assigns_the_first_free_seat_in_order():
    event = make_event_with_seats(["A-1", "A-2", "A-3"])
    ticket = make_ticket(event, seat=None)
    assert assign_seat_for(ticket) == "A-1"

def test_does_not_reassign_a_taken_seat():
    event = make_event_with_seats(["A-1", "A-2"])
    first = assign_seat_for(make_ticket(event, seat=None))
    second = assign_seat_for(make_ticket(event, seat=None))
    assert {first, second} == {"A-1", "A-2"}     # dos boletos, dos asientos distintos

def test_returns_none_for_general_admission():
    event = make_event_with_seats([])             # el evento no tiene asientos
    assert assign_seat_for(make_ticket(event, seat=None)) is None

def test_keeps_the_seat_the_customer_already_picked():
    event = make_event_with_seats(["A-1", "B-7"])
    assert assign_seat_for(make_ticket(event, seat="B-7")) == "B-7"

Fíjate en la función auxiliar assign_seat_for: es un envoltorio de una línea que hoy llama al plugin y mañana llamará a la función directa. Ese pequeño truco es lo que permite que el mismo test valga antes y después. Un test que menciona la estructura no es una red: es una segunda cosa que hay que desmontar.

Paso 1 — Corta el uso: reemplaza la resolución dinámica por la concreta.

# Archivo: checkout/checkout.py   (paso 1)
- from plugins.registry import get_for as get_seating_plugin
+ from plugins.impls.default_seating import DefaultSeatingPlugin

  def checkout(order):
      ...
-     plugin = get_seating_plugin(event)
+     plugin = DefaultSeatingPlugin()
      for ticket in tickets:
          ticket.seat = plugin.assign_seat(order, ticket)

Corre los tests. Verde. El registro sigue existiendo, pero ya nadie lo llama desde el checkout. Este es un cambio de dos líneas y se puede revisar en treinta segundos, que es exactamente lo que quieres de un primer paso.

Paso 2 — Comprueba que no queda nadie más.

grep -rn "plugins.registry\|plugins.config\|get_seating_plugin" boletia/

Si aparecen otros sitios de llamada, repite el paso 1 en cada uno, uno por commit. No sigas hasta que la búsqueda devuelva solo los archivos que vas a borrar.

Paso 3 — Borra el andamiaje que quedó sin uso.

Fuera registry.py, config.py y el __init__.py que disparaba el descubrimiento. Y fuera también los cuatro tests que probaban el registro: prueban algo que ya no existe, y conservarlos sería conservar el andamio después de quitar el edificio.

Corre los tests de comportamiento. Verde. Aquí ya desaparecieron ciento cuatro de las ciento ochenta y tres líneas, y con ellas el descubrimiento dinámico, la primera petición lenta después de cada despliegue, y la posibilidad de que un archivo suelto tire el checkout.

Paso 4 — Colapsa la jerarquía.

DefaultSeatingPlugin ya no hereda nada útil. Quita SeatingPlugin de sus bases, borra base.py, y borra los métodos que existían solo para el registro:

# Archivo: plugins/impls/default_seating.py   (paso 4)
- from plugins.base import SeatingPlugin
- from plugins.registry import register
  from db import query, execute

- @register
- class DefaultSeatingPlugin(SeatingPlugin):
+ class DefaultSeatingPlugin:
      """Asignación por defecto: el primer asiento libre, en orden alfabético."""

-     def name(self) -> str:
-         return "default"
-
-     def supports(self, event) -> bool:
-         return True
-
      def assign_seat(self, order, ticket) -> str | None:
          ...

Verde. Se fueron base.py y dos métodos que solo servían al mecanismo.

Paso 5 — Convierte la clase en funciones, si no tiene estado.

Mira lo que queda: una clase sin __init__, sin atributos, sin estado. Los tres métodos no usan self para nada. Eso no es un objeto: es un espacio de nombres disfrazado de clase, y en Python un módulo ya es un espacio de nombres.

# Archivo: seating/seating.py   (el resultado final: 34 líneas)
"""Asignación de asientos: el primer libre en orden alfabético."""

from db import query, execute


def assign_seat(ticket) -> str | None:
    """Asigna el primer asiento libre del evento. None si es admisión general."""
    # Si el boleto ya trae asiento (el cliente lo eligió en el mapa), respétalo.
    if ticket.seat is not None:
        return ticket.seat
    row = query(
        "SELECT label FROM seats WHERE event_id = ? AND taken = 0 ORDER BY label LIMIT 1",
        ticket.event_id,
    )
    if row is None:
        return None                      # evento de admisión general: no hay asientos
    execute(
        "UPDATE seats SET taken = 1 WHERE event_id = ? AND label = ?",
        ticket.event_id, row.label,
    )
    return row.label


def release_seat(ticket) -> None:
    """Libera el asiento cuando se cancela una orden."""
    if ticket.seat is None:
        return
    execute(
        "UPDATE seats SET taken = 0 WHERE event_id = ? AND label = ?",
        ticket.event_id, ticket.seat,
    )


def render_map(event) -> dict:
    """Mapa de asientos del evento, para pintarlo en el front."""
    rows = query("SELECT label, taken FROM seats WHERE event_id = ?", event.id)
    return {"seats": [{"label": r.label, "taken": bool(r.taken)} for r in rows]}

Y el checkout:

# Archivo: checkout/checkout.py   (final)
from seating.seating import assign_seat

def checkout(order):
    ...
    for ticket in tickets:
        ticket.seat = assign_seat(ticket)      # un salto, un archivo

Nota que assign_seat perdió el parámetro order, que nunca usaba. Ese parámetro existía porque la interfaz lo pedía, no porque el trabajo lo necesitara. Las interfaces especulativas suelen pedir más de lo que hace falta, precisamente porque se diseñan para casos imaginados.

Paso 6 — Limpia lo que quedó colgando, con una advertencia.

Queda la columna seating_plugin de la tabla events, la sección del README interno que explica cómo agregar un plugin, y los imports muertos.

Los dos últimos se borran y ya. La columna, no. Y esta es la parte de la lección que conecta con YAGNI, así que léela con atención: el código es reversible y el esquema de datos no. Borrar una columna en el mismo cambio significa que si algo sale mal, revertir el despliegue no basta —el dato ya no está—.

El camino correcto tiene tres tiempos, separados por días:

  1. Deja de escribirla. El código nuevo no la toca.
  2. Confirma que nadie la lee. Búscala en el código, en los reportes, en las consultas del panel de administración y en cualquier proceso externo.
  3. Bórrala en una ventana aparte, con respaldo, cuando los dos pasos anteriores lleven un tiempo en producción sin novedad.

Esa asimetría —código reversible, datos no— es una de las distinciones más útiles del oficio. La lección 5 la anticipó con el ejemplo de UTC; aquí la ves aplicada.

El resultado, en números:

AntesDespués
Archivos51
Líneas18334
Conceptos nuevos del proyecto4 (SeatingPlugin, registro, descubrimiento, seating_plugin)0
Saltos para responder "¿cómo se elige el asiento?"61
Tests que prueban el andamiaje4 (67 líneas)0
Tests que prueban el comportamiento04
Comportamiento observableidéntico

Mira las dos últimas filas juntas, porque son el resumen del proyecto. Antes había sesenta y siete líneas de tests que no probaban ninguna funcionalidad; después hay cuatro tests que sí prueban lo que le importa al usuario. Y el comportamiento del sistema no cambió ni un carácter.

Y bajo qué condición volvería a ponerse

Un desmontaje sin esta parte está incompleto, y es lo primero que va a preguntar quien revise tu trabajo. La respuesta, con dos niveles:

Nivel uno — dos o tres algoritmos internos. Si Boletia necesita, además de "primer libre", un "mejor asiento disponible" para teatros con calidad de visión por fila, y un "asientos contiguos para el grupo completo" en compras múltiples, entonces hay tres comportamientos reales. Ahí sí abstraes. Y aun así no hace falta un registro dinámico: alcanza con un diccionario literal de tres entradas, elegido por el tipo de evento. Diez líneas.

Nivel dos — código de terceros que no despliegas tú. Si un organizador grande necesita conectar su propio sistema de butacas sin que Boletia despliegue código, entonces sí hace falta un mecanismo de extensión con carga dinámica. Ese es el único escenario que justifica lo que había.

Fíjate en que el nivel uno es infinitamente más probable que el nivel dos, y que la solución del nivel uno es un diccionario. La lección 7 te va a mostrar que entre "llamada directa" y "plugin cargado por reflexión" hay unos siete peldaños, y que casi siempre el correcto es el segundo o el tercero.

Errores comunes

Desmontar de adentro hacia afuera (de método). Qué pasa: alguien decide quitar la abstracción y empieza por lo que más le molesta —la clase base abstracta—, la borra, y de inmediato se rompen la implementación, el registro, el checkout y los tests, todo al mismo tiempo. Pasa las siguientes tres horas arreglando cosas sin poder correr nada, y termina revirtiendo. Por qué pasa: la interfaz es lo que se siente como "el problema", así que es lo primero que uno quiere quitar. Pero es la base de la pirámide, no la punta. Cómo detectarlo: si después de tu primer cambio no puedes correr los tests, empezaste por el lugar equivocado. Cómo corregirlo: de afuera hacia adentro, siempre. Corta el uso primero, y borra solo lo que ya nadie usa. La búsqueda del paso 2 es tu guía: borra únicamente lo que no aparece en ningún grep.

Contar clases en vez de contar implementaciones (de diagnóstico). Qué pasa: alguien encuentra una interfaz con un solo implementador en el código de producción, aplica el olor, y quita una abstracción que sí se ganaba su lugar porque la segunda implementación era el doble usado en cincuenta tests. Al quitarla, esos cincuenta tests pasan a hablar con un servicio externo real, se vuelven lentos e inestables, y alguien termina desactivándolos. Por qué pasa: el olor se enuncia como "una interfaz con un implementador" y la gente cuenta clases en src/, no en tests/. Cómo detectarlo: antes de aplicar el olor, busca los implementadores en todo el repositorio, incluidos los tests, y pregunta si hay una versión distinta corriendo en otro entorno o en otro repositorio. Cómo corregirlo: la pregunta correcta no es "¿cuántas clases heredan de esto?" sino "¿cuántos comportamientos distintos se ejecutan de verdad, en algún entorno?". Si la respuesta es dos o más, el olor no aplica.

Borrar la columna de la base de datos en el mismo cambio (de riesgo). Qué pasa: el desmontaje sale perfecto, el código queda limpio, y en el mismo despliegue va la migración que borra events.seating_plugin. Dos días después aparece un reporte del área de operaciones que leía esa columna, y ya no hay dato que recuperar. Por qué pasa: se trata el esquema como si fuera código, y no lo es: revertir un despliegue restaura el código, no los datos borrados. Cómo detectarlo: si tu cambio incluye a la vez una eliminación de código y una migración destructiva, sepáralos. Cómo corregirlo: los tres tiempos del paso 6 —deja de escribirla, confirma que nadie la lee, bórrala en una ventana aparte con respaldo—. Y quédate con la regla general, que vale para toda tu carrera: el código es reversible; los datos no.

Ejercicios

Ejercicio 1 — ¿Olor o excepción legítima? Para cada caso, decide si el olor aplica y justifica en dos líneas.

(a) interface EmailSender con una sola implementación, SmtpEmailSender, y un FakeEmailSender en la carpeta de tests que se usa en ochenta pruebas. (b) interface ReportExporter con CsvExporter, y en impls/ no hay nada más. En el README interno hay una sección "cómo agregar un exportador". (c) interface Clock con SystemClock en producción y FrozenClock en tests. (d) abstract class BaseValidator con un solo hijo, OrderValidator, y ningún doble en tests porque la validación es lógica pura sin efectos. (e) interface StorageBackend con S3Storage en el repositorio principal, y LocalStorage en el repositorio de la versión de código abierto del mismo producto.

Ver solución

(a) Excepción legítima. Hay dos implementaciones reales y las dos se ejecutan: una en producción y otra en ochenta tests. Además, lo de adentro es efecto real —red y credenciales—, así que la testabilidad es un beneficio verificable, no una excusa. Quitar esta interfaz haría que ochenta tests intenten mandar correos.

(b) El olor aplica, y con las nueve señales encendidas. Un implementador, un directorio en plural con un archivo, y documentación de cómo extender sin ninguna extensión. La señal nueve es especialmente elocuente aquí: alguien escribió el manual de un mecanismo que nadie usó nunca. Verifica el historial y, si nadie agregó un exportador en años, desmonta.

(c) Excepción legítima, y del caso más claro de todos. El reloj es una dependencia del entorno; controlarlo es la única forma de escribir tests deterministas sobre cosas que dependen de la fecha —el descuento de early-bird, por ejemplo, que en Boletia depende de una fecha de corte—. Dos implementaciones, las dos en uso.

(d) El olor aplica. Y fíjate en la razón que se da: "no hay doble porque la validación es lógica pura sin efectos". Esa frase es exactamente la que descalifica el argumento de testabilidad. El código puro ya es testeable; ponerle una interfaz delante no agrega nada y sí quita: un salto, un archivo y un concepto. Además, BaseValidator es la señal ocho —un nombre genérico para algo que hace una cosa concreta—.

(e) Excepción legítima, del cuarto tipo: la segunda implementación existe pero vive en otro repositorio. La pregunta de control se contesta bien —puedes señalar dónde está—. Ojo con un detalle: eso hace que esta interfaz sea también una frontera pública, así que cambiarla es más caro de lo que parece desde tu repositorio. Aplica la advertencia de la lección 2.

Por qué funciona: tres de los cinco casos son excepciones legítimas. Si el olor te diera positivo siempre, sería inútil como herramienta de diagnóstico. Lo que lo hace valioso es que convierte una sensación en una pregunta con respuestas posibles, y las respuestas se pueden verificar.

Ejercicio 2 — El orden importa. Aquí están los seis pasos del desmontaje, desordenados. Ponlos en orden y explica, para cada uno, qué se rompería si lo hicieras antes de tiempo.

(i) Borrar base.py y quitar la herencia. (ii) Escribir el test de comportamiento. (iii) Borrar registry.py y config.py. (iv) Cambiar el checkout para instanciar la clase concreta. (v) Convertir la clase en funciones de módulo. (vi) Buscar otros usuarios del registro.

Ver solución

El orden: (ii) → (iv) → (vi) → (iii) → (i) → (v).

  • (ii) primero. Sin la red, cualquier paso siguiente es a ciegas: si el comportamiento cambia, te enteras en producción. Y el test tiene que estar escrito contra el comportamiento, no contra la estructura; si lo escribes mencionando el registro, en el paso (iii) se rompe y ya no sabes si lo que falló fue tu red o tu desmontaje.
  • (iv) segundo. Es el corte del uso, y es el cambio más pequeño posible: dos líneas. Si lo hicieras después de (iii), el checkout estaría importando un módulo borrado y el sistema entero estaría caído entre un paso y otro.
  • (vi) tercero. Antes de borrar hay que saber quién más usa lo que vas a borrar. Si lo saltas y hay un segundo sitio de llamada —un comando de administración, una tarea programada, un test de integración—, el paso (iii) lo rompe y probablemente no lo descubras hasta que ese código se ejecute, que puede ser el fin de mes.
  • (iii) cuarto. Ahora sí es seguro: nadie llama al registro. Aquí se van más de la mitad de las líneas.
  • (i) quinto. La clase base se borra cuando ya nada la necesita. Si lo hicieras primero, se rompen a la vez la implementación, el registro y los tests del andamiaje, y pierdes la capacidad de avanzar en pasos verificables.
  • (v) último. Es el paso más cosmético y el único que se puede omitir sin perder casi nada. Por eso va al final: si algo se complica, puedes parar aquí y el resultado ya es enormemente mejor que el original.

La regla que resume el orden: de afuera hacia adentro, y lo destructivo al final. Y una propiedad que conviene notar: en este orden, puedes detenerte después de cualquier paso y el sistema queda funcionando y mejor que antes. Un desmontaje que solo sirve si lo terminas completo es un desmontaje mal diseñado, porque el trabajo real siempre se interrumpe.

Por qué funciona: el orden no es una preferencia estética. Cada paso tiene una precondición concreta, y saltársela produce un fallo específico y predecible. Poder decir qué se rompe es lo que convierte el procedimiento en algo que puedes defender en una revisión.

Ejercicio 3 — Redacta el argumento con el historial. Vas a proponer el desmontaje en una revisión. Escribe el mensaje —máximo diez líneas— que abre la conversación. Tiene que incluir: la evidencia del historial, el conteo, el costo concreto que ya se pagó, y la condición bajo la cual volverías a poner el mecanismo. Y tiene que estar escrito de forma que no suene a reproche hacia quien lo escribió.

Ver solución

Una versión que funciona:

Propuesta: quitar el mecanismo de plugins de asientos.

Datos: plugins/ son 5 archivos y 183 líneas, de las cuales 17 hacen trabajo. En los dos años que lleva existiendo se registraron 0 plugins; la columna events.seating_plugin vale "default" en los 180 mil eventos publicados, y supports() devuelve True sin condiciones. Los 3 commits del período son mantenimiento: un renombre, un arreglo por la subida a Python 3.11 (medio día) y un log.debug. En noviembre, un archivo a medias en impls/ tiró el checkout 40 minutos porque el descubrimiento importa todo lo que encuentra.

Propongo dejar seating/seating.py con las mismas funciones y el mismo comportamiento —34 líneas, 1 salto en vez de 6—, en seis pasos con tests de comportamiento antes de tocar nada. La columna se retira aparte, sin migración destructiva en este cambio.

Volvería a poner un mecanismo de extensión si aparecen dos algoritmos más de asignación —y en ese caso bastaría un diccionario, no un registro dinámico— o si un organizador necesita conectar código propio sin que nosotros despleguemos.

Fíjate en cinco decisiones de redacción, porque el contenido técnico es la mitad del trabajo:

  1. Datos primero, opinión después. No dice "esto está sobre-diseñado". Dice cero, ciento ochenta mil, tres commits, cuarenta minutos. Contra un número se discute; contra un adjetivo se pelea.
  2. Ninguna referencia a personas. No aparece "quien escribió esto", ni "no se pensó bien". El sujeto de todas las frases es el código.
  3. El comportamiento se preserva explícitamente. "Las mismas funciones y el mismo comportamiento" desactiva de entrada el miedo principal de quien revisa.
  4. El plan se ve seguro. "Seis pasos con tests antes de tocar nada" y "sin migración destructiva" comunican que esto no es un arranque de limpieza.
  5. La condición de reversión está escrita. Esto es lo que convierte la propuesta en criterio y no en gusto personal, y de paso le da a quien no esté de acuerdo algo concreto que discutir: puede decir "esa condición ya se cumple, mira este contrato". Y si tiene razón, ganaste tú también.

Por qué funciona: la mitad de las propuestas correctas se rechazan por cómo están escritas. El módulo 7 se dedica entero a nombrar problemas de forma que sean accionables, y el módulo 8 a justificar cambios por su porqué; este mensaje es un anticipo de las dos cosas. Guárdalo, porque es la plantilla de la entrega del proyecto.

Resumen y siguiente paso

En esta lección viste el anti-patrón completo. El olor se enuncia en una frase —una interfaz con un solo implementador es una pregunta, no un pecado— y viene acompañado de nueve señales, de las cuales el rincón plugins/ de Boletia tiene siete. La más elocuente: un supports() que devuelve True sin condiciones, que es la confesión escrita de que nunca hubo competencia. Y la prueba definitiva no está en el código: está en el historial. Cero implementaciones agregadas en dos años convierte una discusión de opiniones en un dato.

Leíste los cinco archivos completos y los contaste: ciento ochenta y tres líneas alrededor de diecisiete de trabajo, seis saltos para responder una pregunta que cabe en nueve líneas, un editor que al pedirle la definición te lleva al método vacío, cuatro tests que prueban el andamiaje y ninguno el comportamiento, y un incidente de cuarenta minutos causado por un archivo que nadie usaba.

Entendiste cómo se llega ahí sin que nadie haga nada estúpido: una semilla real mal calibrada, un principio bien intencionado enunciado sin su condición, una instrucción sin unidades, el sesgo de simetría —copiar la forma de un rincón vecino sin copiar su razón— y una pregunta que no se hizo porque hacerla resulta socialmente incómodo.

Conociste las cinco excepciones legítimas —frontera de sistema externo con doble de prueba, frontera pública de paquete, exigencia del entorno, segunda implementación en otro repositorio, variación de entorno en uso— y la anti-excepción más común: invocar el principio abierto-cerrado sin nombrar el eje donde el cambio es probable.

Y te llevas el procedimiento de seis pasos, con su regla innegociable —cada paso deja el sistema verde—, su orden —de afuera hacia adentro, lo destructivo al final— y su propiedad más valiosa: puedes detenerte después de cualquier paso y el resultado ya es mejor. Más la distinción que vale para toda tu carrera: el código es reversible; los datos no.

Antes de avanzar deberías poder: enunciar el olor y al menos cinco de sus nueve señales; distinguirlo de sus cinco excepciones legítimas con la pregunta de control de cada una; y ordenar los seis pasos del desmontaje explicando qué se rompe si te saltas uno.

Nos queda una pieza, y es la que convierte todo el módulo en criterio en vez de en una lista de reglas. Cuando quitamos el registro de plugins, el checkout pasó a depender directamente del código de asientos. Eso es acoplamiento, y el acoplamiento también es un costo. No lo eliminamos: lo cambiamos por otro. La lección 7 se ocupa de ese intercambio —quitar acoplamiento agrega indirección, quitar indirección agrega acoplamiento— y te da los tres ejes con los que se decide cuál de los dos venenos conviene en cada contexto.

Recursos