6. Singleton (Instancia única) - Patrón Creacional

El patrón Singleton garantiza que exista una única instancia de una clase y que esta sea accesible de manera global y controlada. Se trata de uno de los patrones creacionales más conocidos, aunque también uno de los más debatidos, porque su uso indiscriminado puede generar acoplamientos innecesarios.

Comprender cuándo aplicarlo, cómo implementarlo correctamente y qué alternativas existen resulta fundamental para aprovechar sus ventajas sin comprometer la mantenibilidad del sistema.

6.1 Problema que resuelve

Hay situaciones en las que una clase debe tener exactamente una instancia: administradores de configuraciones, registradores de logs o manejadores de conexiones compartidas. El reto consiste en asegurar que no se creen instancias adicionales y en ofrecer un único punto de acceso que respete las restricciones de inicialización.

La solución debe funcionar en entornos concurrentes, evitar estados inconsistentes y permitir su uso controlado en todo el sistema.

6.2 Intención y contexto habitual

La intención del Singleton es encapsular la creación y el acceso a una instancia única. Se aplica cuando:

  • La clase representa un recurso escaso que no puede duplicarse (por ejemplo, un driver de hardware).
  • Se necesita un punto de coordinación global (como un registro central de servicios).
  • El costo de crear múltiples instancias es alto o podría provocar errores.

Sin embargo, antes de recurrir al patrón, es conveniente evaluar si una inyección de dependencias o un parámetro explícito resolverían el problema con menos acoplamiento.

6.3 Estructura y participantes

El patrón involucra una sola clase con tres responsabilidades principales:

  • Constructor privado: impide la creación directa mediante new.
  • Instancia estática: almacena la referencia única, ya sea inicializada al cargar la clase o bajo demanda.
  • Método de acceso estático: expone la instancia al resto del sistema y controla la inicialización.

Opcionalmente, la clase puede implementar interfaces para facilitar pruebas o intercambiar implementaciones en escenarios avanzados.

6.4 Implementación básica en Java

La versión más simple utiliza inicialización ansiosa: la instancia se crea cuando la clase se carga. Esto es seguro para la mayoría de aplicaciones y evita problemas de concurrencia.

public final class ConfiguracionSistema {
    private static final ConfiguracionSistema INSTANCIA = new ConfiguracionSistema();

    private ConfiguracionSistema() {
        // Cargar valores desde archivo o variables de entorno
    }

    public static ConfiguracionSistema obtenerInstancia() {
        return INSTANCIA;
    }

    public String obtenerValor(String clave) {
        // Retorna la configuración asociada a la clave
        return "valor";
    }
}

class Aplicacion {
    public static void main(String[] args) {
        ConfiguracionSistema configuracion = ConfiguracionSistema.obtenerInstancia();
        System.out.println("Modo de ejecución: " + configuracion.obtenerValor("modo"));
    }
}
Diagrama Singleton

El inconveniente de esta variante es que crea la instancia incluso si nunca se utiliza, lo cual puede ser costoso cuando la inicialización es pesada.

6.5 Singleton con inicialización diferida y concurrencia

Para instancias costosas conviene inicializarlas bajo demanda. En escenarios concurrentes, la implementación debe ser segura para múltiples hilos. Una técnica habitual es el patrón de holder estático, que aprovecha la inicialización diferida de clases internas.

public final class RegistroEventos {
    private RegistroEventos() {}

    private static class Holder {
        private static final RegistroEventos INSTANCIA = new RegistroEventos();
    }

    public static RegistroEventos obtenerInstancia() {
        return Holder.INSTANCIA;
    }

    public void registrar(String mensaje) {
        // Persistir o enviar el evento
    }
}

class Aplicacion {
    public static void main(String[] args) {
        RegistroEventos registro = RegistroEventos.obtenerInstancia();
        registro.registrar("Sistema iniciado");
    }
}

Esta variante evita bloques sincronizados y garantiza inicialización segura gracias al modelo de memoria de la plataforma.

6.6 Ventajas y limitaciones

Ventajas principales:

  • Ofrece un punto de acceso global a recursos compartidos.
  • Controla la cantidad de instancias, útil para recursos limitados.
  • Puede combinarse con otros patrones (por ejemplo, Abstract Factory) para gestionar configuraciones.

Limitaciones y riesgos:

  • Introduce estado global, lo que complica pruebas unitarias y paralelismo.
  • Favorece el acoplamiento, ya que muchas clases conocen la implementación concreta.
  • Difícil de extender o reemplazar sin refactorizar código existente.

6.7 Errores comunes y anti-patrones

Usar Singleton para cada servicio suele derivar en un “God Object” lleno de responsabilidades. Otros errores frecuentes incluyen:

  • No manejar la serialización y clonado, lo que puede generar múltiples instancias.
  • Olvidar el control de concurrencia en inicialización diferida.
  • Abusar del acceso global en lugar de inyectar dependencias explícitas.

Cuando el Singleton se usa únicamente para compartir estado mutable entre componentes, conviene evaluar si un patrón de observación o un contenedor de dependencias ofrecen una solución más limpia.

6.8 Ejemplo completo en Java con pruebas facilitadas

El siguiente ejemplo muestra un Singleton diseñado para ser testeable. La clase expone una interfaz y permite reemplazar la instancia durante las pruebas, minimizando el acoplamiento. Utiliza el lenguaje Java y combina inicialización diferida con sincronización.

public interface ServicioClima {
    double temperaturaActual(String ciudad);
}

public final class ServicioClimaSingleton implements ServicioClima {
    private static volatile ServicioClima instancia;

    private ServicioClimaSingleton() {}

    public static ServicioClima obtenerInstancia() {
        ServicioClima resultado = instancia;
        if (resultado == null) {
            synchronized (ServicioClimaSingleton.class) {
                resultado = instancia;
                if (resultado == null) {
                    resultado = instancia = new ServicioClimaSingleton();
                }
            }
        }
        return resultado;
    }

    public static void establecerInstanciaParaPruebas(ServicioClima reemplazo) {
        instancia = reemplazo;
    }

    @Override
    public double temperaturaActual(String ciudad) {
        // Lógica real: llamar a API externa, cachear resultados, etc.
        return 20.0;
    }
}

class Aplicacion {
    public static void main(String[] args) {
        ServicioClima servicio = ServicioClimaSingleton.obtenerInstancia();
        System.out.println("Temperatura: " + servicio.temperaturaActual("Cordoba"));
    }
}

El método establecerInstanciaParaPruebas permite inyectar un doble durante los tests, lo cual mitiga uno de los principales inconvenientes del patrón.

Singleton

6.9 Variantes y recomendaciones finales

Además de las implementaciones anteriores, existen variantes como el Singleton basado en enumeraciones, introducido a partir de Java 5, que simplifica el manejo de serialización. En algunos frameworks, los contenedores de inversión de control reemplazan al Singleton mediante configuraciones de ámbito singleton, manteniendo los beneficios sin exponer detalles estáticos.

Antes de adoptar el patrón, pregúntese si necesita realmente una instancia única y documente claramente la responsabilidad de la clase. Esto previene que el Singleton se convierta en un contenedor de funcionalidades arbitrarias y mantiene el diseño alineado con los principios SOLID.