StackPractices
beginner Por Mathias Paulenko

Asegurar una Única Instancia con el Singleton Pattern

Cómo garantizar exactamente una instancia de una clase en una aplicación usando inicialización perezosa, creación thread-safe y singletons basados en registro.

Temas: design

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Visión general

Algunos recursos son inherentemente singulares dentro del alcance de una aplicación: un pool de conexiones a base de datos, un gestor de configuración, un framework de logging o un cache en memoria. Crear múltiples instancias de estos recursos desperdicia memoria, causa inconsistencia de estado y puede agotar límites del sistema (ej. demasiadas conexiones a base de datos). El singleton pattern asegura que una clase tenga exactamente una instancia y provee un punto de acceso global a ella.

La implementación ingenua — un campo estático inicializado al cargar la clase — funciona para casos simples pero falla bajo concurrencia y hace el testing difícil. Un test que muta el estado del singleton filtra esa mutación a tests posteriores. Las implementaciones modernas usan inicialización perezosa, inyección de dependencias o registros para balancear rendimiento, thread safety y testeabilidad. El siguiente enfoque cubre la evolución desde singletons básicos hasta producción.

Cuándo usarlo

Usa esta receta cuando:

  • Una clase gestiona un recurso que debe ser único dentro de la aplicación (pool de conexiones, cache, config). Consulta Factory Pattern para patrones de creación.
  • Múltiples instancias causarían conflictos o agotamiento de recursos. Consulta Connection Pooling para recursos compartidos.
  • Necesitas inicialización perezosa para evitar setup costoso durante el arranque
  • El singleton es stateless o read-only después de la inicialización (evita estado global mutable). Consulta Locks y Mutexes para acceso thread-safe.

Solución

Singleton Thread-Safe (Java)

public class DatabaseConnectionPool {
    private static volatile DatabaseConnectionPool instance;
    private static final Object lock = new Object();
    private final HikariDataSource dataSource;

    private DatabaseConnectionPool() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(System.getenv("DATABASE_URL"));
        config.setMaximumPoolSize(10);
        this.dataSource = new HikariDataSource(config);
    }

    public static DatabaseConnectionPool getInstance() {
        if (instance == null) {
            synchronized (lock) {
                if (instance == null) {
                    instance = new DatabaseConnectionPool();
                }
            }
        }
        return instance;
    }

    public Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }
}

Singleton a Nivel de Módulo (Python)

from psycopg2 import pool

class DatabaseConnectionPool:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._initialize()
        return cls._instance

    def _initialize(self):
        self.pool = pool.ThreadedConnectionPool(
            minconn=2, maxconn=10,
            dsn="postgresql://user:pass@localhost/db"
        )

    def get_connection(self):
        return self.pool.getconn()

    def release_connection(self, conn):
        self.pool.putconn(conn)

# Los imports del modulo dan la misma instancia en todas partes
from connection_pool import DatabaseConnectionPool
pool = DatabaseConnectionPool()

Singleton Basado en Registro (TypeScript)

class SingletonRegistry {
  private static instances: Map<string, unknown> = new Map();

  static get<T>(key: string, factory: () => T): T {
    if (!SingletonRegistry.instances.has(key)) {
      SingletonRegistry.instances.set(key, factory());
    }
    return SingletonRegistry.instances.get(key) as T;
  }

  static reset(key: string): void {
    SingletonRegistry.instances.delete(key);
  }

  static clear(): void {
    SingletonRegistry.instances.clear();
  }
}

const pool = SingletonRegistry.get('db-pool', () => new ConnectionPool());
SingletonRegistry.reset('db-pool'); // para tests

Singleton con DI Container (C# / .NET)

builder.Services.AddSingleton<IDatabaseConnectionPool, DatabaseConnectionPool>();

public class OrderService {
    private readonly IDatabaseConnectionPool _pool;

    public OrderService(IDatabaseConnectionPool pool) {
        _pool = pool;
    }

    public async Task<Order> GetOrder(int id) {
        await using var conn = await _pool.GetConnectionAsync();
        // ...
    }
}

Explicación

  • Double-checked locking: Después del primer chequeo exitoso, otro thread podría haber inicializado la instancia entre el chequeo y el lock, así que el segundo chequeo dentro del bloque sincronizado es necesario.
  • Singleton a nivel de módulo (Python): los módulos de Python se importan una vez y se cachean en sys. modules. Una clase definida en un módulo e instanciada a nivel de módulo se comporta como singleton. Todos los imports referencian el mismo objeto. Es más simple que __new__ pero menos explícito.
  • Patrón registro: en lugar de hardcodear getInstance() en cada clase, un registro central mapea claves a instancias singleton. Esto desacopla la creación de la clase, soporta singletons parametrizados y permite reset fácil para testing. El registro mismo es un singleton.
  • Singleton con DI container: frameworks modernos (Spring, ASP. NET, Angular) gestionan el ciclo de vida de singletons declarativamente. Declaras un binding como singleton scope y el container crea una instancia que inyecta en todas partes. Es el enfoque más testeable — los tests usan un container separado con mocks.

Variantes

EnfoqueThread-safePerezosoTesteableMejor para
Estático eagerNoPobreRecursos simples, siempre necesarios
Double-checked lockPobreInicialización perezosa performance-crítica
Bill Pugh (holder)PobreEnfoque preferido en Java
Enum singletonNoPobreSingleton basado en enum Java
A nivel de móduloSí*PobreCasos simples en Python
RegistroBuenoMúltiples singletons nombrados
DI containerExcelenteAplicaciones modernas

Lo que funciona

  • Prefiere DI sobre singletons manuales: un container de inyección de dependencias gestiona singletons declarativamente. Configuras `services. Las dependencias son explícitas y el testing es trivial.
  • Haz singletons stateless o inmutables: un singleton mutable es estado global, y el estado global es el enemigo del testing y la concurrencia. Consulta Prevención de Race Conditions para seguridad concurrente.
  • Evita singletons para lógica de negocio: un UserService no debería ser singleton. Las reglas de negocio cambian por request (usuarios distintos, contextos distintos). Reserva singletons para infraestructura: pools de conexiones, caches, loggers, lectores de configuración.
  • Implementa IDisposable / Closeable: un singleton frecuentemente mantiene recursos (conexiones, threads, file handles). En Spring o ASP. NET, registra hooks de disposición con el container.
  • Documenta thread-safety: si el singleton no es thread-safe, documentalo claramente. Los consumidores deben sincronizar externamente.

Errores comunes

  • Testing con singletons mutables: un test que llama Config. setDebug(true) filtra ese setting a todos los tests subsecuentes. Pasa configuración como parámetros de constructor.
  • Inicialización perezosa en código multithread sin sincronización: dos threads llamando getInstance() simultáneamente pueden crear dos instancias antes de que alguna asigne al campo estático.
  • Singletons con estado de ámbito de request: Los singletons deben mantener solo datos de ámbito aplicación.
  • Dependencias circulares en singletons: si ConnectionPool es singleton que depende de ConfigManager, y ConfigManager es singleton que depende de ConnectionPool, ninguno puede construirse. Los containers DI detectan esto y lanzan excepciones, pero los singletons manuales se bloquean durante la inicialización estática.

Errores Comunes Adicionales

  1. Usar singleton para compartir estado entre microservicios. Cada microservicio tiene su propia JVM/proceso — un singleton en uno no es visible para otro. Usa un cache distribuido (Redis) o base de datos compartida.

  2. Singleton con colecciones mutables sin sincronización. Un singleton con un HashMap que múltiples threads leen y escriben se corromperá. Usa ConcurrentHashMap o sincroniza el acceso:

// Mal: race condition en HashMap
private Map<String, User> cache = new HashMap<>();

// Bien: thread-safe
private Map<String, User> cache = new ConcurrentHashMap<>();
  1. Olvidar cerrar recursos singleton en shutdown. Los pools de conexiones y thread executors filtran si no se cierran:
// Hook de shutdown
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    DatabaseConnectionPool.getInstance().close();
}));

Preguntas frecuentes

Bill Pugh Holder Idiom (Java)
public class ConfigManager {
    private ConfigManager() {
        // Cargar config desde entorno o archivo
    }

    // Clase interna estática — cargada solo cuando getInstance() se llama
    private static class Holder {
        static final ConfigManager INSTANCE = new ConfigManager();
    }

    public static ConfigManager getInstance() {
        return Holder.INSTANCE;
    }

    public String get(String key) {
        // ...
        return "";
    }
}

La JVM garantiza que una clase se inicializa exactamente una vez, y la clase holder no se carga hasta que getInstance() se llama por primera vez. Esto da inicialización perezosa sin overhead de sincronización — la JVM maneja la thread safety.

Enum Singleton (Java)
public enum DatabaseType {
    INSTANCE;

    private final Map<String, String> properties;

    DatabaseType() {
        this.properties = loadProperties();
    }

    public String getProperty(String key) {
        return properties.get(key);
    }

    private Map<String, String> loadProperties() {
        // Cargar desde archivo o env
        return Map.of("driver", "postgresql");
    }
}

// Uso
String driver = DatabaseType.INSTANCE.getProperty("driver");

Los enum singletons están garantizados por la JVM como instancias únicas, incluso a través de serialización y reflection. Esta es la forma más robusta de singleton en Java.

Testing de Código Singleton
// Singleton testeable vía registro — reset entre tests
describe('OrderService con singleton pool', () => {
  afterEach(() => {
    SingletonRegistry.clear();
  });

  it('usa pool de conexiones compartido', () => {
    const pool = SingletonRegistry.get('pool', () => new InMemoryConnectionPool());
    const service = new OrderService(pool);

    service.process({ id: '1', items: [] });

    expect(pool.getConnectionsUsed()).toBe(1);
  });

  it('aísla estado entre tests', () => {
    // Registro fue limpiado — nueva instancia de pool
    const pool = SingletonRegistry.get('pool', () => new InMemoryConnectionPool());
    expect(pool.getConnectionsUsed()).toBe(0);
  });
});
// Java — test con DI container
@Test
void testOrderServiceWithMockPool() {
    var container = new DIContainer();
    container.bind(IDatabaseConnectionPool.class, MockConnectionPool.class, Scope.SINGLETON);

    var service = container.resolve(OrderService.class);
    service.process(new Order("1", List.of()));

    var mockPool = container.resolve(IDatabaseConnectionPool.class);
    verify(mockPool, times(1)).getConnection();
}