Módulo 4: Patrones para crear objetos
5. Singleton: el patrón del que más hay que desconfiar
Descripción
Al terminar esta lección vas a poder reconocer un Singleton en cualquier código que abras —tiene tres o cuatro formas distintas y todas se detectan de un vistazo— y vas a poder explicar, con argumentos concretos y no con dogma, por qué la industria se movió en contra de él durante veinte años. Vas a entender qué problema creía resolver, que era un problema real. Y vas a salir con las alternativas que se usan hoy en su lugar, que son más simples y no más complicadas.
Quiero ser explícito sobre el propósito desde la primera línea, porque esta lección es distinta de las demás del módulo: está aquí para que lo reconozcas, no para que lo apliques. No es una posición ideológica, es lo que quiere el mercado hoy. Vas a encontrar Singletons en código heredado —Boletia tiene uno—, en respuestas de foros escritas hace quince años, en la primera página de cualquier tutorial de patrones, y en más de una entrevista técnica. Vas a necesitar saber por qué ese código es difícil de mantener, cómo se desmonta sin romper nada, y qué responder cuando alguien lo proponga. Lo que no vas a necesitar nunca es escribir uno nuevo.
Conexión con el módulo: las lecciones 2 y 3 resolvieron qué construir; la 4, cómo construirlo. El Singleton se presenta a sí mismo como una respuesta a un problema de creación —"que solo exista una instancia"— pero, como vas a ver, su daño real está en el tercer problema del módulo: quién construye y cómo lo obtiene. Un Singleton no es principalmente una forma de crear: es una forma de que cualquier parte del sistema tome lo que quiera sin declararlo. Por eso esta lección va justo antes de la 6: la inyección de dependencias no solo es la alternativa recomendada, es la explicación de por qué el Singleton duele.
La llave maestra que todo el mundo tiene
Imagina un edificio de oficinas donde hay una sola sala de juntas. Es un recurso escaso y compartido, y eso es un hecho de la realidad: no hay dos salas, hay una.
Ahora imagina dos formas de administrarla.
Primera forma: hay una recepción. Quien necesita la sala la reserva. La recepción sabe quién la tiene, en qué horario y para qué. Si dos personas la piden a la misma hora, alguien lo detecta y lo resuelve. Si mañana el edificio consigue una segunda sala, la recepción empieza a repartir dos y nadie más se entera.
Segunda forma: hay una copia de la llave pegada en la pared del pasillo, y todo el mundo puede tomarla cuando quiera. Nadie reserva nada. La sala está libre si está libre.
La segunda forma funciona sorprendentemente bien mientras el edificio es chico. Es más simple, no hay burocracia, nadie espera a la recepción. Y falla exactamente cuando el edificio crece, de tres maneras que vale la pena que veas por separado porque son las tres formas en que falla un Singleton:
Nadie sabe quién la usa. Si preguntas "¿quiénes necesitan esta sala?", no hay respuesta posible. Habría que preguntarle a las noventa personas del edificio. En código, esto significa que no puedes saber qué partes del sistema dependen de ese objeto sin leer el sistema completo.
Dos personas entran al mismo tiempo. Con la llave en el pasillo, nada impide que dos equipos lleguen a la vez. En código, esto es el estado compartido mutable, y es la fuente de los bugs más difíciles de reproducir que existen.
No se puede probar nada en aislamiento. Si quieres ensayar una presentación sin que nadie te interrumpa, no hay forma: cualquiera puede entrar. En código, esto significa que no puedes probar una parte del sistema sin arrastrar la configuración global de todo lo demás.
Y hay una cuarta, más sutil y más cara a largo plazo: el día que aparece la segunda sala, hay que avisarle a las noventa personas. Con la recepción, cambiar de una a dos salas es cambiar la recepción. Con la llave en la pared, es cambiar todo el edificio.
El Singleton es la llave en la pared. Y fíjate en lo importante: el problema no era que hubiera una sola sala. Que exista una sola instancia de algo es perfectamente legítimo y muy común. El problema es la parte que el patrón agrega y que casi nadie nota que está agregando: el punto de acceso global.
Qué es un Singleton, y sus cuatro disfraces
Un Singleton es una clase que garantiza que solo exista una instancia de sí misma, y que ofrece un punto global desde donde cualquiera puede obtenerla.
Lee esa definición otra vez y sepárala en dos, porque es la separación que ordena toda la lección:
- "Solo existe una instancia" — esto casi siempre es una necesidad legítima.
- "Un punto global desde donde cualquiera puede obtenerla" — esto es lo que hace daño.
El patrón junta las dos cosas como si fueran una sola, y ahí está el truco. Puedes tener lo primero sin lo segundo, y es exactamente lo que hace la alternativa moderna.
Ahora, cómo se ve. Estas son las cuatro formas en que te lo vas a encontrar.
Disfraz 1: el clásico, con _instance. Es el que sale en todos los libros y el que vas a ver en código escrito antes de 2015.
# Archivo: config/gateway_config.py — el Singleton de Boletia
class PaymentGatewayConfig:
"""La configuración de los proveedores de pago. Una sola, compartida."""
_instance = None
@classmethod
def get_instance(cls):
# Si no existe, la crea; si existe, devuelve la misma de siempre.
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
self.default_provider = "stripe"
self.retry_attempts = 3
self.timeout_seconds = 30
self.sandbox_mode = False
Y se usa así, desde donde sea:
# En cualquier archivo del sistema, sin declarar nada, sin recibir nada:
config = PaymentGatewayConfig.get_instance()
if config.sandbox_mode:
...
Disfraz 2: sobrescribiendo __new__. Más sofisticado, más difícil de detectar, y peor: quien escribe PaymentGatewayConfig() cree que está creando un objeto nuevo y no lo está haciendo.
class PaymentGatewayConfig:
_instance = None
def __new__(cls):
# Intercepta la creación misma. Quien haga PaymentGatewayConfig()
# recibe SIEMPRE el mismo objeto, aunque el código diga lo contrario.
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
Este es especialmente traicionero porque rompe la expectativa más básica del lenguaje: que llamar a un constructor produce algo nuevo. Alguien que escriba a = PaymentGatewayConfig(); b = PaymentGatewayConfig() y modifique a va a ver cambiar b, y va a perder una tarde entendiendo por qué.
Disfraz 3: el decorador. Se ve elegante, y por eso es el más común en código Python reciente.
from functools import lru_cache
@lru_cache(maxsize=1)
def get_gateway_config() -> PaymentGatewayConfig:
"""Se ve como una factory. Es un Singleton: siempre devuelve el mismo objeto."""
return PaymentGatewayConfig()
Aquí está la señal a memorizar: @lru_cache sobre una función sin argumentos que devuelve un objeto es un Singleton. No dice "singleton" en ningún lado, pero el segundo llamado devuelve el mismo objeto que el primero, con todo lo que eso implica. La gente lo escribe pensando "estoy evitando construirlo dos veces" y no nota que además está creando un punto de acceso global.
Disfraz 4: el módulo de Python. Este merece un párrafo aparte porque es el más frecuente y el más discutible.
# Archivo: config/settings.py
STRIPE_KEY = os.environ["STRIPE_KEY"]
DEFAULT_PROVIDER = "stripe"
RETRY_ATTEMPTS = 3
# Y en cualquier archivo del sistema:
from config import settings
if settings.RETRY_ATTEMPTS > 0:
...
Los módulos de Python ya son singletons: se importan una sola vez y todos comparten el mismo objeto. Esto es importante saberlo por dos razones opuestas. La primera: es la razón por la que escribir la clase con _instance en Python es doblemente innecesario —el lenguaje ya te da un objeto único por módulo, gratis—. La segunda: significa que un módulo con estado mutable tiene exactamente los mismos problemas que un Singleton, aunque no lo parezca.
La distinción que importa: un módulo con constantes que se leen —STRIPE_KEY, RETRY_ATTEMPTS— es aceptable y muy común. Un módulo donde alguien escribe —settings.sandbox_mode = True desde una prueba, o un _cache = {} a nivel de módulo que se va llenando— es un Singleton con estado global, con todos sus problemas.
Los cuatro problemas, con nombre y evidencia
Ahora la parte sustancial. No basta con decir "es un anti-patrón": eso es dogma y no sirve en una discusión de equipo. Vamos con los cuatro problemas concretos, cada uno con su ejemplo de Boletia.
Problema 1: es una variable global con traje
Este es el fondo del asunto y todo lo demás se deriva de él.
Durante décadas, la primera lección de cualquier curso de programación fue "las variables globales son malas". Las razones son conocidas: cualquiera puede leerlas, cualquiera puede escribirlas, y cuando algo sale mal no hay forma de saber quién las tocó. En algún momento apareció un patrón que hace exactamente lo mismo —estado accesible desde cualquier parte del programa— pero con sintaxis de orientación a objetos y un nombre del catálogo. Y la sintaxis de clase le dio una respetabilidad que la variable global nunca tuvo.
# Estas dos líneas son, para efectos prácticos, la misma cosa:
payment_config = {"retry_attempts": 3} # variable global — todos coinciden en que es mala
PaymentGatewayConfig.get_instance().retry_attempts # Singleton — se ve profesional
La segunda tiene un método, tiene una clase y tiene un nombre de patrón. Y sigue siendo un dato que cualquier parte del sistema puede leer y modificar sin pedirle permiso a nadie.
Problema 2: acoplamiento invisible — la firma miente
Este es el problema que más cuesta caro en el día a día, y es el que enlaza con toda la idea del módulo.
# Archivo: checkout/checkout.py
def charge_order(order) -> PaymentResult:
"""Cobra una orden."""
config = PaymentGatewayConfig.get_instance() # ← nada anunció esto
provider = get_payment_provider(order.provider)
for attempt in range(config.retry_attempts):
result = provider.charge(order)
if result.status != "failed":
return result
return PaymentResult(status="failed", error_message="Se agotaron los reintentos")
Lee la firma: charge_order(order). Dice que para cobrar una orden hace falta una orden. Es mentira. Hace falta una orden y, además, una configuración global que tiene que estar inicializada, con el modo sandbox correcto y con un número de reintentos razonable.
¿Por qué importa tanto? Por tres consecuencias muy concretas.
No puedes saber de qué depende una función sin leerla entera. En un sistema con inyección de dependencias, la firma es un contrato: te dice todo lo que la función necesita. Con Singletons, la firma es una sugerencia y la verdad está enterrada en el cuerpo. Multiplica eso por doscientas funciones y tienes un sistema que nadie puede razonar sin leer completo.
No puedes saber quién usa la configuración. Si te preguntan "¿qué se rompe si cambio el valor por defecto de retry_attempts?", la única respuesta honesta es "hay que buscar get_instance en todo el repositorio y revisar caso por caso". Con una dependencia pasada por parámetro, la respuesta es "quien la recibe", y eso se ve en el código.
El orden de inicialización se vuelve un campo minado. El Singleton se crea la primera vez que alguien lo pide. ¿Quién lo pide primero? Depende del orden de los imports, de qué endpoint se llame primero, de si corrió una tarea programada antes. Si la configuración necesita leer una variable de entorno que se carga en el arranque, y algún módulo pide la configuración durante su propio import, tienes un error que ocurre solo en producción, solo a veces, y solo al desplegar.
Ejemplo trabajado — problema 3: las pruebas se contaminan entre sí
Aquí es donde el Singleton se vuelve visible incluso para quien no cree en los argumentos de diseño. Es el problema más fácil de demostrar y el que más rápido convence a un equipo.
# Archivo: tests/test_checkout.py
def test_sandbox_orders_do_not_charge_real_money():
config = PaymentGatewayConfig.get_instance()
config.sandbox_mode = True # ← modificamos el objeto global
result = charge_order(sandbox_order)
assert result.status == "succeeded"
def test_failed_payment_is_retried_three_times():
result = charge_order(failing_order)
assert result.status == "failed"
assert fake_gateway.call_count == 3
Corre esas dos pruebas en ese orden y la segunda falla, porque la primera dejó sandbox_mode = True en un objeto que sobrevive entre pruebas. Corre la segunda sola y pasa. Corre las dos al revés y pasan las dos.
Ese comportamiento tiene un nombre en la industria: prueba intermitente (flaky test). Y su costo real no es el tiempo que se pierde depurándola. Su costo es cultural: cuando una prueba falla a veces, el equipo aprende a volver a correrla en vez de investigarla. Y una vez que el equipo aprende eso, deja de creerle a la suite completa, incluidas las pruebas que están detectando bugs de verdad. Un solo Singleton con estado mutable puede erosionar la confianza en un sistema de pruebas entero.
La respuesta habitual a este problema empeora las cosas:
# El parche que todo el mundo escribe, y que es peor que el problema.
@pytest.fixture(autouse=True)
def reset_singleton():
yield
PaymentGatewayConfig._instance = None # tocando un atributo privado desde una prueba
Fíjate en lo que se necesitó: acceder a un atributo privado —el guion bajo dice "no me toques"— desde el código de pruebas, para deshacer a mano el efecto del patrón. Cuando tus pruebas necesitan violar el encapsulamiento de tu código de producción para funcionar, el problema no es la prueba. Y este parche además es frágil: el día que alguien agregue un segundo Singleton, hay que acordarse de agregarlo al fixture.
Qué esperar de este ejemplo. Lo primero que quiero que notes es que las dos pruebas están bien escritas. Cada una verifica una cosa, tiene un nombre que dice qué comprueba, y si la corres sola pasa. No hay nada que corregir en ellas. El defecto no está en ninguna de las dos, está en lo que comparten sin haberlo pedido, y por eso este tipo de falla desconcierta tanto a quien la sufre: revisa la prueba que falló, no encuentra nada mal, la vuelve a correr y pasa.
Lo segundo: el orden es lo que decide el resultado. Eso es lo que convierte el problema en algo insidioso. Una suite de pruebas puede correr en orden alfabético, en el orden de los archivos, o en paralelo repartida entre procesos —y varias herramientas los aleatorizan a propósito—. Así que el mismo código puede pasar en tu máquina y fallar en el servidor de integración, sin que haya cambiado una línea.
Lo tercero, y es la consecuencia que de verdad cuesta dinero: el parche del reset_singleton funciona. Ese es el problema. Como funciona, el equipo lo adopta, sigue adelante, y el Singleton se queda —ahora con un fixture que lo sostiene—. Seis meses después hay tres Singletons, un fixture de quince líneas que los reinicia todos, y nadie recuerda que aquello empezó como un parche. Los anti-patrones no sobreviven porque nadie los detecte: sobreviven porque tienen parches baratos que posponen la conversación.
Y lo cuarto, para que la crítica sea justa: nada de esto ocurriría si PaymentGatewayConfig fuera inmutable. Si nadie pudiera escribir config.sandbox_mode = True, el objeto compartido no podría contaminar nada. Eso confirma la regla que vamos a repetir al final de la lección: el daño del Singleton crece con el estado mutable que guarda. Un punto de acceso global a un dato congelado es un olor menor; a uno que cambia, es el bug que no puedes reproducir.
Problema 4: la concurrencia lo rompe de dos maneras
Este problema es menos frecuente en Python que en otros lenguajes, pero cuando aparece es de los peores.
Primera forma: dos instancias donde debía haber una. Mira otra vez el get_instance:
@classmethod
def get_instance(cls):
if cls._instance is None: # ← hilo A comprueba: es None
cls._instance = cls() # ← hilo B comprueba TAMBIÉN aquí: sigue siendo None
return cls._instance # los dos construyen. Dos "singletons".
Si dos hilos entran a la vez, los dos pueden ver None antes de que cualquiera asigne, y los dos construyen. El patrón que existe para garantizar una instancia produce dos, en silencio, y solo bajo carga. Se arregla con un candado —la solución clásica se llama double-checked locking— y esa solución tiene una historia larga de estar mal implementada en varios lenguajes durante años. Un patrón cuya implementación correcta requiere un artículo académico no es un patrón simple.
Segunda forma, más común en la práctica: el estado compartido entre peticiones. Si el Singleton guarda algo mutable —un caché, un contador, la última orden procesada— y el servidor atiende varias peticiones a la vez, esas peticiones se pisan.
# ⚠️ Este es el bug de producción clásico del Singleton.
class EventCache:
_instance = None
def __init__(self):
self._current_event = None # ← estado mutable compartido por TODAS las peticiones
def load(self, event_id):
self._current_event = fetch_event(event_id)
return self._current_event
@property
def current(self):
return self._current_event
Dos clientes comprando para eventos distintos al mismo tiempo, y uno recibe los datos del evento del otro. Es un bug que no se reproduce en desarrollo —donde hay un usuario— y que en producción aparece una vez cada mil peticiones, cuando dos coinciden en el milisegundo exacto. Este tipo de falla es la razón principal por la que las guías de estilo de las empresas grandes prohíben el estado mutable a nivel de módulo o de clase.
Qué se usa en su lugar
Ahora la parte constructiva, porque criticar sin ofrecer alternativa no sirve de nada. Y la buena noticia es que la alternativa es más simple, no más complicada.
Volvamos a la separación del principio:
- "Solo existe una instancia" — necesidad legítima.
- "Punto global de acceso" — el daño.
La alternativa hace lo primero sin lo segundo: crea una sola instancia en el arranque de la aplicación, y pásala a quien la necesite.
# Archivo: app.py — el arranque de Boletia.
# Este es el único lugar del sistema que construye las piezas compartidas.
def build_app():
"""Construye la aplicación con sus dependencias.
Aquí, y solo aquí, se decide QUÉ instancias existen. Una de cada una.
A partir de este punto, todo lo demás las RECIBE.
"""
config = PaymentGatewayConfig(
default_provider=os.environ.get("DEFAULT_PROVIDER", "stripe"),
retry_attempts=int(os.environ.get("RETRY_ATTEMPTS", "3")),
sandbox_mode=os.environ.get("SANDBOX") == "1",
)
db = Database(url=os.environ["DATABASE_URL"])
return Application(config=config, db=db)
# Archivo: checkout/checkout.py
def charge_order(order, provider: PaymentProvider, retry_attempts: int) -> PaymentResult:
"""Cobra una orden. La firma ahora dice la verdad completa."""
for attempt in range(retry_attempts):
result = provider.charge(order)
if result.status != "failed":
return result
return PaymentResult(status="failed", error_message="Se agotaron los reintentos")
Compara los dos mundos:
| Con Singleton | Con una instancia inyectada | |
|---|---|---|
| ¿Hay una sola? | Sí | Sí — la crea el arranque |
| ¿Se sabe quién la usa? | No, hay que buscar en todo el repo | Sí, está en las firmas |
| ¿Se puede sustituir en pruebas? | Solo tocando atributos privados | Sí, se pasa otra y ya |
| ¿Las pruebas se contaminan? | Sí, el estado sobrevive | No, cada prueba arma la suya |
| ¿Cuándo se crea? | Cuando alguien la pide primero (impredecible) | En el arranque, en un lugar visible |
| ¿Y si mañana hacen falta dos? | Cambio en todo el sistema | Cambio en el arranque |
Y la prueba, que ahora es aburrida —que es lo que uno quiere de una prueba:
def test_failed_payment_is_retried_three_times():
gateway = FailingProvider() # un doble, construido aquí
result = charge_order(order, provider=gateway, retry_attempts=3)
assert result.status == "failed"
assert gateway.call_count == 3
# No hay fixture de limpieza. No hay estado global. No importa el orden.
Ese lugar donde todo se construye —el build_app()— tiene nombre en la literatura: raíz de composición (composition root). La idea es simple y ordena mucho: hay un solo lugar en la aplicación donde se construyen las cosas, y está lo más cerca posible del arranque; todo lo demás recibe. La lección 6 desarrolla esto en serio.
Un caso especial que vale la pena mencionar, porque genera dudas legítimas: ¿y si construir el objeto es caro? Una conexión a base de datos, un cliente HTTP con su pool, un modelo cargado en memoria. Esos de verdad no deberían crearse dos veces. La respuesta sigue siendo la misma: se crean una vez en el arranque y se pasan. Que un objeto sea caro es un argumento para crearlo una sola vez, no para que sea accesible globalmente. Son dos cosas distintas y el Singleton las confunde.
Cómo se desmonta uno sin romper nada
Boletia tiene su PaymentGatewayConfig con get_instance(), usado en once lugares. No se puede detener el sistema para arreglarlo. Estos son los pasos, en el mismo espíritu de los seis de la lección 3: cada uno deja el sistema funcionando.
Paso 1 — Haz que la clase se pueda construir normalmente. Casi siempre ya se puede; si el __init__ no recibe nada, dale parámetros con valores por defecto para que se pueda construir con datos distintos.
@dataclass
class PaymentGatewayConfig:
default_provider: str = "stripe"
retry_attempts: int = 3
timeout_seconds: int = 30
sandbox_mode: bool = False
# get_instance() sigue existiendo, por ahora. Nada se rompió.
_instance = None
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
Paso 2 — Agrega el parámetro, con el Singleton como valor por defecto. Este es el truco que hace todo el refactor incremental posible:
def charge_order(order, config: PaymentGatewayConfig | None = None) -> PaymentResult:
# Si nadie la pasa, sigue funcionando como antes. Nada se rompió,
# y ya se puede pasar una configuración distinta desde una prueba.
config = config or PaymentGatewayConfig.get_instance()
...
Los once lugares siguen llamando igual. Pero a partir de ahora, cualquier prueba nueva puede pasar su propia configuración y quedar aislada.
Paso 3 — Mueve la construcción al arranque y empieza a pasarla, de a un lugar. Cada llamada que actualizas es un lugar menos que depende del global. Con las pruebas en verde entre cada uno.
Paso 4 — Cuando ningún lugar dependa del default, borra el get_instance y el _instance. Y quita el | None = None del parámetro, para que la dependencia sea obligatoria y explícita.
Este refactor tiene una propiedad que conviene señalar: se puede hacer a lo largo de meses, un lugar a la vez, sin una rama larga. Cada paso es pequeño, seguro y valioso por sí solo. Es la forma en que se desmontan los Singletons en sistemas reales, y es también el patrón general para quitar cualquier dependencia global.
Lo que sí es legítimo, para no exagerar
Un módulo que solo se enseña como prohibición produce gente que aplica la prohibición donde no toca. Estas cosas están bien y no hay que perseguirlas:
Constantes de módulo. settings.STRIPE_KEY leído desde varios lugares es aceptable. Es un valor, se lee y no se escribe, y no tiene ciclo de vida. Si te molesta que las pruebas dependan de una variable de entorno, hay un matiz: pásala como parámetro en las funciones que la usan de verdad, no en todas.
El módulo logging de Python. Sí, logging.getLogger(__name__) es un acceso global. Y está bien: el registro de eventos es una preocupación transversal que atraviesa todo el sistema por diseño, no guarda estado del dominio, y la biblioteca estándar ya resolvió el aislamiento en pruebas. Insistir en inyectar el logger en cada función es ceremonia sin ganancia.
Objetos inmutables y sin estado compartidos. Un objeto congelado que solo tiene datos de solo lectura no puede sufrir los problemas 3 y 4 —nadie lo puede modificar, así que no contamina pruebas ni se pisa entre hilos—. Sigue teniendo el problema 2, el acoplamiento invisible, y eso ya es cuestión de cuánto te importe en ese caso.
El propio caché de un módulo, si es de solo lectura y se llena una vez. Cargar una tabla de países al arrancar y consultarla desde varios lugares es razonable. Se vuelve problemático el día que alguien escribe en ella durante la ejecución.
La regla que ordena estos casos: el daño del Singleton crece con el estado mutable que guarda. Un punto de acceso global a algo inmutable es un olor menor. Un punto de acceso global a algo que cambia es el bug de producción que no puedes reproducir.
Errores comunes
Creer que el problema es "una sola instancia" (conceptual). Qué pasa: alguien aprende que Singleton es un anti-patrón y concluye que tener una sola instancia de algo está mal. Entonces crea una conexión a la base de datos por petición, o construye el cliente HTTP con su pool en cada llamada, y el sistema se vuelve lento por razones que nada tienen que ver con el diseño. Por qué pasa: el nombre del patrón describe la primera mitad —la instancia única— y no la segunda, que es la dañina. Cómo detectarlo: si tu "solución al Singleton" empeoró el rendimiento o duplicó recursos caros, aplicaste la crítica al lado equivocado. Cómo corregirlo: separa las dos ideas. Una sola instancia: bien, y créala en el arranque. Acceso global a ella: mal, pásala como parámetro. La alternativa al Singleton no es "muchas instancias", es "una instancia con dueño conocido".
Reemplazar el Singleton por un módulo con estado mutable (de implementación). Qué pasa: alguien quita la clase con get_instance() y la sustituye por un módulo state.py con variables a nivel de módulo que se modifican durante la ejecución. Como ya no hay una clase que diga "Singleton", el equipo cree que el problema se resolvió. No se resolvió nada: los cuatro problemas siguen exactamente igual, y ahora además no hay ni siquiera un nombre para señalarlos. Por qué pasa: se atacó la sintaxis del patrón en vez de su propiedad dañina. Cómo detectarlo: busca asignaciones a variables de módulo desde fuera del módulo —import state; state.current_user = u— y global dentro de funciones. Cómo corregirlo: la prueba es la misma para las dos formas: ¿alguien puede modificar esto desde cualquier parte del sistema sin que aparezca en ninguna firma? Si la respuesta es sí, da igual cómo se llame.
Usar el argumento equivocado en una discusión de equipo (de comunicación). Qué pasa: en una revisión, alguien propone un Singleton y el reviewer responde "los singletons son un anti-patrón" con un enlace. La discusión se traba, porque el otro tiene un problema real —no quiere construir la conexión cuarenta veces— y recibió una etiqueta en vez de una solución. Por qué pasa: es más rápido citar la conclusión que reconstruir el argumento. Cómo detectarlo: si tu comentario de revisión menciona el nombre del patrón más que el problema concreto, estás citando. Cómo corregirlo: usa el problema que de verdad te importa en ese caso, y trae la alternativa. "Coincido en que solo debe haber una conexión. Lo que me preocupa del get_instance() es que después no vamos a poder probar el checkout sin base de datos, y ya nos pasó con el caché de eventos. ¿La construimos en build_app() y la pasamos? Es el mismo número de instancias y las pruebas quedan aisladas." Eso es una conversación; lo otro es una sentencia. El módulo 7 desarrolla esta diferencia a fondo.
Ejercicios
Ejercicio 1 — Encuentra los singletons. Los siguientes cuatro fragmentos aparecen en Boletia. ¿Cuáles son Singletons? Para los que lo sean, di cuál de los cuatro problemas es el más grave en ese caso concreto.
# (a)
@lru_cache(maxsize=1)
def get_db_connection():
return psycopg.connect(os.environ["DATABASE_URL"])
# (b)
# Archivo: config/currencies.py
SUPPORTED_CURRENCIES = ("MXN", "USD", "COP")
DEFAULT_CURRENCY = "MXN"
# (c)
class SeatMapCache:
_maps = {}
@classmethod
def get(cls, event_id):
if event_id not in cls._maps:
cls._maps[event_id] = load_seat_map(event_id)
return cls._maps[event_id]
# (d)
class Notifier:
def __init__(self, channels):
self._channels = channels
def notify(self, customer, message):
for channel in self._channels:
channel.send(customer, message)
Ver solución
(a) Sí es un Singleton, con el disfraz del decorador. @lru_cache sobre una función sin argumentos garantiza una instancia y da acceso global. El problema más grave aquí es el tercero, las pruebas: cualquier código que llame a get_db_connection() por dentro es imposible de probar sin una base real corriendo. Y hay un agravante propio de las conexiones: si la conexión se cae, el caché sigue devolviendo el objeto muerto para siempre, porque lru_cache no sabe nada de salud de conexiones. Se arregla creándola en build_app() y pasándola.
(b) No es un Singleton en el sentido dañino. Es un módulo con constantes inmutables de solo lectura. Ningún estado mutable, nadie escribe, no hay ciclo de vida. Esto está bien y no hay que tocarlo. Si alguien te propone inyectar DEFAULT_CURRENCY como parámetro en veinte funciones, eso es ceremonia sin ganancia.
(c) Sí, y es el más peligroso de los cuatro. No usa _instance ni get_instance(), así que no se ve como el patrón del libro —pero _maps es un diccionario mutable a nivel de clase, compartido por todo el proceso—. El problema más grave es el cuarto, la concurrencia y el estado compartido: dos peticiones simultáneas pueden cargar el mismo mapa a la vez, y peor, el caché nunca se invalida. Si un organizador cambia el mapa de asientos, el sistema sigue sirviendo el viejo hasta que alguien reinicie el proceso. Ese es un bug de negocio real que se reporta como "cambié los asientos y no se ve el cambio", y que en desarrollo no se reproduce porque ahí el proceso se reinicia todo el tiempo. El tercer problema también está: las pruebas de asientos se contaminan entre sí.
(d) No es un Singleton. Es exactamente lo contrario: recibe sus canales por constructor. Puede haber uno, diez o ninguno, quien lo usa decide, y las pruebas le pasan canales falsos sin ninguna gimnasia. Esto es la alternativa recomendada, y es la lección 6.
Por qué funciona: dos de los cuatro casos no dicen "Singleton" en ninguna parte, y uno de esos dos es el peor. El diagnóstico no se hace buscando la palabra: se hace preguntando "¿hay estado mutable al que cualquiera puede llegar sin declararlo?".
Ejercicio 2 — Escribe el comentario de revisión. Un compañero abre un pull request en Boletia. Agregó esto para no tener que construir el cliente de Stripe en cada petición, y su argumento es bueno: construirlo es caro porque valida credenciales contra la API al inicializar.
class StripeClientSingleton:
_instance = None
@classmethod
def get(cls):
if cls._instance is None:
cls._instance = StripeClient(api_key=settings.STRIPE_KEY)
return cls._instance
Escribe el comentario de revisión que dejarías. No debe pasar de cinco líneas, debe reconocer lo que su solución resuelve bien, y debe proponer una alternativa concreta.
Ver solución
Una versión que funciona:
Coincido con el diagnóstico: construir el cliente en cada petición es caro y no tiene sentido, y una sola instancia es lo correcto aquí.
Lo que me preocupa del
get()estático es que después no vamos a poder probar el checkout sin credenciales reales de Stripe, y ya nos pasó conSeatMapCache—las pruebas empezaron a depender del orden—.¿Qué te parece si lo construimos una vez en
build_app()y lo pasamos a la factory de proveedores? Sigue siendo una sola instancia, mismo ahorro, y las pruebas pueden pasar un cliente falso sin tocar nada privado. Te ayudo con el cableado si quieres.
Por qué funciona este comentario, pieza por pieza:
Empieza dándole la razón en lo que la tiene, y no es cortesía vacía: su problema es real y su solución sí lo resuelve. Un comentario que arranca con "esto es un anti-patrón" pone al otro a defenderse y la discusión deja de ser técnica.
Nombra un costo concreto y verificable, no una etiqueta. "No vamos a poder probar el checkout sin credenciales reales" es algo que se puede comprobar en dos minutos. "Es un anti-patrón" no es comprobable, es una apelación a autoridad.
Cita un precedente del propio equipo. SeatMapCache ya les costó tiempo. Un ejemplo interno vale más que cualquier enlace externo, porque el equipo lo vivió.
Propone la alternativa con su nivel de esfuerzo, y deja claro que la propiedad que él quería —una sola instancia— se conserva. Sin eso, el otro escucha "quítalo" y no "cámbialo por esto".
Ofrece ayuda. Un cambio de cableado en el arranque puede sonar más grande de lo que es, y esa percepción es lo que hace que la gente defienda el atajo.
Y fíjate en lo que no dice: no menciona la palabra "Singleton" ni una vez. No hacía falta. El módulo 7 vuelve sobre esto: el vocabulario de patrones sirve para pensar y para conversar entre quienes lo comparten, pero en una revisión el argumento que convence es el costo concreto, no el nombre.
Ejercicio 3 — Desmonta el caché de asientos. Toma el SeatMapCache del ejercicio 1 y escribe el plan de desmontaje en pasos, siguiendo el método de la lección. Es usado desde tres lugares: checkout/checkout.py, api/routes.py y un comando scripts/warm_cache.py. Y agrega: ¿qué problema del caché no arregla quitar el Singleton?
Ver solución
El plan, paso a paso:
Paso 1 — Convierte la clase en algo instanciable normal. El estado deja de vivir en la clase y pasa a vivir en el objeto:
class SeatMapCache:
def __init__(self, loader=load_seat_map, ttl_seconds: int = 300):
self._maps: dict[int, tuple[float, SeatMap]] = {}
self._loader = loader
self._ttl = ttl_seconds
def get(self, event_id) -> SeatMap:
entry = self._maps.get(event_id)
if entry and (time.monotonic() - entry[0]) < self._ttl:
return entry[1]
seat_map = self._loader(event_id)
self._maps[event_id] = (time.monotonic(), seat_map)
return seat_map
Nota que el _maps pasó de atributo de clase a atributo de instancia. Ese cambio de una palabra es el que convierte el estado global en estado propio.
Paso 2 — Deja un acceso global temporal, para no romper los tres lugares:
_default_cache = SeatMapCache() # temporal, se borra en el paso 5
Los tres llamadores cambian de SeatMapCache.get(id) a _default_cache.get(id). Es un cambio mecánico, el sistema sigue igual, y ahora la clase ya se puede instanciar en pruebas.
Paso 3 — Crea la instancia real en el arranque y pásala donde se pueda:
def build_app():
seat_cache = SeatMapCache(ttl_seconds=int(os.environ.get("SEAT_CACHE_TTL", "300")))
return Application(seat_cache=seat_cache, ...)
Paso 4 — Migra los tres lugares, uno por uno, a recibir el caché como parámetro. El comando warm_cache.py es un caso interesante: al ser un proceso aparte, va a construir su propio caché, y ahí se hace visible algo que el Singleton escondía —un caché en memoria no se puede "calentar" desde otro proceso, porque no comparten memoria—. Es muy posible que ese script nunca haya servido para nada.
Paso 5 — Borra _default_cache.
Y ahora la segunda pregunta, que es el punto del ejercicio: quitar el Singleton NO arregla la invalidación.
El bug de negocio —el organizador cambia los asientos y el sistema sigue mostrando los viejos— viene de que el caché nunca expira, no de que sea global. Un caché por instancia con el mismo defecto tiene exactamente el mismo bug. Por eso en el paso 1 agregué el ttl_seconds: ese es el arreglo del bug, y es independiente del refactor del Singleton.
Vale la pena separarlos porque es una confusión frecuente. El Singleton empeoraba las cosas de tres maneras —no se podía probar, se compartía entre peticiones, y con varios procesos cada uno tenía su propia copia desincronizada— pero el error de diseño del caché era otro. Quitar un anti-patrón no arregla los bugs que ese anti-patrón hacía difíciles de ver; solo los deja a la vista. Que es, de todos modos, la mitad del trabajo.
Resumen y siguiente paso
En esta lección viste el único patrón del módulo que no vas a escribir. Empezaste separando la definición en sus dos mitades: "solo existe una instancia", que casi siempre es una necesidad legítima, y "un punto global desde donde cualquiera puede obtenerla", que es lo que hace daño. El Singleton junta las dos como si fueran una sola, y esa unión es el truco del patrón.
Aprendiste sus cuatro disfraces: el clásico con _instance y get_instance(); el que sobrescribe __new__ y rompe la expectativa de que un constructor devuelve algo nuevo; el @lru_cache sobre una función sin argumentos, que es el más común en Python moderno y el que menos parece un patrón; y el módulo con estado mutable, que tiene todos los problemas sin tener el nombre.
Viste los cuatro daños con evidencia concreta de Boletia: es una variable global con traje de clase; la firma miente —charge_order(order) necesitaba también una configuración que nadie anunció—; las pruebas se contaminan entre sí y el equipo termina desconfiando de su propia suite; y la concurrencia lo rompe, tanto creando dos instancias donde debía haber una como haciendo que dos peticiones se pisen los datos.
Y viste la alternativa, que es más simple y no más complicada: crear una sola instancia en el arranque —la raíz de composición— y pasarla a quien la necesite. Con la tabla que compara los dos mundos punto por punto, y el método de desmontaje incremental que permite quitar un Singleton a lo largo de meses, un lugar a la vez, sin romper nada. También viste qué no perseguir: las constantes de módulo, el logging de la biblioteca estándar y los objetos inmutables compartidos están bien.
Antes de avanzar deberías poder: reconocer los cuatro disfraces en código que nadie etiquetó; explicar por qué una prueba intermitente puede erosionar la confianza en una suite entera; escribir un comentario de revisión que rechace un Singleton sin usar la palabra; y distinguir el daño del patrón de los bugs que el patrón solo hacía difíciles de ver.
La lección 6 desarrolla la alternativa que aquí quedó esbozada. Vamos a hablar de inyección de dependencias, que tiene el nombre más intimidante de todo el módulo y es la idea más simple: en vez de que un objeto construya lo que necesita, se lo pasas. En Python es literalmente eso —pasarlo como parámetro— y no hace falta ningún contenedor, ninguna anotación mágica ni ninguna librería. Vas a ver cómo cambia el checkout de Boletia y por qué las pruebas dejan de necesitar trucos.
Recursos
- python-patterns.guide — The Singleton Pattern — Brandon Rhodes explica por qué en Python el patrón es doblemente innecesario: el módulo ya es un objeto único. Es la mejor lectura complementaria de esta lección.
- Miško Hevery — Singletons are Pathological Liars — el artículo del blog de pruebas de Google que popularizó el argumento de "la firma miente". Corto, concreto y todavía vigente.
- Martin Fowler — Static Factory / Registry — sobre el objeto compartido que se accede globalmente, con las condiciones bajo las cuales Fowler lo considera aceptable. Útil para no exagerar la prohibición.
- Refactoring Guru — Singleton — la ficha del catálogo. Léela por la sección de contras, que es inusualmente honesta para una página de referencia.