Master Configuration in ASP.NET Core With The Options Pattern

antonmartyniuk

Anton Martyniuk

Posted on June 29, 2024

Master Configuration in ASP.NET Core With The Options Pattern

Options Pattern in ASP.NET Core provides a robust way to manage configurations in a type-safe manner.
This blog post explores the Options Pattern, its benefits, and how to implement it in your ASP.NET Core applications.

On my webite: antondevtips.com I already have .NET blog posts.
Subscribe as more are coming.

How To Manage Configuration in ASP.NET Core Apps?

Every ASP.NET application needs to manage configuration.

Let's explore how to manage BlogPostConfiguration from appsettings.json in ASP.NET Core app:

{
  "BlogPostConfiguration": {
    "ScheduleInterval": 10,
    "PublishCount": 5
  }
}

Enter fullscreen mode Exit fullscreen mode

The naive approach for managing configuration is using a custom configuration class registered as Singleton in the DI container:

public record BlogPostConfiguration
{
    public int ScheduleInterval { get; init; }

    public int PublishCount { get; init; }
}

var configuration = new BlogPostConfiguration();
builder.Configuration.Bind("BlogPostConfiguration", configuration);

builder.Services.AddSingleton(configuration);
Enter fullscreen mode Exit fullscreen mode

Let's implement a BackgroundService service that will use this configuration to trigger a blog post publishment job every X seconds based on the configuration.
This job should get a configured count of blogs per each iteration.
A simplified implementation will be as follows:

public class BlogBackgroundService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly BlogPostConfiguration _configuration;
    private readonly ILogger<BlogBackgroundService> _logger;

    public BlogBackgroundService(
        IServiceScopeFactory scopeFactory,
        BlogPostConfiguration configuration,
        ILogger<BlogBackgroundService> logger)
    {
        _scopeFactory = scopeFactory;
        _configuration = configuration;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Trigger blog publishment background job");

            using var scope = _scopeFactory.CreateScope();
            await using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

            var blogs = await dbContext.BlogPosts
                .Take(_configuration.PublishCount)
                .ToListAsync(cancellationToken: stoppingToken);

            _logger.LogInformation("Publish {BlogsCount} blogs: {@Blogs}",
                blogs.Count, blogs.Select(x => x.Title));

            var delay = TimeSpan.FromSeconds(_configuration.ScheduleInterval);
            await Task.Delay(delay, stoppingToken);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we are injecting BlogPostConfiguration configuration class directly into the Job's constructor and using it in the ExecuteAsync method.

At first glance, this approach might seem okay, but it has several drawbacks:

  1. Configuration is built manually, it doesn't have any validation
  2. Configuration is registered as singleton, it can't be changed without restarting an application
  3. Configuration is tightly coupled with the service logic. This approach reduces the flexibility and maintainability of the code
  4. Testing can be more cumbersome since the configuration is tightly bound to the services. Mocking the configuration for unit tests requires more setup and can be error-prone.

Another approach will be injecting IConfiguration into the Job's constructor and calling GetSection("").GetValue<T>() method each time we need to read configuration.
This method is much worse as it creates even more coupling of configuration with the service logic.

The much better approach is to use the Options Pattern.

The Basics Of Options Pattern in ASP.NET Core

The Options Pattern is a convention in ASP.NET Core that allows developers to map configuration settings to strongly-typed classes.

This pattern has the following benefits:

  1. Type safety: configuration values are mapped to strongly typed objects, reducing errors due to incorrect configurations
  2. Validation: supports validation of configuration values
  3. Separation of concerns: configuration logic is separated from application logic, making the codebase cleaner and easier to maintain.
  4. Ease of Testing: configuration can be easily mocked during testing, improving testability.

There are three ways to get configuration in ASP.NET core with the Options Pattern: IOptions, IOptionsSnapshot and IOptionsMonitor.

IOptions

IOptions<T> is a singleton service that retrieves configuration values once at application startup and does not change during the application's lifetime.
It is best used when configuration values do not need to change once the application is running.
IOptions is the most performant option of the three.

IOptionsSnapshot

IOptionsSnapshot<T> is a scoped service that retrieves configuration values each time they are accessed within the same request.
It is useful for handling configuration changes without restarting the application. It has a performance cost as it provides a new instance of the options class for each request.

IOptionsMonitor

IOptionsMonitor<T> is a singleton service that provides real-time updates to configuration values.
It allows subscribing to change notifications and provides the current value of the options at any point in time.
It is ideal for scenarios where configuration values need to change dynamically without restarting the application.

These classes behave differently. Let's have a detailed look at each of these options.

How to Use IOptions in ASP.NET Core

The registration of configuration in DI for all three option classes is the same.

Let's rewrite BlogPostConfiguration using Options Pattern.
First, we need to update the configuration registration to use AddOptions:

builder.Services.AddOptions<BlogPostConfiguration>()
    .Bind(builder.Configuration.GetSection(nameof(BlogPostConfiguration)));
Enter fullscreen mode Exit fullscreen mode

Now we can inject this configuration into the Background Service using IOptions interface:

public BlogBackgroundService(
    IServiceScopeFactory scopeFactory,
    IOptions<BlogPostConfiguration> options,
    ILogger<BlogBackgroundService> logger)
{
    _scopeFactory = scopeFactory;
    _options = options;
    _logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        // ...
        var blogs = await dbContext.BlogPosts
            .Take(_options.Value.PublishCount)
            .ToListAsync(cancellationToken: stoppingToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

To get a configuration value you need to use _options.Value.

How to Use IOptionsSnapshot in ASP.NET Core

To best illustrate the difference between IOptions and IOptionsSnapshot let's create two minimal API endpoints that return configuration using these classes:

app.MapGet("/api/configuration-singleton", (IOptions<BlogPostConfiguration> options) =>
{
    var configuration = options.Value;
    return Results.Ok(configuration);
});

app.MapGet("/api/configuration-snapshot", (IOptionsSnapshot<BlogPostConfiguration> options) =>
{
    var configuration = options.Value;
    return Results.Ok(configuration);
});
Enter fullscreen mode Exit fullscreen mode

Each time you call "configuration-singleton" endpoint it will always return the same configuration.

But if you update your appsettings.json file and save it, the next call to "configuration-snapshot" endpoint will render a different result:

Screenshot_1

How to Use IOptionsMonitor in ASP.NET Core

To fully understand how IOptionsMonitor works, let's try to change IOptions to IOptionsMonitor in our background service:

public class BlogBackgroundServiceWithIOptionsMonitor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly IOptionsMonitor<BlogPostConfiguration> _optionsMonitor;
    private readonly ILogger<BlogBackgroundServiceWithIOptionsMonitor> _logger;

    public BlogBackgroundServiceWithIOptionsMonitor(
        IServiceScopeFactory scopeFactory,
        IOptionsMonitor<BlogPostConfiguration> optionsMonitor,
        ILogger<BlogBackgroundServiceWithIOptionsMonitor> logger)
    {
        _scopeFactory = scopeFactory;
        _optionsMonitor = optionsMonitor;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _optionsMonitor.OnChange(newConfig =>
        {
            _logger.LogInformation("Configuration changed. ScheduleInterval - {ScheduleInterval}, PublishCount - {PublishCount}",
                newConfig.ScheduleInterval, newConfig.PublishCount);
        });

        while (!stoppingToken.IsCancellationRequested)
        {
            // ...

            var blogs = await dbContext.BlogPosts
                .Take(_optionsMonitor.CurrentValue.PublishCount)
                .ToListAsync(cancellationToken: stoppingToken);

            _logger.LogInformation("Publish {BlogsCount} blogs: {@Blogs}",
                blogs.Count, blogs.Select(x => x.Title));

            var delay = TimeSpan.FromSeconds(_optionsMonitor.CurrentValue.ScheduleInterval);
            await Task.Delay(delay, stoppingToken);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here are few important points worth mentioning.
Despite IOptionsMonitor being a Singleton class it always returns an up-to-date configuration value using _optionsMonitor.CurrentValue property.

This class has a OnChange method with a delegate that fires when an appsettings.json is saved. This method can be called twice:

info: OptionsPattern.HostedServices.BlogBackgroundServiceWithIOptionsMonitor[0]
      Configuration changed. ScheduleInterval - 2, PublishCount - 2
info: OptionsPattern.HostedServices.BlogBackgroundServiceWithIOptionsMonitor[0]
      Configuration changed. ScheduleInterval - 2, PublishCount - 2
Enter fullscreen mode Exit fullscreen mode

This can happen depending on the file system, that can trigger IOptionsMonitor to update the configuration on file saved and file closed events from the operating system.

Validation in Options Pattern

As we mentioned before, Options Pattern in ASP.NET Core supports validation.
It supports 2 types of validation: data annotations and custom validation.

Data annotations validation is based on attribute validation which I am not a fan of.
This type of validation breaks a single responsibility principle by polluting the configuration classes with a validation logic.

I prefer using custom validation. Let's have a look how to add validation for BlogPostConfiguration.

First, let's extend the configuration registration in DI container and add ValidateDataAnnotations and ValidateOnStart method calls:

builder.Services.AddOptions<BlogPostConfiguration>()
    .Bind(builder.Configuration.GetSection(nameof(BlogPostConfiguration)))
    .ValidateDataAnnotations()
    .ValidateOnStart();
Enter fullscreen mode Exit fullscreen mode

Regardless of chosen validation type, we need to call the ValidateDataAnnotations method.

ValidateOnStart method triggers validation on ASP.NET Core app startup and when the configuration is updated in appsettings.json.
This is particularly useful to catch errors early before the application is started.

For validation, we are going to use FluentValidation library:

public class BlogPostConfigurationValidator : AbstractValidator<BlogPostConfiguration>
{
    public BlogPostConfigurationValidator()
    {
        RuleFor(x => x.ScheduleInterval).GreaterThan(0);
        RuleFor(x => x.PublishCount).GreaterThan(0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's create our custom options validator by implementing the IValidateOptions<T> interface:

public class BlogPostConfigurationValidationOptions : IValidateOptions<BlogPostConfiguration>
{
    private readonly IServiceScopeFactory _scopeFactory;

    public BlogPostConfigurationValidationOptions(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public ValidateOptionsResult Validate(string? name, BlogPostConfiguration options)
    {
        using var scope = _scopeFactory.CreateScope();
        var validator = scope.ServiceProvider.GetRequiredService<IValidator<BlogPostConfiguration>>();

        var result = validator.Validate(options);
        if (result.IsValid)
        {
            return ValidateOptionsResult.Success;
        }

        var errors = result.Errors.Select(error => $"{error.PropertyName}: {error.ErrorMessage}").ToList();
        return ValidateOptionsResult.Fail(errors);
    }
}
Enter fullscreen mode Exit fullscreen mode

BlogPostConfigurationValidationOptions must be registered a singleton, that's why we resolve scoped IValidator<BlogPostConfiguration> from the service scope factory.

Finally, you need to register validator and the validation options in DI:

builder.Services.AddValidatorsFromAssemblyContaining(typeof(BlogPostConfigurationValidator));

builder.Services.AddSingleton<IValidateOptions<BlogPostConfiguration>, BlogPostConfigurationValidationOptions>();
Enter fullscreen mode Exit fullscreen mode

The Validate method is called in the following cases:

  • application startup
  • configuration was updated in appsettings.json

Using Options Pattern to Manage Configuration From Other Files

The real power of the Options Pattern in ASP.NET Core is that you can resolve configuration from any source using Options classes.

In all examples above, we were managing configuration within a standard appsettings.json.
In the same way, you can manage configuration from any other JSON files.

Let's create a "custom.settings.json" file:

{
  "BlogLimitsConfiguration": {
    "MaxBlogsPerDay": 3
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we can add this file to the Configuration object and add options for its configuration:

builder.Configuration.AddJsonFile("custom.settings.json", true, true);

builder.Services.AddOptions<BlogLimitsConfiguration>()
    .Bind(builder.Configuration.GetSection(nameof(BlogLimitsConfiguration)));
Enter fullscreen mode Exit fullscreen mode

Now we can use BlogLimitsConfiguration with any of the Options classes, for example:

app.MapGet("/api/configuration-custom", (IOptions<BlogLimitsConfiguration> options) =>
{
    var configuration = options.Value;
    return Results.Ok(configuration);
});
Enter fullscreen mode Exit fullscreen mode

You can even create custom Options configuration providers that read configuration from the database, redis or any other store.
There are many ready-made configuration providers from external Nuget packages, for example, to access configuration from Azure, AWS using the Options classes.

On my webite: antondevtips.com I already have .NET blog posts.
Subscribe as more are coming.

💖 💪 🙅 🚩
antonmartyniuk
Anton Martyniuk

Posted on June 29, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related