← maurobernal.com.ar

Etiqueta: POCO

  • El Patrón Options en .NET: IOptions, IOptionsSnapshot e IOptionsMonitor Explicados

    Una de las cosas que más me molestaba cuando empecé a trabajar con .NET era ver código lleno de configuration["Email:SmtpServer"] disperso por todos lados. Magic strings por acá, magic strings por allá. Si el nombre de la clave cambiaba en el JSON, el error aparecía en runtime y solo si ese código en particular se ejecutaba. El Patrón Options resuelve exactamente eso: configuración fuertemente tipada, validada y con soporte para recarga en caliente. En este artículo explico las tres interfaces principales y cómo elegir la correcta para cada situación.

    ¿Qué es el Patrón Options?

    El Patrón Options (en Microsoft.Extensions.Options) es el estándar arquitectónico de .NET para manejar configuraciones. En lugar de acceder a valores por nombre de cadena, mapeás secciones de appsettings.json o variables de entorno a clases C# tipadas (POCOs). Ganas autocompletado en el IDE, refactoring seguro y la posibilidad de validar la configuración antes de que la app arranque.

    .NET provee tres interfaces para diferentes necesidades de ciclo de vida y recarga:

    • IOptions<T> → Singleton, configuración estática
    • IOptionsSnapshot<T> → Scoped, recarga por request
    • IOptionsMonitor<T> → Singleton, recarga en tiempo real con eventos

    1. IOptions<T>: El caso base

    La implementación más simple. Se registra como Singleton: la configuración se lee una sola vez al arrancar la app y se cachea. Ideal para valores que no van a cambiar mientras la aplicación corre — credenciales de base de datos, configuración de email, endpoints de terceros fijos.

    Limitación clave: si el appsettings.json cambia mientras la app corre, IOptions<T> no lo va a ver hasta el próximo reinicio.

    // Modelo fuertemente tipado
    public class EmailSettings
    {
        public const string SectionName = "Email";
        public string SmtpServer { get; set; } = string.Empty;
        public int Port { get; set; }
    }
    
    // Registro en Program.cs
    builder.Services.Configure<EmailSettings>(
        builder.Configuration.GetSection(EmailSettings.SectionName));
    
    // Uso en un servicio (Primary Constructor C# 12+)
    public class EmailService(IOptions<EmailSettings> options)
    {
        // .Value se evalúa una sola vez y se cachea (es Singleton)
        private readonly EmailSettings _settings = options.Value;
    
        public void SendEmail()
        {
            Console.WriteLine($"Conectando a {_settings.SmtpServer}:{_settings.Port}...");
        }
    }

    La constante SectionName dentro de la clase es un patrón que adopté hace tiempo: elimina la magic string del registro y centraliza el nombre de la sección junto a su definición.

    2. IOptionsSnapshot<T>: Recarga por Request

    Se registra como Scoped — una instancia por request HTTP (o por scope de DI). Cada vez que se crea un nuevo scope, lee la configuración más reciente. Pero dentro del mismo scope, el valor es inmutable. Esto es clave: garantiza consistencia durante el procesamiento de un request, sin que un cambio de configuración a mitad de camino rompa la lógica.

    Trampa común — Captive Dependency: nunca inyectar IOptionsSnapshot<T> en un servicio Singleton. El Singleton «captura» la instancia Scoped y la mantiene viva más allá de su ciclo de vida esperado, lo que puede generar valores desactualizados o excepciones.

    public class FeatureFlagService(IOptionsSnapshot<FeatureFlagsSettings> options)
    {
        // Cada nuevo request HTTP obtiene la configuración más reciente.
        // Durante este request completo, _settings no cambia.
        private readonly FeatureFlagsSettings _settings = options.Value;
    
        public void ExecuteFeature()
        {
            if (_settings.IsNewFeatureEnabled)
            {
                Console.WriteLine("Ejecutando la nueva característica...");
            }
        }
    }

    Este es el que uso para Feature Flags en combinación con Microsoft.FeatureManagement: cambio el valor en el archivo de configuración y el siguiente request ya lo toma, sin reiniciar nada.

    3. IOptionsMonitor<T>: Hot Reload en Servicios Singleton

    La interfaz más dinámica. Se registra como Singleton y permite dos cosas que las anteriores no pueden:

    • CurrentValue: acceso a la configuración más reciente en cualquier momento
    • OnChange(): suscripción a eventos cuando la configuración cambia

    Es la opción correcta para BackgroundService, IHostedService o cualquier servicio Singleton que necesite reaccionar a cambios de configuración en tiempo real.

    public class BackgroundProcessor : IDisposable
    {
        private readonly IOptionsMonitor<ProcessingSettings> _optionsMonitor;
        private readonly IDisposable? _changeSubscription;
        private ProcessingSettings _currentSettings;
    
        public BackgroundProcessor(IOptionsMonitor<ProcessingSettings> optionsMonitor)
        {
            _optionsMonitor = optionsMonitor;
            _currentSettings = _optionsMonitor.CurrentValue;
    
            // Suscripción al evento de cambio
            _changeSubscription = _optionsMonitor.OnChange(newSettings =>
            {
                Console.WriteLine("¡La configuración cambió en tiempo real!");
                _currentSettings = newSettings;
            });
        }
    
        public void Process()
        {
            Console.WriteLine($"Procesando con límite de {_currentSettings.BatchSize} items.");
        }
    
        public void Dispose()
        {
            // Importante: liberar la suscripción para evitar memory leaks
            _changeSubscription?.Dispose();
        }
    }

    El Dispose() no es opcional. Si no liberás la suscripción de OnChange(), el callback mantiene una referencia al objeto vivo más allá de su ciclo de vida y tenés un memory leak. Lo aprendí a las malas en un BackgroundService que crecía en memoria de forma constante hasta que el proceso era reciclado.

    Cuadro comparativo: ¿Cuál usar?

    InterfazCiclo de vidaHot ReloadCuándo usarla
    IOptions<T>Singleton❌ NoConfiguración estática (DB, endpoints fijos)
    IOptionsSnapshot<T>Scoped✅ Por requestFeature flags, configuración que cambia entre requests
    IOptionsMonitor<T>Singleton✅ Tiempo realBackgroundServices, servicios que reaccionan a cambios

    Validación Moderna: Fail Fast con ValidateOnStart y Source Generators

    Desde .NET 8, el patrón Options se complementa con validación en tiempo de compilación. El problema que resuelve: si falta una clave crítica en la configuración, ¿preferís enterarte al arrancar la app o cuando ese código se ejecuta por primera vez en producción a las 2 AM?

    ValidateOnStart() hace que la app falle inmediatamente al arrancar si la configuración no es válida. Los Source Generators con [OptionsValidator] generan el código de validación en tiempo de compilación, sin reflexión en runtime.

    // Configuración moderna en Program.cs (patrón recomendado desde .NET 8)
    builder.Services.AddOptions<EmailSettings>()
        .BindConfiguration(EmailSettings.SectionName)
        .ValidateDataAnnotations()
        .ValidateOnStart(); // Falla al arrancar si la config es inválida
    
    // Modelo con validaciones
    public class EmailSettings
    {
        public const string SectionName = "Email";
    
        [Required]
        public string SmtpServer { get; set; } = string.Empty;
    
        [Range(1, 65535)]
        public int Port { get; set; }
    }
    
    // Source Generator para validación sin reflexión (recomendado para AOT y alto rendimiento)
    [OptionsValidator]
    public partial class EmailSettingsValidator : IValidateOptions<EmailSettings> { }

    Esta combinación es la que uso en todos los proyectos nuevos. Si el archivo de configuración del entorno de staging tiene un typo o falta una clave, el pipeline de CI lo detecta en el startup del smoke test, no en producción.

    Conclusión

    El Patrón Options no es un detalle menor — es la base de una configuración robusta en .NET. La elección entre las tres interfaces no es arbitraria: depende del ciclo de vida de tu servicio y de si necesitás que la configuración se actualice en caliente. La regla práctica: si tu servicio es Singleton y necesita reaccionar a cambios, usá IOptionsMonitor<T>. Si es Scoped (un controller, un servicio de request), usá IOptionsSnapshot<T>. Si la configuración no cambia nunca, IOptions<T> es suficiente y más eficiente.

    Y si todavía usás IConfiguration directamente con magic strings — este es el momento de migrar. No es una refactorización grande, y el beneficio en mantenibilidad vale la pena.

    ¿Querés que profundice en la validación con Source Generators para escenarios AOT o en cómo combinar este patrón con Azure App Configuration? Dejalo en los comentarios.

Tags

tsql (27)mssql (26)devops (21)sql (20)dotnet (18)docker (16)performance (14)contenedores (11)dotnet10 (10)linux (9)csharp (8)microservicios (8)angular (8)angular21 (7)sql server (6)issabel (6)kubernetes (6)docker-compose (6)typescript (6)aot (6)