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 C#

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

using System;

public sealed class ConfiguracionSistema
{
    private static readonly ConfiguracionSistema _instancia = new ConfiguracionSistema();

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

    public static ConfiguracionSistema ObtenerInstancia() => _instancia;

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

public static class DemoConfiguracion
{
    public static void Main()
    {
        var configuracion = ConfiguracionSistema.ObtenerInstancia();
        var modo = configuracion.ObtenerValor("modo");
        Console.WriteLine($"Modo de ejecución: {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 en C# es combinar clases anidadas con inicialización estática, lo que aprovecha las garantías del CLR.

public sealed class RegistroEventos
{
    private RegistroEventos() { }

    private static class Holder
    {
        internal static readonly RegistroEventos Instancia = new RegistroEventos();
    }

    public static RegistroEventos ObtenerInstancia() => Holder.Instancia;

    public void Registrar(string mensaje)
    {
        // Persistir o enviar el evento
    }
}

public static class DemoRegistroEventos
{
    public static void Main()
    {
        var registro = RegistroEventos.ObtenerInstancia();
        registro.Registrar("Sistema iniciado");
    }
}

Esta variante evita bloqueos explícitos y garantiza inicialización segura gracias al modelo de memoria del runtime .NET.

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 C# 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 C# con inicialización diferida y control explícito de concurrencia.

using System;

public interface IServicioClima
{
    double TemperaturaActual(string ciudad);
}

public sealed class ServicioClimaSingleton : IServicioClima
{
    private static IServicioClima? _instancia;
    private static readonly object _sincronizacion = new();

    private ServicioClimaSingleton() { }

    public static IServicioClima ObtenerInstancia()
    {
        var instanciaLocal = _instancia;
        if (instanciaLocal == null)
        {
            lock (_sincronizacion)
            {
                instanciaLocal = _instancia;
                if (instanciaLocal == null)
                {
                    instanciaLocal = _instancia = new ServicioClimaSingleton();
                }
            }
        }
        return instanciaLocal;
    }

    public static void EstablecerInstanciaParaPruebas(IServicioClima reemplazo)
    {
        _instancia = reemplazo;
    }

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

public static class DemoClima
{
    public static void Main()
    {
        var servicio = ServicioClimaSingleton.ObtenerInstancia();
        var temperatura = servicio.TemperaturaActual("Cordoba");
        Console.WriteLine($"Temperatura: {temperatura}");
    }
}

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 uso de Lazy<T> de .NET, que simplifica la inicialización diferida y el manejo de concurrencia. 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.