← maurobernal.com.ar

Singleton Pattern en C#: Buenas Prácticas, Organización y Mantenibilidad

El patrón Singleton es uno de los más conocidos del catálogo de Gang of Four — y también uno de los más malentendidos. Bien usado, resuelve problemas reales. Mal usado, se convierte en un generador de bugs sutiles, acoplamiento oculto y tests imposibles de escribir.

En este post vamos a ver cómo implementarlo correctamente en C#, cuándo tiene sentido usarlo, y cuándo deberías elegir otra alternativa.

¿Qué es el patrón Singleton?

El Singleton garantiza que una clase tenga una sola instancia durante toda la vida de la aplicación, y provee un punto de acceso global a esa instancia. Pertenece a la familia de patrones creacionales.

El caso de uso clásico: configuración de la aplicación, conexiones a recursos costosos, loggers, o cualquier componente del que realmente necesités una única instancia.


La implementación moderna: Lazy<T>

En C# moderno, la forma canónica de implementar un Singleton thread-safe es con Lazy<T>:

public sealed class Singleton
{
    private static readonly Lazy<Singleton> _instance =
        new Lazy<Singleton>(() => new Singleton());

    public static Singleton Instance => _instance.Value;

    private Singleton() { }

    public void DoSomething() { /* ... */ }
}

¿Por qué Lazy<T>?

  • Thread-safe por defecto: el runtime garantiza que el factory se ejecuta una sola vez, sin que vos tengas que escribir un solo lock.
  • Lazy initialization: la instancia se crea recién cuando alguien accede a Instance por primera vez. Si nunca se usa, nunca se instancia.
  • Sin boilerplate: nada de double-checked locking manual, que es complejo y propenso a errores sutiles.

¿Por qué sealed?

Si alguien puede heredar de tu clase Singleton, puede crear instancias adicionales a través de la clase derivada, rompiendo la garantía del patrón. sealed cierra esa puerta.

// ✅ Correcto
public sealed class Singleton { }

// ❌ Evitar — heredable
public class Singleton { }

Evolución histórica: los enfoques anteriores

Para entender por qué Lazy<T> es la mejor opción hoy, vale repasar cómo se implementaba antes:

1. Eager initialization (estático)

public sealed class Singleton
{
    private static readonly Singleton _instance = new Singleton();
    public static Singleton Instance => _instance;
    private Singleton() { }
}

Thread-safe gracias al garantismo del CLR para inicializadores estáticos. Pero la instancia se crea al cargar el tipo, aunque nunca se use.

2. Double-checked locking manual

public sealed class Singleton
{
    private static volatile Singleton? _instance;
    private static readonly object _lock = new object();

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                lock (_lock)
                {
                    if (_instance == null)
                        _instance = new Singleton();
                }
            }
            return _instance;
        }
    }

    private Singleton() { }
}

Funciona, pero es verboso, difícil de leer y fácil de romper si olvidás el volatile. Hoy no hay razón para elegir este enfoque sobre Lazy<T>.


Organización del código: estructura recomendada

Cuando implementás un Singleton, seguí un orden consistente dentro de la clase:

/// <summary>
/// Singleton para configuración de la aplicación.
/// Usa Lazy<T> para thread safety automático.
/// Considerá DI como alternativa si necesitás testeabilidad.
/// </summary>
public sealed class AppConfiguration
{
    // 1. Instancia estática privada
    private static readonly Lazy<AppConfiguration> _instance =
        new Lazy<AppConfiguration>(() => new AppConfiguration());

    // 2. Acceso público estático
    public static AppConfiguration Instance => _instance.Value;

    // 3. Constructor privado
    private AppConfiguration()
    {
        LoadConfiguration();
    }

    // 4. Campos inmutables (si los necesitás)
    private readonly string _connectionString;
    public string ConnectionString => _connectionString;

    // 5. Métodos públicos
    public string GetSetting(string key) { /* ... */ return string.Empty; }

    // 6. Helpers privados
    private void LoadConfiguration() { /* leer appsettings, etc. */ }
}

Documentar la decisión es parte de la buena organización. Un comentario explicando por qué elegiste Singleton (y no DI) le ahorra horas de confusión a quien lea el código después.


Las reglas de oro

✅ Hacé esto: Singleton sin estado (o inmutable)

El mejor Singleton es el que no tiene estado mutable. Las operaciones son stateless y solo operan sobre lo que reciben como parámetro:

public sealed class MathHelper
{
    private static readonly Lazy<MathHelper> _instance =
        new Lazy<MathHelper>(() => new MathHelper());

    public static MathHelper Instance => _instance.Value;
    private MathHelper() { }

    // Sin estado — solo lógica pura
    public double CalcularDistancia(Point a, Point b) =>
        Math.Sqrt(Math.Pow(b.X - a.X, 2) + Math.Pow(b.Y - a.Y, 2));

    public double CalcularIVA(decimal monto, double porcentaje) =>
        (double)monto * porcentaje / 100;
}

❌ Evitá esto: estado global mutable

// ❌ Anti-patrón: estado global mutable
public sealed class AppState
{
    private static readonly Lazy<AppState> _instance =
        new Lazy<AppState>(() => new AppState());

    public static AppState Instance => _instance.Value;

    public string? UsuarioActual { get; set; }  // Estado mutable — problema
    public int ContadorSesiones { get; set; }   // Estado mutable — problema
}

Esto crea dependencias ocultas entre distintas partes de la aplicación. Si alguien modifica UsuarioActual en un thread y otro lo lee, tenés una race condition. Y en tests, el estado del test anterior contamina el siguiente.

La alternativa: si necesitás estado compartido, usá DI con servicios scoped/singleton declarados explícitamente, o patrones como CQRS con un store centralizado.


El elefante en la habitación: Singleton vs Dependency Injection

En aplicaciones ASP.NET Core modernas, la recomendación es clara: registrá tus servicios como singleton en el contenedor de DI en lugar de implementar el patrón manualmente.

// En Program.cs
builder.Services.AddSingleton<ILogger, AppLogger>();
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();

// En tu servicio — inyectado, no accedido globalmente
public class PedidoService
{
    private readonly ILogger _logger;
    private readonly ICacheService _cache;

    public PedidoService(ILogger logger, ICacheService cache)
    {
        _logger = logger;
        _cache = cache;
    }
}

¿Por qué DI es mejor en este contexto?

  • Testeabilidad: podés mockear ILogger en tests sin tocar el Singleton real.
  • Dependencias explícitas: los constructores muestran exactamente qué necesita cada clase.
  • Desacoplamiento: el servicio depende de la interfaz, no de la implementación concreta.
  • El container maneja el ciclo de vida: no necesitás implementar la lógica de instancia única vos mismo.

¿Cuándo sí tiene sentido el Singleton clásico (estático)?

  • Librerías de utilidades stateless sin dependencias externas.
  • Código que corre fuera de un host DI (scripts, herramientas de línea de comandos simples).
  • Cuando el overhead del container es genuinamente inaceptable (raro).

Consideraciones para tests

El mayor problema de los Singletons clásicos es la testeabilidad. Si tu código hace MiSingleton.Instance.HacerAlgo(), no podés reemplazarlo con un mock en tests.

Si aun así necesitás un Singleton testeable, exponé una interfaz:

public interface IAppLogger
{
    void Log(string mensaje);
    void LogError(Exception ex, string contexto);
}

public sealed class AppLogger : IAppLogger
{
    private static readonly Lazy<AppLogger> _instance =
        new Lazy<AppLogger>(() => new AppLogger());

    public static IAppLogger Instance => _instance.Value;

    private AppLogger() { }

    public void Log(string mensaje) { /* ... */ }
    public void LogError(Exception ex, string contexto) { /* ... */ }
}

// En producción:
AppLogger.Instance.Log("Pedido creado");

// En tests: podés mockear IAppLogger sin usar el Singleton real
var mockLogger = new Mock<IAppLogger>();

Inicialización asincrónica: un caso especial

Lazy<T> no soporta constructores async. Si tu Singleton necesita inicialización asincrónica (por ejemplo, cargar datos de una base de datos), no lo hagas en el constructor. Usá un método de inicialización explícito:

public sealed class DatabaseConfig
{
    private static readonly Lazy<DatabaseConfig> _instance =
        new Lazy<DatabaseConfig>(() => new DatabaseConfig());

    public static DatabaseConfig Instance => _instance.Value;

    private string? _connectionString;
    private bool _initialized;

    private DatabaseConfig() { }

    // Inicialización async separada
    public async Task InitializeAsync()
    {
        if (_initialized) return;
        _connectionString = await LoadFromSecretsManagerAsync();
        _initialized = true;
    }

    private async Task<string> LoadFromSecretsManagerAsync()
    {
        // Llamada async real
        await Task.Delay(100); // simulación
        return "Server=...;Database=...;";
    }
}

// Uso:
var config = DatabaseConfig.Instance;
await config.InitializeAsync();  // Llamado explícitamente al arranque

En ASP.NET Core, esto se maneja mejor con IHostedService o llamando al init en el startup pipeline.


Resumen: cuándo sí, cuándo no

SituaciónRecomendación
App ASP.NET Core con DIUsá AddSingleton() en el container
Librería de utilidades statelessSingleton con Lazy<T>
Estado mutable compartidoEvitá Singleton, buscá otra solución
Lógica de negocioNunca Singleton — usá DI
Necesitás testeabilidadExponé interfaz o usá DI
Script/herramienta sin DISingleton con Lazy<T> puede ser OK

Conclusión

El Singleton tiene mala fama en parte merecida — pero generalmente porque se usa donde no corresponde. La receta para usarlo bien es simple:

  1. sealed + constructor privado siempre.
  2. Lazy<T> para thread safety sin drama.
  3. Sin estado mutable — si necesitás estado, hay mejores herramientas.
  4. Documentá la decisión — el próximo developer (quizás vos en 6 meses) va a agradecer el contexto.
  5. Preferí DI si estás en una aplicación con contenedor. AddSingleton() te da los mismos beneficios con mucha más flexibilidad.

El patrón no es el problema. El problema es usarlo como cajón de sastre para todo lo que querés que sea global.

¿Usás Singleton estático o preferís DI en tus proyectos? Contame en los comentarios.