avatarSiarhei Kharlap

Summarize

Options Pattern in Asp Net Core: Easier than you think!

Photo by Sigmund on Unsplash

I don’t think I need to mention that the Options Pattern has become the source of truth when it comes to distributing configurations across the system. While it may seem overcomplicated and overwhelming at first glance, it is still very useful

The very thing I’m going to mention in this story can be found in the original documentation, but I’ll present it in a more human-readable way:

When I consider options as configurations, for me, it naturally implies having the capability to establish, validate, and utilize them. Just like when we talk about your smartphone configurations; you can configure the volume, and during the configuration, it can be validated (for example, if you’ve restricted the maximum volume while using earbuds, it may not allow you to increase it further). Then these configurations are applied to the system, effectively increasing the sound volume. So let’s go through each of these steps.

Establish / Set up / Configure

Primarily out of the box, there are a few ways you can configure your options. However, regardless of the mechanism you choose, they all rely on the same underlying mechanism — the IConfigureOptions<TOptions> interface (in fact, there are more composite interfaces, but not all at once). So, as I’ve mentioned, despite of which mechanism is chosen, they register an IConfigureOptions<TOptions> implementation in one way or another. The framework allows you to utilize as many of these implementations as you want, and each of them will be involved in composition in the order of registration (although I wouldn’t recommend relying on that). The Options can be added in two different at first glance but actually similar ways, direct Configuration method on IServiceCollection and via OptionsBuilder:

builder.Services.AddOptions<TOptions>() // creates a OptionsBUilder
    .Configure(options =>
    {
        //options configuration here
    });
builder.Services.Configure<TOptions>(options =>
{
    //options configuration here
});

They both register the exact class — ConfigureNamedOptions<TOptions> as an implementation of IConfigureOptions<TOptions>. (Please, neglect the ‘Named’, we will examine it later).

In the example before, the configurations assume that you set them up manually, for example, by using “options.SomeProperty = someValue.” However, it is also possible to bind options to the built-in ‘IConfiguration’ feature of ASP (actually, it is part of Microsoft.Extensions.Configuration and can be utilized independently from ASP; but never mind):

builder.cServices.Configure<TOptions>(
    builder.Configuration.GetSection("Configuration Section Name"));
//or
builder.Services.AddOptions<TOptions>()
    .Bind(builder.Configuration.GetSection("Configuration Section Name"));
//in example upper it uses the direct desirialization from json config
//but it can be achieved also as:
class Opt{
 [ConfigurationKeyName("TestSection:TestPropery")]
  public string Property { get; set; }
}
builder.Services.AddOptions<Opt>(Builder.Configuration);

I hope that this has given you some ideas about configuration. I’m going to move forward with our topic and discuss the consumption of our configured options. However, I’ll also introduce a few more ideas related to configurations later on.

Utilizing

More interesting aspects come into play when you start utilizing the Options you’ve configured. Options can be consumed through three interfaces:

IOptions<TOptions> 
IOptionsSnapshot<TOptions>
IOptionsMonitor<TOptions>

According to the official documentation:

IOptions<TOptions>:
  * Does not support:
    * Reading of configuration data after the app has started.
    * Named options
  * Is registered as a Singleton and can be injected into any service lifetime.

IOptionsSnapshot<TOptions>:
  * Supports named options
  * Is useful in scenarios where options should be recomputed on every 
    injection resolution, in scoped or transient lifetimes. 
  * Is registered as Scoped and therefore cannot be injected into a
    Singleton service.

IOptionsMonitor<TOptions>:
  * Supports:
    * Change notifications
    * Named options
    * Reloadable configuration
    * Selective options invalidation (IOptionsMonitorCache<TOptions>)
  * Is used to retrieve options and manage options notifications 
    for TOptions instances.
  * Is registered as a Singleton and can be injected into any service lifetime.

But honestly, for me, it makes things unclear, so my main intention is to bring clarity to these interfaces. For that purpose, I’ll need a small code demo. In a plain ASP.NET Core Web API project, let’s add a TestOptions class:

    public class TestOptions
    {
        public static string SectionName = "Test";
        public string Property1 { get; set; }
        public string Property2 { get; set; }
        public string Property3 { get; set; }
        public string AppendingProperty { get; set; }
    }

And set up our Options in the Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddOptions<TestOptions>()
    .Bind(builder.Configuration.GetSection(TestOptions.SectionName));
builder.Services.Configure<TestOptions>(options =>
{
    //ussed to show that the configuration is applied in order of registration
    options.AppendingProperty += "[after_binding]";
});
...

and the following to appsettings.json:

And TestController class:

[Route("api/[controller]")]
    [ApiController]
    public class TestController : ControllerBase
    {
        private readonly IOptionsSnapshot<TestOptions> _testOptionsSnapshot;
        private readonly IOptions<TestOptions> _testOptions;
        private readonly IOptionsMonitor<TestOptions> _optionsMonitor;

        public TestController(
            IOptionsSnapshot<TestOptions> testOptionsSnapshot,
            IOptions<TestOptions> testOptions,
            IOptionsMonitor<TestOptions> optionsMonitor)
        {
            _testOptionsSnapshot = testOptionsSnapshot;
            _testOptions = testOptions;
            _optionsMonitor = optionsMonitor;
        }

        [HttpGet("nowait")]
        public async Task<IActionResult> InvokeWithouWait()
        {
            var result = new
            {
                Options = _testOptions.Value,
                OptionsSnapshot = _testOptionsSnapshot.Value,
                OptionsMonitor = _optionsMonitor.CurrentValue
            };
            return Ok(
                result
                );
        }

        [HttpGet("wait")]
        public async Task<IActionResult> IndexAsync()
        {
            var beforeChange = new
            {
                Options = _testOptions.Value,
                OptionsSnapshot = _testOptionsSnapshot.Value,
                OptionsMonitor = _optionsMonitor.CurrentValue
            };

            //code to waite for 1 minte
            //change the value of appsettings.json file in this time
            await Task.Delay(20000);

            var afterChange = new
            {
                Options = _testOptions.Value,
                OptionsSnapshot = _testOptionsSnapshot.Value,
                OptionsMonitor = _optionsMonitor.CurrentValue
            };

            var result = new { beforeChange, afterChange };
            return Ok(
                result
                );
        }
    }

As you can see from the code above, we are defining the endpoint /api/test/wait, which retrieves configuration from every registered interface twice. This delay is needed to allow enough time to edit and save appsettings.json while making the request (to show you the difference).:

I changed Property3 during this waiting period, and as you can see, only the IOptionsMonitor reacted to the changes. If we make a request again to /api/test/nowait, these changes will also be reflected in IOptionsSnapshot but not in the IOptions.

While everything is very clear with IOptions — it never changes since the first utilization, the other two require a few more words:

This works that way because IOptionsSnapshot composes the options object in each request upon first retrieval (I don’t recommend using this as it’s a performance-intensive feature unless you really need to reconstruct options and preserve their integrity per request). On the other hand, IOptionsMonitor reacts to changes in bound sources and removes only the changed objects from the cache (unfortunately, we won’t cover it here as it’s another extensive topic for discussion).

I hope this gives you an idea of how these interfaces differ and how they behave

IPostConfigureOptions

Apart from the IConfigureOptions, IPostConfigureOptions<TOptions> can be used to apply additional configuration. IPostConfigureOptions take place after all registered IConfigureOptions implementations have been executed on an options object during the composing process. (While you still can register more than one IPostConfigureOptions implementation, they are executed in the same - registered order).

Why do we need IPostConfigureOptions if we can register as many IConfigureOptions? They operate with values that have already been set, as you can see in the above responses. AppendingProperty was initially set by Bin(…) and then modified with the next registered IConfigureOptions, as we wrote it:

builder.Services.AddOptions<TestOptions>()
    .Bind(builder.Configuration.GetSection(TestOptions.SectionName));
builder.Services.Configure<TestOptions>(options =>
{
    //ussed to show that the configuration is applied in order of registration
    options.AppendingProperty += "[after_binding]"; 
});

The answer is simple: we may want to give third parties a chance to make configurations on their own, but we still need a few steps at the end of the configuration process. For example, the AddJwt authentication scheme works in a way that allows you to configure everything on your own. However, in case you haven’t registered JWKs but have registered the authority URL, it reads them from there. So even if we add PostConfigure in the middle of the configuration:

builder.Services.AddOptions<TestOptions>()
    .Bind(builder.Configuration.GetSection(TestOptions.SectionName));
builder.Services.PostConfigure<TestOptions>(options =>
{
    options.AppendingProperty += "[in_post_configure]";
});
builder.Services.Configure<TestOptions>(options =>
{
    //ussed to show that the configuration is applied in order of registration
    options.AppendingProperty += "[after_binding]"; 
});

We will still get it in the predictable order:

Named Options

You’ve already seen this mentioned throughout the article a few times, so let’s figure out how it works and why we need it. But first, recall that the Named Options Feature is supported only by either IOptionsSnapshot or IOptionsMonitor. So, what do they allow us to have?

Let’s create a new class to demonstrate such options:

public class TestNamedOptions
{
    public string Property { get; set; }
    public string PropertyToDemoAll { get; set; }
}

Set up it in the Program.cs:

builder.Services.ConfigureAll<TestNamedOptions>(options =>
{
    options.PropertyToDemoAll = "Configured in ConfigureAll";
});
builder.Services.AddOptions<TestNamedOptions>("Name1")
    .Configure(options =>
    {
        options.Property = "Name1 otpions property";
    });
builder.Services.AddOptions<TestNamedOptions>("Name2")
     .Configure(options =>
     {
         options.Property = "Name2 otpions property";
     });

Inject IOptionsMonitor<TestNamedOptions> namedOptionsMonitor to the TestController and add a new action to it:

[HttpGet("named")]
public async Task<IActionResult> GetNamedOptions()
{
    var result = new
    {
        Name1 = _namedOptionsMonitor.Get("Name1"),
        Name2 = _namedOptionsMonitor.Get("Name2")
    };
    return Ok(result);
}

And nothing explains it better as a demo:

As you can see, the named options feature allows us to register the same options type multiple times, configure them differently (and even share configuration among all via ConfigureAll, as you may observe from propertyToDemoAll), and then you can easily retrieve the needed configuration by name. This feature is widely used throughout the ASP framework, for example in Authentication, where you register the scheme and its configuration, with the scheme name actually being the name of the options object.

To bring things back to reality, you need to be aware of IConfigureNamedOptions<TOptions>, which is used whenever you call default configuration mechanisms. Even if you don’t specify the name directly, it is registered with Options.DefaultName (which is actually an empty string). However, it inherits from IConfigureOptions<TOptions>, which is why I still consider IConfigureOptions as something that underlies it.

I think it’s important to understand how options are constructed, and here is the code from the fabric class that composes an options object:

...
foreach (IConfigureOptions<TOptions> setup in _setups)
{
    if (setup is IConfigureNamedOptions<TOptions> namedSetup)
    {
        namedSetup.Configure(name, options);
    }
    else if (name == Options.DefaultName)
    {
        setup.Configure(options);
    }
}
foreach (IPostConfigureOptions<TOptions> post in _postConfigures)
{
    post.PostConfigure(name, options);
}
...

Where _setups is retrieved directly via injection all registered IConfigureOptions from DI:

public OptionsFactory(
  IEnumerable<IConfigureOptions<TOptions>> setups, 
  IEnumerable<IPostConfigureOptions<TOptions>> postConfigures, 
  IEnumerable<IValidateOptions<TOptions>> validations
)...

So now I hope it makes more sense to you how the options are registered, configured, and constructed.

Validation

The final point of our journey — validation. Validators are executed right after the creation of options, actually within the same function. You’ve already seen this code, but here’s the full version:

public TOptions Create(string name)
{
    TOptions options = CreateInstance(name);
    foreach (IConfigureOptions<TOptions> setup in _setups)
    {
        if (setup is IConfigureNamedOptions<TOptions> namedSetup)
        {
            namedSetup.Configure(name, options);
        }
        else if (name == Options.DefaultName)
        {
            setup.Configure(options);
        }
    }
    foreach (IPostConfigureOptions<TOptions> post in _postConfigures)
    {
        post.PostConfigure(name, options);
    }

    if (_validations != null)
    {
        var failures = new List<string>();
        foreach (IValidateOptions<TOptions> validate in _validations)
        {
            ValidateOptionsResult result = validate.Validate(name, options);
            if (result is not null && result.Failed)
            {
                failures.AddRange(result.Failures);
            }
        }
        if (failures.Count > 0)
        {
            throw new OptionsValidationException(name, typeof(TOptions), failures);
        }
    }

    return options;
}

Validation can be provided via 3 main places: DataAnataion Validation Predicate External Validator

To demonstrate it we need options class and external validator:

public class TestValidationOptions
{
    [Required]
    public string DataAnatationProperty { get; set; }
    public string ValidationPredicateProperty { get; set; }
    public string ExternalValidatorProperty { get; set; }
}

public class TestValidationOptionsValidator : IValidateOptions<TestValidationOptions>
{
    public ValidateOptionsResult Validate(string name, TestValidationOptions options)
    {
        if (options.ExternalValidatorProperty == null)
        {
            return ValidateOptionsResult.Fail("ExternalValidatorProperty is null");
        }
        return ValidateOptionsResult.Success;
    }
}

As you can see, DataAnnotationProperty has the [Required] attribute from System.ComponentModel.DataAnnotations. The external validator also validates as required by comparing with null. Then we need to add these options to the Program.cs:

builder.Services.AddOptions<TestValidationOptions>()
    .Configure(options =>
    {
        options.DataAnatationProperty = "Configured in class";
        options.ValidationPredicateProperty = "Configured in class";
        options.ExternalValidatorProperty = "Configured in class";
    })
    .ValidateDataAnnotations()
    .Validate(options =>
    {
        return options.ValidationPredicateProperty != null;
    }, "ConfigurationProperty is null");
builder.Services.AddSingleton<IValidateOptions<TestValidationOptions>, TestValidationOptionsValidator>();

Similarly, for the external validator, we compare it with null. So now, if we add the following action to the TestController and inject IOptionsMonitor<TestValidationOptions>:

        [HttpGet("validation")]
        public async Task<IActionResult> GetValidationOptions()
        {
            var result = _validationOptionsMonitor.CurrentValue;
            return Ok(result);
        }

We will see that all is fine:

But if, for example, we comment out the setup of the DataAnnotationProperty, we will encounter an error when trying to access /api/test/validation:

But we also can add ValidateOnStart() to immediately break the application while attending to run with incorrect configuration:

And now you won’t be able to proceed until all validations are passed.

DI during Options Creation

There are two primary ways to inject services into the Configurator or Validator: either register them as a separate class and use constructor injection, or use a specific method of OptionsBuilder that allows you to specify services, like:

.Configure<IMyService1, IMyService2>((options, myService1, myService2) =>..);

And that’s a wrap!

The code can be found here.

I hope that this article was not only informative but also provided you with a clear understanding of the intricacies of the Options Pattern.

Also, if you enjoyed this story and found it insightful, be sure to follow me for more engaging content like this in the future. You can also follow me on LinkedIn where I post notifications about my new publication with free access links.

In addition, if you build multitenant applications you may find this helpful: Tenanted Options for Multitenant Applications in ASP

Aspnetcore
C Sharp Programming
Configuration Management
Software Development
Recommended from ReadMedium