Módulo 7: Patrones como vocabulario de revisión
5. Anti-patrones: cuando un "patrón" es la trampa
Descripción
Al terminar esta lección vas a poder distinguir un patrón que se ganó su lugar de uno que se convirtió en el problema, y vas a tener nombre para los seis anti-patrones que de verdad aparecen en código real: Singleton como estado global, la fábrica de fábricas, el objeto ancla que todos importan, la herencia de cinco niveles, el intermediario vacío y la generalidad especulativa. Para cada uno vas a salir con cómo se reconoce, qué buena intención lo produjo y qué hace falta para desmontarlo.
Esto importa por una razón que a estas alturas de la guía ya deberías estar sintiendo. Los módulos 3 a 6 te dieron un catálogo de soluciones, y ese catálogo es exactamente lo que produce anti-patrones cuando se aplica sin criterio. Un anti-patrón casi nunca es ignorancia: es un patrón aplicado con entusiasmo en el lugar equivocado. La persona que puso una arquitectura de plugins para una sola implementación no era mala programadora. Era alguien que había leído sobre extensibilidad, que quiso hacer las cosas bien, y que no tenía todavía la pregunta que el módulo 2 te enseñó a hacer: ¿cuántas implementaciones existen hoy, de verdad?
Y hay una razón práctica, específica de este módulo: los anti-patrones son más difíciles de señalar en una revisión que los olores. Un olor es una señal impersonal en el código; nadie lo defendió nunca. Un anti-patrón fue una decisión, tomada por alguien con argumentos, y muchas veces esa persona sigue en el equipo. Diagnosticarlo bien exige más cuidado —y la lección 7 te va a dar el resto de las herramientas para eso—.
Conexión con el módulo: las lecciones 2 y 3 cubrieron los olores: señales locales en el código, que se detectan leyendo y contando. Esta lección sube un nivel a las decisiones de diseño con nombre. La diferencia práctica es grande: un olor se investiga y muchas veces se deja; un anti-patrón, cuando es real, hay que desmontarlo, y desmontarlo cuesta más que corregir un olor. Por eso la lección 6 —refactorizar hacia afuera de un patrón— viene inmediatamente después: es la técnica que aquí se necesita. Y la lección 4 sigue vigente: un anti-patrón se comunica con las mismas cinco partes, con un cuidado extra en la dirección y en el peso.
El organizador de cables que hizo todo peor
Alguien llega a una oficina donde hay cables por todas partes. Compra canaletas, amarra cables, los mete en una manguera plástica que recorre la pared, y en dos horas la oficina se ve impecable. Todo el mundo lo felicita, y con razón: era un desorden y ahora no lo es.
Tres semanas después alguien tiene que cambiar el cable de un monitor. Para llegar a él hay que abrir la canaleta, cortar cuatro amarres, sacar los quince cables de la manguera, encontrar el correcto —que no está etiquetado, porque estaban a la vista cuando se hizo el trabajo—, cambiarlo y rehacer todo. Lo que antes era un trámite de treinta segundos ahora toma cuarenta minutos.
Fíjate en tres cosas de esta historia, porque son las tres de todo anti-patrón.
La solución resolvía un problema real. El desorden de cables existía. No era una preocupación inventada.
La solución era una técnica legítima. Las canaletas no son un error; en una instalación fija son exactamente lo correcto. El problema no está en la técnica: está en aplicarla donde los cables cambian seguido.
El costo llegó después y a otra persona. Quien organizó los cables recibió el beneficio inmediato —la oficina se ve bien— y no pagó el costo, que llegó semanas más tarde y le tocó a quien tuvo que cambiar un monitor. Ese desfase temporal es la razón por la que los anti-patrones se propagan: el beneficio es visible y rápido, el costo es invisible y lento.
Un anti-patrón funciona igual. Es una solución con nombre, que se aplica repetidamente a un problema real, que parece buena en el momento y que produce más problema del que resolvió. Y como la canaleta, casi siempre es una técnica legítima usada donde no correspondía.
Anti-patrón, olor y "código malo": tres cosas distintas
Vale la pena separar tres términos que en conversación se usan indistintamente, porque la diferencia cambia cómo se habla de cada uno.
| Qué es | De dónde vino | Cómo se trata | |
|---|---|---|---|
| Código malo | Código que no funciona bien o que está escrito descuidadamente | De la prisa, del cansancio, de no saber | Se corrige |
| Code smell | Una señal superficial que sugiere un problema de estructura | Se acumuló; nadie lo decidió | Se investiga, y a veces se deja |
| Anti-patrón | Una decisión de diseño con nombre, que parecía buena y no lo era | Alguien lo eligió, con argumentos | Se desmonta, y hay que hablar con quien lo eligió |
La fila que importa es la última columna de la última fila. Un olor no tiene autor emocional: checkout.py llegó a trescientas líneas por acumulación, y decir "esto es un God object" no acusa a nadie en particular. Un anti-patrón sí tiene autor: alguien escribió el registro de plugins en una tarde, contento, pensando que estaba dejando el sistema preparado para el futuro.
Eso cambia dos cosas en la práctica. Primera, la evidencia tiene que ser más fuerte, porque vas a contradecir una decisión razonada. Segunda, el tono tiene que reconocer la intención, porque no reconocerla convierte una discusión técnica en una defensa personal. Las dos cosas se resuelven con la misma frase, que conviene tener a mano: "esto tenía sentido cuando se decidió; lo que cambió es que hoy sabemos cuántas implementaciones hay".
Un cuarto término que conviene descartar: deuda técnica es un atajo consciente que se paga después —y como toda deuda, puede ser una buena decisión—. Un anti-patrón no es deuda: nadie lo tomó como atajo, sino como inversión. Confundirlos lleva a conversaciones raras, donde alguien defiende una estructura elaborada diciendo "es deuda técnica, ya la pagaremos", cuando no hay nada que pagar: hay algo que quitar.
Los seis que de verdad aparecen
1. Singleton como estado global
Qué es. Una clase que garantiza una sola instancia y ofrece un punto global desde donde cualquiera puede tomarla. El módulo 4 le dedicó una lección entera; aquí interesa el aspecto de revisión.
La buena intención. Es genuina y suena impecable: "la configuración es una sola, no tiene sentido crear diez; y pasarla por parámetro a través de once módulos es ruido". Las dos mitades de esa frase son ciertas. El problema es que el Singleton resuelve la segunda con un costo que no se ve.
Cómo se ve en Boletia.
# Archivo: config.py
class Settings:
_instance = None
def __new__(cls):
# Solo existe una instancia en todo el proceso.
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._load_from_env()
return cls._instance
def _load_from_env(self):
self.STRIPE_KEY = os.environ["STRIPE_KEY"]
self.MP_TOKEN = os.environ["MP_TOKEN"]
self.SEATING_PLUGIN = os.environ.get("SEATING_PLUGIN", "default_seating")
settings = Settings() # importado en once archivos distintos
Cómo se reconoce. Tres señales, en orden de facilidad:
_instance,get_instance(),__new__sobrescrito, o un@lru_cachesobre una función sin argumentos.- Una variable de módulo que se importa en muchos archivos y que tiene estado.
- La señal decisiva, y la que sirve en una revisión: las pruebas. Si en algún test aparece
Settings._instance = None,monkeypatchsobre un módulo global, o unsetUpque resetea estado compartido, ahí está el diagnóstico servido. El test es la denuncia. Ninguna prueba haría eso si pudiera simplemente construir la configuración que necesita.
La consecuencia, dicha para una revisión. No digas "el Singleton es un anti-patrón". Di lo que cuesta: "once módulos dependen de una variable global, así que no se puede saber qué partes del sistema usan la configuración sin leer el sistema completo; y como _load_from_env corre en el primer uso, cualquier prueba que quiera otra configuración tiene que pelear con la instancia ya creada — que es exactamente lo que hace test_klarpay.py:14".
La dirección. Casi siempre es la misma y es más simple de lo que parece: conserva la instancia única, quita el acceso global. Se construye una sola vez en el arranque de la aplicación y se pasa a quien la necesite. Eso es inyección de dependencias, y el módulo 4 le dedicó su lección 6.
Un matiz importante, porque si no queda dicho suena a dogma: que exista una sola instancia de algo es perfectamente legítimo y muy común. Una conexión a base de datos, un pool de hilos, un caché. Lo que hace daño no es la unicidad: es el punto de acceso global que el patrón trae de contrabando.
2. La fábrica de fábricas
Qué es. Una capa de creación que crea otra capa de creación. ProviderFactoryProvider que devuelve un ProviderFactory que devuelve un Provider. Es el ejemplo canónico de indirección que no sirve a nadie.
La buena intención. "Si un día necesitamos cambiar cómo se crean las fábricas…". Es especulación sobre especulación: una capa por si acaso, encima de otra capa que ya era por si acaso.
Cómo se ve. Esta versión de Boletia nunca existió, pero es exactamente lo que alguien propuso en una revisión y por poco entra:
# Archivo: payments/factories.py — la propuesta que no entró
class PaymentProviderFactory:
def create(self, order): raise NotImplementedError
class StripeProviderFactory(PaymentProviderFactory):
def create(self, order):
return StripeProvider(StripeClient(api_key=settings.STRIPE_KEY))
class MercadoPagoProviderFactory(PaymentProviderFactory):
def create(self, order):
return MercadoPagoProvider(MercadoPagoClient(token=settings.MP_TOKEN))
class PaymentProviderFactoryProvider:
"""Devuelve la fábrica correcta para un nombre de proveedor."""
_FACTORIES = {
"stripe": StripeProviderFactory(),
"mercadopago": MercadoPagoProviderFactory(),
}
def get_factory(self, name):
return self._FACTORIES[name]
# Y en el checkout:
provider = factory_provider.get_factory(order.provider).create(order)
Cuenta las clases: cinco, más una interfaz, para hacer lo que este diccionario hace:
# Archivo: payments/registry.py — lo que sí entró
_PROVIDERS = {
"stripe": lambda: StripeProvider(StripeClient(api_key=settings.STRIPE_KEY)),
"mercadopago": lambda: MercadoPagoProvider(MercadoPagoClient(token=settings.MP_TOKEN)),
}
def provider_for(name):
if name not in _PROVIDERS:
raise UnknownProvider(name)
return _PROVIDERS[name]()
Cómo se reconoce. La regla más útil es contar saltos hasta el trabajo real: desde el punto de uso, ¿cuántos archivos hay que abrir para llegar al código que hace algo? Uno o dos es normal. Cuatro es señal. Y la señal específica de este anti-patrón: una clase cuyo único trabajo es devolver otra clase cuyo único trabajo es construir un objeto.
La consecuencia. Cada capa se paga en tres monedas: hay que leerla para entender el flujo, hay que mantenerla cuando algo cambia, y hay que reproducirla mentalmente cada vez que alguien depura un problema. Y ninguna de esas capas está resolviendo un problema que exista.
La dirección. Aplanar. Y como criterio general, el más útil de toda esta sección: el número de capas de creación debería ser igual al número de decisiones que de verdad se toman. Si la única decisión es "cuál de tres proveedores", hay una decisión, así que hay una capa: un diccionario.
3. El objeto ancla que todos importan
Qué es. Un módulo del que depende medio sistema, normalmente llamado utils, helpers, common, core o base. No tiene una responsabilidad: tiene la responsabilidad de "lo que no cupo en otro lado".
La buena intención. Impecable y muy razonable: "esta función la usan tres módulos, la pongo en un lugar común para no repetirla". Cada decisión individual de meter algo ahí es correcta. El problema es la acumulación.
Cómo se ve en Boletia.
# Archivo: utils/misc.py
SERVICE_FEE_RATE = 0.08 # ← una regla de negocio, aquí
COURTESY_LIMIT = 50 # ← otra
COUPON_RATES = {"CUMBRE10": 0.10} # ← otra más
def format_money(amount): ... # presentación
def parse_date(s): ... # parseo
def generate_cash_reference(oid): ... # ← lógica de pagos, aquí
def slugify(text): ... # texto
def send_slack_alert(msg): ... # ← integración externa, aquí
def is_valid_email(s): ... # validación
def courtesy_count(event_id): ... # ← una consulta a la base de datos, aquí
Ocho cosas sin ninguna relación entre sí, tres de las cuales son reglas de negocio y una hace una consulta a la base de datos. Y este archivo lo importan diecinueve módulos.
Cómo se reconoce. Dos señales que se cuentan:
- El nombre.
utils,helpers,common,misc,shared. Un nombre que no dice qué hay dentro es un nombre que autoriza a meter cualquier cosa. - El grado de entrada. Cuántos módulos lo importan. Si es "casi todos", tienes un ancla.
# Cuántos archivos importan el módulo sospechoso.
$ grep -rl "from utils.misc import\|import utils.misc" --include="*.py" . | wc -l
19
La consecuencia, y es de tres tipos. Primero, cualquier cambio ahí tiene alcance imprevisible: no puedes saber a quién afecta sin revisar diecinueve archivos. Segundo, arrastra dependencias: como courtesy_count consulta la base de datos, cualquier módulo que importe utils.misc acaba dependiendo del acceso a datos, aunque solo quisiera format_money. Y tercero, el más caro: las reglas de negocio que viven ahí no las encuentra nadie. Quien busca por qué una cortesía se limita a cincuenta va a buscar en pricing/ y en models/, no en utils/misc.py.
La dirección. No es "reorganizar utils". Es repartir por dueño: SERVICE_FEE_RATE y COUPON_RATES a pricing/, generate_cash_reference a payments/, courtesy_count al repositorio, send_slack_alert a notifications/. Lo que queda al final —format_money, parse_date, slugify, is_valid_email— son funciones genuinamente genéricas y sin estado, y ese módulo pequeño está bien que exista.
La regla que evita que vuelva a crecer: si algo tiene dueño natural, va con su dueño, aunque hoy lo usen tres módulos. utils es solo para lo que no pertenece a ningún dominio.
4. La herencia de cinco niveles
Qué es. Una jerarquía tan profunda que para entender qué hace un método hay que leer cinco archivos y reconstruir mentalmente el orden en que se llaman. La comunidad lo llama yo-yo problem, por el movimiento de subir y bajar por la jerarquía que hace tu lectura.
La buena intención. "Esto lo comparten dos clases, lo subo a una base común", repetido cinco veces. Cada paso individual es una refactorización de libro. El resultado acumulado no.
Ejemplo trabajado: rastrear un método a través de cinco archivos
Esto sí existe en Boletia, en el módulo de reportes, y llegó por acumulación honesta:
# reports/base_exporter.py
class BaseExporter:
def export(self, event_id):
rows = self._fetch(event_id)
rows = self._prepare(rows)
return self._write(rows, self._path(event_id))
def _prepare(self, rows): return rows
def _path(self, event_id): raise NotImplementedError
# reports/tabular_exporter.py
class TabularExporter(BaseExporter):
def _fetch(self, event_id): return db.fetch_attendees(event_id)
def _prepare(self, rows): return [self._row(r) for r in super()._prepare(rows)]
def _row(self, r): return {h: r[h] for h in HEADERS}
# reports/sorted_tabular_exporter.py
class SortedTabularExporter(TabularExporter):
def _prepare(self, rows): return sorted(super()._prepare(rows), key=self._sort_key)
def _sort_key(self, r): return r["name"]
# reports/attendee_exporter.py
class AttendeeExporter(SortedTabularExporter):
def _prepare(self, rows):
# Filtra los no pagados. Nadie recuerda quién agregó esto ni por qué.
return [r for r in super()._prepare(rows) if r["status"] == "paid"]
def _path(self, event_id): return f"/tmp/attendees_{event_id}"
# reports/csv_exporter.py
class CsvExporter(AttendeeExporter):
def _path(self, event_id): return super()._path(event_id) + ".csv"
def _write(self, rows, path): ...
Ahora contesta una pregunta simple: ¿en qué orden se procesan las filas de un CSV de asistentes? Para responder hay que abrir cinco archivos y seguir la cadena de super() de abajo hacia arriba: CsvExporter no toca _prepare, AttendeeExporter filtra, pero primero llama a super(), que es SortedTabularExporter, que ordena, pero primero llama a super(), que es TabularExporter, que mapea columnas, que primero llama a super(), que es BaseExporter, que no hace nada. Orden real: mapear → ordenar → filtrar.
Qué esperar de este rastreo. Lo primero: te tomó, siendo generoso, tres minutos — y era una de las preguntas más simples que se le pueden hacer al módulo. Multiplícalo por cada persona nueva que entre al equipo y por cada vez que alguien tenga que tocar un reporte.
Lo segundo, y es lo que convierte esto en un problema y no en una molestia: el orden que descubriste no lo eligió nadie. Ordenar antes de filtrar es trabajo de más, y está así porque SortedTabularExporter se agregó antes que AttendeeExporter. La jerarquía no solo escondió el orden: escondió que era accidental. Ese es el patrón general de este anti-patrón — cuando entender algo exige reconstruirlo, casi siempre hay adentro una decisión que nadie tomó.
Y lo tercero, que vale como advertencia para tu revisión: el editor no te ayuda aquí. "Ir a la definición" sobre _prepare te lleva a la del nivel actual, no a la cadena completa. Las herramientas que usas para leer código asumen jerarquías planas; en una de cinco niveles con super() entrelazado, estás solo.
Cómo se reconoce. Cuenta los niveles: más de dos o tres es señal. Y busca la señal específica, que es más diagnóstica que la profundidad: métodos que llaman a super() y además hacen algo. Un super() al principio o al final que solo delega es inofensivo; un super() en medio de una expresión —sorted(super()._prepare(rows), ...)— significa que el comportamiento está entrelazado entre niveles y que ninguno se entiende solo.
La consecuencia. Tres, y la tercera es la peor. Leer cuesta caro. Cambiar es peligroso, porque una modificación en un nivel intermedio afecta a todos los descendientes de formas difíciles de prever. Y la tercera: agregar una variante que no encaja en la línea recta obliga a distorsionar la jerarquía. El día que haga falta un exportador que no ordene, no hay dónde ponerlo sin duplicar o sin agregar una bandera.
La dirección. Aplanar y componer. En este caso, un solo export() con los pasos explícitos y un objeto de formato inyectado:
# reports/exporter.py
def export_attendees(event_id, writer):
# El orden de los pasos, visible de una sola lectura.
rows = db.fetch_attendees(event_id)
rows = [{h: r[h] for h in HEADERS} for r in rows]
rows = sorted(rows, key=lambda r: r["name"])
rows = [r for r in rows if r["status"] == "paid"]
return writer.write(rows, f"/tmp/attendees_{event_id}.{writer.extension}")
Cinco archivos y una jerarquía se convirtieron en una función de seis líneas donde el orden de los pasos se lee de corrido. La regla general que resume esto es vieja y sigue siendo la mejor: prefiere composición sobre herencia. Un nivel de herencia para compartir un esqueleto es un Template Method legítimo (módulo 3); cinco niveles no son un Template Method mejor, son otra cosa.
5. El intermediario vacío
Qué es. Una clase que solo delega en otra sin agregar nada. Fowler lo llama Middle Man; en su versión más extrema, cuando la clase además no tiene estado ni razón de existir, se le dice Poltergeist: aparece, pasa un mensaje y desaparece.
La buena intención. "Mejor no acoplar el checkout directo al servicio de pagos; le pongo una capa en medio". La intención —desacoplar— es correcta. Lo que falla es que una capa que no traduce nada no desacopla nada: solo agrega un salto.
Cómo se ve.
# Archivo: services/payment_service.py
class PaymentService:
def __init__(self, registry):
self.registry = registry
def charge(self, order):
return self.registry.provider_for(order.provider).charge(order)
def refund(self, order):
return self.registry.provider_for(order.provider).refund(order)
def available_providers(self):
return self.registry.available()
Cómo se reconoce. La prueba es directa: lee cada método y pregúntate si hace algo además de reenviar. Si la respuesta es "no" para todos, es un intermediario vacío. Una variante útil de la misma prueba: si borraras la clase y llamaras al colaborador directo, ¿perderías algo? Si no, sobra.
El falso positivo, que aquí es especialmente importante. Un Adapter (módulo 5) delega casi todo y no es un intermediario vacío, porque traduce: convierte el dict del SDK de Stripe en un ChargeResult, convierte pesos en centavos, convierte una excepción del proveedor en una del dominio. Un Facade (módulo 5) también delega y tampoco lo es, porque simplifica: ofrece un método donde antes había que llamar a cinco en orden. La pregunta que separa los tres casos: ¿esta capa cambia la forma, el vocabulario o el número de llamadas? Si cambia alguna, tiene valor. Si solo pasa la pelota, no.
La consecuencia. Es la más leve de los seis y conviene decirlo: cuesta un archivo más que leer y un salto más al depurar, nada más. En una revisión, casi nunca es bloqueante. Lo que sí conviene señalar es cuando el intermediario está creciendo: si en cada PR se le agrega un método de reenvío más, la conversación vale la pena antes de que tenga veinte.
La dirección. Quitarlo y llamar directo. O —y esta suele ser la mejor salida cuando la capa se puso "por si acaso"— darle una razón de existir: si hace falta registrar métricas de cada cobro, o traducir errores de proveedor a errores del dominio, ponlo ahí y la clase se justifica sola.
6. La generalidad especulativa
Qué es. Estructura de extensión construida para necesidades que nunca llegaron. Interfaces con un implementador, parámetros que siempre reciben el mismo valor, jerarquías con un solo hijo, mecanismos de plugins con un plugin.
Este ya lo conoces: el módulo 2 le dedicó su lección 6 completa, con plugins/ de Boletia como caso de estudio y con el desmontaje paso a paso. Lo incluyo aquí por dos razones. Primera, porque en un catálogo de anti-patrones no puede faltar, y es el más frecuente de todos en código de equipos que estudiaron patrones. Segunda, porque tiene una propiedad que los otros cinco no tienen y que vale la pena subrayar: es el único cuyo tratamiento es exclusivamente quitar. En los otros cinco hay una versión legítima de la misma técnica. En este no: si la extensión nunca se usó, no hay nada que preservar.
El recordatorio operativo, en una línea: la pregunta que lo diagnostica es "¿cuántas implementaciones distintas existen hoy, de verdad?", y si la respuesta es una, ya tienes tu caso.
Y una advertencia sobre cómo se propaga, que es específica de este módulo y que vas a ver en el proyecto. Una abstracción especulativa que sobrevive suficiente tiempo se convierte en un martillo. Alguien nuevo llega, ve que existe un mecanismo de plugins, y decide usarlo para algo que no tiene nada que ver —porque está ahí, porque parece el lugar "extensible", porque reutilizar se siente correcto—. En ese momento la abstracción que sobraba deja de ser inofensiva: pasa a acumular usos que después van a hacer más difícil quitarla. Es el mismo fenómeno que en el módulo 1 llamamos el síndrome del martillo nuevo, y aquí tiene una forma particularmente cara.
Las cuatro preguntas que diagnostican cualquiera de los seis
Más útil que memorizar los seis nombres es tener las preguntas que los detectan. Funcionan sobre código que nunca viste y sobre anti-patrones que no están en esta lista.
Una: ¿cuántas implementaciones o casos reales existen hoy? Si la estructura soporta N y hoy hay 1, la estructura no está resolviendo un problema que exista. Detecta generalidad especulativa y buena parte de la fábrica de fábricas.
Dos: ¿cuántos archivos hay que abrir para responder una pregunta simple? Elige una pregunta que un desarrollador nuevo haría —"¿en qué orden se procesan las filas?", "¿de dónde sale la credencial de Stripe?"— y cuenta los saltos. Más de tres es señal. Detecta la herencia profunda, la fábrica de fábricas y el intermediario.
Tres: ¿qué hace falta para probar esto solo? Esta es la mejor de las cuatro, y la razón es que la respuesta no admite discusión: o la prueba es de tres líneas o no lo es. Si para probar una función hay que resetear estado global, construir seis dependencias o levantar un registro, la estructura te está diciendo algo. Detecta el Singleton, el objeto ancla y el God object.
Cuatro: ¿esta estructura resuelve un problema que tenemos o uno que imaginamos? Es la pregunta de criterio y la más difícil de contestar honestamente, porque el problema imaginado siempre suena plausible. La forma de aterrizarla: "¿puedes nombrar una vez, concreta y pasada, en la que esta estructura nos ahorró trabajo?". Si la respuesta es un escenario futuro, ya tienes tu respuesta.
Por qué nacen de buenas intenciones, y por qué eso importa
Vale la pena decir explícitamente el patrón que atraviesa los seis casos, porque no es un adorno humanista: cambia cómo se diagnostica.
Los seis nacen de tres impulsos que son virtudes profesionales: no repetirse (que produce el objeto ancla y la herencia profunda), preparar el futuro (que produce la generalidad especulativa y la fábrica de fábricas) y desacoplar (que produce el Singleton mal usado y el intermediario vacío). Nadie llega a un anti-patrón por descuido. Se llega por aplicar una virtud sin la pregunta que la limita.
Esto tiene tres consecuencias prácticas para una revisión.
Primera: el argumento "esto es un anti-patrón" nunca convence. Quien lo escribió tenía un argumento, y "es un anti-patrón" no responde a ese argumento: lo descarta por nombre. Lo que sí funciona es aceptar la intención y mostrar que el costo no se pagó: "la idea de dejarlo extensible tenía todo el sentido; lo que pasó es que en dos años no hubo una segunda implementación, y mientras tanto entender el mapa de asientos cuesta tres archivos".
Segunda: la evidencia tiene que ser histórica, no teórica. Contra una decisión razonada, el argumento que funciona es un hecho del pasado del propio sistema: cuántas implementaciones hubo, cuántos archivos hay que abrir, qué hizo el último test para poder correr. Esos datos no se discuten. Los principios sí.
Tercera: hay que decir bajo qué condición la decisión sería correcta. Esto es lo que separa una crítica de una conversación. "Si en algún momento vendemos Boletia como plataforma y un cliente necesita su propia asignación de asientos, el registro vuelve — y con esta nota lo reconstruimos en una tarde". Con eso, quien la defendió no queda como alguien que se equivocó, sino como alguien que se adelantó. Muchas veces es literalmente cierto.
Errores comunes
Llamar anti-patrón a todo lo que no te gusta (de precisión). Qué pasa: la palabra tiene autoridad —suena a categoría establecida, casi a norma— y se empieza a usar para cualquier cosa que a uno le parece innecesaria. "Esto es un anti-patrón" aplicado a una decisión de estilo, a una convención del equipo o a algo que simplemente se hubiera hecho distinto. El efecto es que el término se devalúa: cuando de verdad haya uno, ya nadie va a prestar atención. Por qué pasa: nombrar algo con una categoría suena más objetivo que decir "no me gusta", y hay una tentación honesta de darle peso a una intuición. Cómo detectarlo: pregúntate si podrías nombrar el anti-patrón específico —de esta lista o de la literatura— y describir su mecanismo de daño. Si solo puedes decir "es un anti-patrón" en genérico, es una opinión con disfraz. Cómo corregirlo: si no tiene nombre propio y mecanismo conocido, no es un anti-patrón; es un desacuerdo de diseño, y se discute como tal —que también es legítimo, solo que sin autoridad prestada—.
Confundir Adapter con intermediario vacío (de criterio). Qué pasa: alguien aprende que una clase que solo delega es un olor, ve un StripeProvider cuyos métodos son casi todos una línea que llama al SDK, y pide quitarlo. Si le hacen caso, el código del SDK de Stripe —con sus centavos, sus diccionarios crudos y sus excepciones propias— se filtra al checkout, que es exactamente lo que la clase estaba evitando. Por qué pasa: superficialmente las dos estructuras son idénticas; la diferencia está en si hay traducción, y la traducción a veces cabe en una línea y se ve como delegación. Cómo detectarlo: mira la firma de entrada y la de salida. Si entra un Order y sale un ChargeResult, y en el medio hubo una conversión de unidades o de forma, hay traducción. Si entra X y sale exactamente lo que devolvió el colaborador, no la hay. Cómo corregirlo: la pregunta de la sección — ¿esta capa cambia la forma, el vocabulario o el número de llamadas?. El Adapter cambia la forma y el vocabulario; el Facade cambia el número de llamadas; el intermediario vacío no cambia nada.
Desmontar un anti-patrón de un solo golpe (de método). Qué pasa: alguien diagnostica correctamente un Singleton, decide arreglarlo, y abre un PR que toca los once archivos que importan settings para pasar la configuración por parámetro. El PR es enorme, imposible de revisar de verdad, toca todo el sistema a la vez, y —lo más probable— se queda abierto dos semanas y termina cerrándose. Por qué pasa: como el anti-patrón fue una decisión única, se siente que deshacerlo también tiene que ser un movimiento único. Y hay algo de verdad incómoda: los estados intermedios de este tipo de refactor se ven feos, con dos formas de hacer lo mismo conviviendo. Cómo detectarlo: si tu plan no tiene un punto intermedio donde el sistema funcione y se pueda mergear, es demasiado grande. Cómo corregirlo: es exactamente el contenido de la lección 6. La técnica general es introducir la forma nueva junto a la vieja, migrar de a poco, y quitar la vieja cuando no quede nadie usándola. La fealdad temporal de tener las dos formas conviviendo es el precio de poder mergear cada semana, y es un precio barato.
Ejercicios
Ejercicio 1 — Diagnostica con las cuatro preguntas. Aquí hay un rincón de Boletia que nadie ha tocado en año y medio. Aplica las cuatro preguntas y di qué anti-patrón es —o si no lo es—.
# Archivo: notifications/dispatcher.py
class ChannelResolver:
"""Decide qué canales usar para un cliente."""
def resolve(self, customer):
return ChannelSetFactory().build(customer)
class ChannelSetFactory:
"""Construye el conjunto de canales disponibles."""
def build(self, customer):
channels = []
for name in ChannelRegistry.available():
channel = ChannelRegistry.get(name)
if channel.is_available_for(customer):
channels.append(channel)
return channels
class ChannelRegistry:
_channels = {}
@classmethod
def register(cls, name, channel):
cls._channels[name] = channel
@classmethod
def get(cls, name):
return cls._channels[name]
@classmethod
def available(cls):
return list(cls._channels)
# Y en notifications/__init__.py:
ChannelRegistry.register("email", EmailChannel())
ChannelRegistry.register("sms", SmsChannel())
ChannelRegistry.register("push", PushChannel())
Ver solución
Pregunta 1 — ¿cuántas implementaciones reales hay? Tres canales, registrados en el mismo archivo, en el arranque, sin descubrimiento dinámico ni configuración. Es decir: el registro es un diccionario escrito en tres líneas fijas. Aquí no hay generalidad especulativa grave —tres implementaciones son tres implementaciones reales—, pero sí sobra el mecanismo de registro: si los tres se conocen en tiempo de escritura, una lista alcanza.
Pregunta 2 — ¿cuántos archivos para responder algo simple? La pregunta simple: "¿a qué canales le llega un aviso a un cliente sin teléfono?". Hay que abrir dispatcher.py (tres clases), channel.py para ver is_available_for, y __init__.py para saber qué está registrado. Y dentro de dispatcher.py hay que seguir ChannelResolver → ChannelSetFactory → ChannelRegistry. Tres saltos para una operación que es un filtro sobre una lista.
Pregunta 3 — ¿qué hace falta para probar esto solo? Para probar ChannelResolver hay que tener el registro poblado, y el registro es estado de clase global: si una prueba registra un canal falso, se lo deja registrado a las siguientes. Muy probablemente ya exista un setUp que hace ChannelRegistry._channels = {}. Esa es la denuncia.
Pregunta 4 — ¿problema real o imaginado? ¿Hubo alguna vez un canal registrado desde fuera de __init__.py? Casi seguro que no.
Veredicto: dos anti-patrones apilados. ChannelResolver es un intermediario vacío —su único método delega en una fábrica y no agrega nada—. Y ChannelRegistry, con sus classmethod sobre estado de clase, es un Singleton disfrazado: una variable global con una fachada de clase, con todos los problemas de acceso global y ninguno de los beneficios de la unicidad, porque la unicidad aquí no hacía falta.
La versión aplanada:
# Archivo: notifications/dispatcher.py
CHANNELS = [EmailChannel(), SmsChannel(), PushChannel()]
def channels_for(customer):
# La única decisión que este módulo toma, visible en una línea.
return [c for c in CHANNELS if c.is_available_for(customer)]
Tres clases y un registro global se convirtieron en una lista y una función. Y ahora la prueba es de dos líneas, sin estado que resetear.
Por qué funciona: las cuatro preguntas no requieren conocer los seis nombres. Producen el diagnóstico solas, y el nombre viene después —que es el orden correcto—. Fíjate además en que la pregunta 3 fue la que dio la evidencia más contundente, como suele pasar.
Ejercicio 2 — Escribe el comentario sobre una decisión de otra persona. El registro de plugins de Boletia lo escribió Rosa, que sigue en el equipo y es la persona con más antigüedad. Lo escribió hace dos años cuando parecía que iban a vender la plataforma a otros organizadores, cosa que no ocurrió. Hoy hay una sola implementación. Escribe el comentario de revisión aplicando las cinco partes de la lección 4, con el cuidado extra que pide un anti-patrón.
Ver solución
Una versión posible:
Nota, no bloqueante —
plugins/(los cuatro archivos). Quiero dejar escrito algo que vengo pensando desde que me tocó tocar el checkout, sin que sea para este PR.El registro de asientos soporta N implementaciones y hoy tiene una:
default_seating.py, la misma desde hace dos años. La consecuencia concreta la medí esta semana: para contestar "¿cómo se asigna un asiento?" tuve que abrircheckout.py,registry.py,config.py,base.pyyimpls/default_seating.py— cinco archivos, y el salto deimportliben el registro no lo puede seguir el editor, así que "ir a la definición" no funciona. Es la tercera vez que alguien nuevo se atasca ahí; a mí me costó cuarenta minutos.Sé que se puso cuando el plan era vender la plataforma a otros organizadores, y con ese plan era claramente la decisión correcta. Lo que cambió es el plan, no la decisión.
Mi propuesta: instanciar
DefaultSeatingdirecto en el checkout y borrar el registro. Son unas cuarenta líneas menos y el comportamiento no cambia; el test de asignación de asientos no menciona plugins, así que sirve igual como red. Y si el plan vuelve —o si aparece un venue con reglas propias— reponerlo es una tarde, y esta nota queda como el registro de por qué se quitó.¿Lo vemos quince minutos esta semana? Prefiero que lo decidamos entre los dos antes de tocar nada.
Qué hace este comentario y por qué. Tiene las cinco partes de la lección 4, con tres cosas específicas del caso de un anti-patrón.
Primero, la evidencia es histórica y personal: cinco archivos, dos años, tres personas atascadas, cuarenta minutos míos. Ninguno de esos datos se puede discutir. Compara con "esto es speculative generality", que se discute eternamente.
Segundo, reconoce la intención sin condescendencia. La frase "lo que cambió es el plan, no la decisión" es el centro del comentario. No dice "estuvo mal"; dice que la premisa cambió. Y es cierto: en 2024, con la venta sobre la mesa, la estructura estaba bien pensada.
Tercero, dice bajo qué condición volvería, y da el costo de reponerla ("una tarde"). Eso convierte una eliminación en una decisión reversible, que es psicológicamente muy distinto — y además es verdad.
Y el detalle final: propone hablarlo, no ejecutarlo. Tratándose de una decisión de la persona con más antigüedad del equipo, mandar un PR que borra su trabajo sin hablarlo antes es una forma bastante confiable de arruinar una relación de trabajo. Ese instinto es material de la lección 7.
Por qué funciona: los anti-patrones son el caso donde el vocabulario más fácilmente se convierte en arma. Este ejercicio te obliga a escribir el diagnóstico completo, sin ablandar ni un dato, y aun así de una forma que abre una conversación en vez de cerrarla.
Ejercicio 3 — Encuentra la versión legítima. Cinco de los seis anti-patrones tienen una versión legítima de la misma técnica. Para cada uno di cuál es y qué la distingue. (a) Singleton, (b) fábrica de fábricas, (c) objeto ancla, (d) herencia profunda, (e) intermediario vacío.
Ver solución
(a) Singleton → una instancia única construida en el arranque e inyectada. Lo legítimo es la unicidad; lo que hace daño es el punto de acceso global. Un pool de conexiones que se crea una vez en main() y se pasa a quien lo necesite tiene todos los beneficios y ninguno de los costos: se sabe quién lo usa, se puede sustituir en una prueba, y el día que haga falta un segundo pool se cambia el arranque y nada más.
(b) Fábrica de fábricas → una Factory, sin la segunda capa. Lo legítimo es concentrar la decisión de qué construir en un solo lugar (módulo 4). El criterio de dosis: una capa de creación por cada decisión que de verdad se toma. Existe un caso real donde dos capas se justifican —cuando la decisión de "qué familia de objetos usar" es distinta de la de "cuál de la familia", como un backend de almacenamiento completo que cambia entre entornos—, y es raro. Si no puedes nombrar las dos decisiones, hay una sola.
(c) Objeto ancla → un módulo pequeño de funciones genuinamente genéricas y sin estado. format_money, slugify, parse_date no pertenecen a ningún dominio y está bien que vivan juntas. Lo que lo convierte en ancla es meter ahí cosas que sí tienen dueño: reglas de negocio, consultas a la base de datos, integraciones. La regla: si algo tiene dueño natural, va con su dueño.
(d) Herencia profunda → un nivel de herencia para compartir un esqueleto, o sea Template Method (módulo 3). Una clase base con el orden de los pasos y un método abstracto por paso variable es exactamente la solución correcta para los tres exportadores. Lo que la vuelve anti-patrón es la acumulación de niveles intermedios, y sobre todo el super() entrelazado: un método que llama a super() y además hace algo hace que ningún nivel se entienda solo.
(e) Intermediario vacío → Adapter o Facade (módulo 5). El Adapter traduce entre dos vocabularios; el Facade reduce cinco llamadas a una. Los dos delegan casi todo y los dos tienen valor, porque cambian algo. El intermediario vacío no cambia nada.
Y el sexto, el que no tiene versión legítima: la generalidad especulativa. Podría parecer que su versión buena es "una abstracción que sí se usó", pero eso ya no es especulativo por definición. Cuando hay tres implementaciones reales, la interfaz no es generalidad especulativa: es una interfaz. Este es el único de los seis donde la respuesta es siempre quitar.
Por qué funciona: este ejercicio es el antídoto contra el efecto colateral más probable de la lección. Alguien que sale de aquí pensando "los Singletons son malos, la herencia es mala, los intermediarios son malos" queda peor que antes, porque va a evitar seis técnicas útiles. La diferencia entre el patrón y el anti-patrón nunca está en la técnica: está en si el problema que resuelve existe y en si la dosis es la correcta.
Resumen y siguiente paso
En esta lección definiste qué es un anti-patrón: una solución con nombre, aplicada repetidamente a un problema real, que parece buena en el momento y produce más problema del que resolvió. Lo separaste del código malo (que se corrige), del code smell (que se investiga y a veces se deja) y de la deuda técnica (que fue un atajo consciente). La diferencia práctica es que un anti-patrón fue una decisión de alguien, tomada con argumentos y casi siempre con buenas intenciones, y eso cambia tanto la evidencia que hace falta como el tono con el que se dice.
Recorriste los seis que aparecen de verdad: el Singleton como estado global —donde la señal decisiva es lo que hacen las pruebas para poder correr—; la fábrica de fábricas —donde la regla es una capa de creación por cada decisión real—; el objeto ancla —utils que crece hasta que diecinueve módulos dependen de él, con reglas de negocio escondidas dentro—; la herencia de cinco niveles —donde el super() entrelazado hace que ningún nivel se entienda solo—; el intermediario vacío —con su falso positivo importantísimo: Adapter y Facade delegan y sí tienen valor—; y la generalidad especulativa, el único cuyo tratamiento es solo quitar, y que si sobrevive suficiente tiempo se convierte en un martillo para usos que no le tocan.
Te llevas cuatro preguntas que diagnostican cualquiera de los seis y también los que no están en la lista: cuántas implementaciones reales hay hoy; cuántos archivos hay que abrir para responder algo simple; qué hace falta para probar esto solo (la mejor de las cuatro, porque la respuesta no se discute); y si la estructura resuelve un problema que tenemos o uno que imaginamos.
Antes de avanzar deberías poder: distinguir un anti-patrón de un olor y decir por qué se comunican distinto; nombrar la versión legítima de cinco de los seis; explicar qué separa un Adapter de un intermediario vacío; y decir por qué "esto es un anti-patrón" nunca convence a quien lo escribió.
Lo que falta es el cómo. Esta lección diagnosticó y dio direcciones, pero no el camino: cómo se quita un Singleton del que dependen once módulos sin abrir un PR imposible de revisar, cómo se aplana una jerarquía de cinco niveles sin romper los reportes, cómo se llega a una Strategy desde un if sin un salto mortal. La lección 6 recorre las dos direcciones —hacia un patrón y hacia afuera de uno—, en pasos pequeños, con el sistema funcionando después de cada paso. Y la dirección de salida, que casi nunca se enseña, es la que más vas a necesitar después de esta lección.
Recursos
- AntiPatterns: Refactoring Software, Architectures, and Projects in Crisis (Brown, Malveau, McCormick, Mowbray) — el libro de 1998 que popularizó el término y catalogó el Blob, el Poltergeist y el Golden Hammer. Está fechado en sus ejemplos y sigue siendo la referencia del vocabulario.
- Wiki Wiki Web — Anti Pattern — la discusión original de la comunidad, con la definición de dos partes que se sigue usando: una solución repetida que parece buena y tiene consecuencias claramente negativas.
- Singletons are Pathological Liars (Miško Hevery) — el ensayo que mejor explica por qué el Singleton duele, contado desde el ángulo de las pruebas. Es la fuente de la idea de que el test es la denuncia.
- Composition over Inheritance — una explicación en Python, con código, de por qué las jerarquías profundas se aplanan mejor con composición. Cubre exactamente el caso de la herencia de cinco niveles.