blog post image
Andrew Lock avatar

Andrew Lock

~8 min read

Using multiple instances of strongly-typed settings with named options in .NET Core 2.x

ASP.NET Core has used the Options pattern to configure strongly typed settings objects since before version 1.0. Since then, the feature has gained more features. For example ASP.NET Core 1.1 introduced IOptionsSnapshot which allows your strongly typed options to update when the underlying IConfigurationRoot changes (e.g. when you change your appsettings.json file)

In this post I discuss your options when you want to register multiple instances of a strongly-typed settings object in the dependency injection container. In particular, I show how to use named options to register each configured object with a different name.

I'll start by recapping on how you typically use the options pattern with strongly typed settings, the IOptions<T> interface, and the IOptionsSnapshot<T> interface. Then I'll dig into three possible ways to register multiple instances of strongly typed settings in the the DI container.

Using strongly typed settings

The options pattern allows the use of strongly typed settings by binding POCO objects to an IConfiguration object. I covered this process in a recent post, so I'll be relatively brief here.

We'll start with a strongly typed settings object that you can bind to configuration, and inject into your services:

public class SlackApiSettings  
{
    public string WebhookUrl { get; set; }
    public string DisplayName { get; set; }
}

You can bind that to a configuration section in Startup.ConfigureServices using Configure<T>():

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<SlackApiSettings>(Configuration.GetSection("SlackApi")); 
}

The Configure method binds your configuration (loaded from appsettings.json, environment variables, user secrets etc) to the SlackApiSettings object. You can also configure an IOptions<> object using an overload of Configure() that takes an Action<> instead of a configuration section, so you can use configuration in code, e.g.

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<SlackApiSettings>(x => x.DisplayName = "My Slack Bot"); 
}

You can access the configured SlackApiSettings object by injecting the IOptions<SlackApiSettings> interface into your services:

public class SlackNotificationService
{
    private readonly SlackApiSettings _settings;
    public SlackNotificationService(IOptions<SlackApiSettings> options)
    {
        _settings = options.Value
    }

    public void SendNotification(string message)
    {
        // use the settings to send a message
    }
}

The configured strongly typed settings object is available on the IOptions<T>.Value property. Alternatively, you can inject an IOptionsSnapshot<T> instead.

Handling configuration changes with IOptionsSnapshot<T>

The example I've shown so far is probably the most typical usage (though it's also common to avoid taking a dependency on IOptions<T> in your services). Using IOptions<T> for strongly typed configuration assumes that your configuration is fixed for the lifetime of the app. The configuration values are calculated and bound to your POCO objects once; if you later change your appsettings.json file for example, the changes won't show up in your app.

Personally, I've found that to be fine in virtually all my apps. However, if you do need to support reloading of configuration, you can do so with the IOptionsSnapshot<T> interface. This interface is configured at the same time as the IOptions<T> interface, so you don't have to do anything extra to use it in your apps. Simply inject it into your services, and access the configured settings object on the IOptionsSnapshot<T>.Value property:

public class SlackNotificationService
{
    private readonly SlackApiSettings _settings;
    public SlackNotificationService(IOptionsSnapshot<SlackApiSettings> options)
    {
        _settings = options.Value
    }
}

If you later change the value of your configuration, e.g. by editing your appsettings.json file, the IOptionsSnapshot<T> will update the strongly typed configuration on the next request, and you'll see the new values. Note that the configuration values essentially have a "Scoped" lifetime - you'll see the same configuration values in IOptionsSnapshot<T> for the lifetime of the request.

Not all configuration providers support configuration reloading. The file-based providers all do but the environment variables provider doesn't, for example.

Reloading configuration could be useful in some cases, but IOptionsSnapshot<T> also has another trick up its sleeve - named options. We'll get to them shortly, but first we'll look at a problem you may run into occasionally where you need to have multiple instances of a settings object.

Using multiple instances of a strongly-typed settings object

The typical use case I see for IOptions<T> is for finely-grained strongly-typed settings. The binding system makes it easy for you to inject small, focused POCO objects for each specific service.

But what if you want to configure multiple objects which all have the same properties. For example, consider the SlackApiSettings I've used so far. To post a message to Slack, you need a WebHook URL, and a display name. The SlackNotificationService uses these values to send a message to a specific channel in Slack when you call SendNotification(message).

What if you wanted to update the SlackNotificationService to allow you to send messages to multiple channels. For example:

public class SlackNotificationService
{
    public void SendNotificationToDevChannel(string message) { }
    public void SendNotificationToGeneralChannel(string message) { }
    public void SendNotificationToPublicChannel(string message) { }
}

I've added methods for three different channels here Dev, General, Public. The question is, how do we configure the WebHook URL and Display Name for each channel? To provide some context, I'll assume that we're binding our configuration to a single appsettings.json file that looks like this:

{
  "SlackApi": {
    "DevChannel" : {
      "WebhookUrl": "https://hooks.slack.com/T1/B1/111111",
      "DisplayName": "c0mp4ny 5l4ck b07"
    },
    "GeneralChannel" : {
      "WebhookUrl": "https://hooks.slack.com/T2/B2/222222",
      "DisplayName": "Company Slack Bot"
    },
    "PublicChannel" : {
      "WebhookUrl": "https://hooks.slack.com/T3/B3/333333",
      "DisplayName": "Professional Looking name"
    }
  }

There's a few options available to us in how we configure the settings for SlackNotificationService; I'll step through three of them below.

1. Create a parent settings object

One way to provide the settings for each channel would be to extend the SlackApiSettings object to include properties for each channel's settings. For example:

public class SlackApiSettings  
{
    public ChannelSettings DevChannel { get; set; }
    public ChannelSettings GeneralChannel { get; set; }
    public ChannelSettings PublicChannel { get; set; }

    public class ChannelSettings  
    {
        public string WebhookUrl { get; set; }
        public string DisplayName { get; set; }
    }
}

I've created a nested ChannelSettings object, and used a separate instance for each channel, with a property for each on the top-level SlackApiSettings. Configuring these settings is simple, as I was careful to match the appsettings.json and the SlackApiSettings hierarchy:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<SlackApiSettings>(Configuration.GetSection("SlackApi")); 
}

In the SlackNotificationService we continue to inject the single settings objects as before:

public class SlackNotificationService
{
    private readonly SlackApiSettings _settings;
    public SlackNotificationService(IOptions<SlackApiSettings> options)
    {
        _settings = options.Value
    }
}

The advantage of this approach is that it's easy to understand what's going on, and it provides strongly typed access to each channel's settings. The downside is that adding support for another channel involves editing the SlackApiSettings class, which may not be possible (or desirable) in some cases.

2. Create separate classes for each channel

An alternative approach is to treat each channel's settings as independent. We would configure and register each channel settings object separately, and inject them all into the SlackNotificationService. For example, we could start with an abstract ChannelSettings class:

public abstract class ChannelSettings  
{
    public string WebhookUrl { get; set; }
    public string DisplayName { get; set; }
}

And derive our individual channel settings from this:

public class DevChannelSettings: ChannelSettings { }
public class GeneralChannelSettings: ChannelSettings { }
public class PublicChannelSettings: ChannelSettings { }

To configure our options, we need to call Configure<T> for each channel, passing in the section to bind:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<DevChannelSettings>(Configuration.GetSection("SlackApi:DevChannel")); 
    services.Configure<GeneralChannelSettings>(Configuration.GetSection("SlackApi:GeneralChannel")); 
    services.Configure<PublicChannelSettings>(Configuration.GetSection("SlackApi:PublicChannel")); 
}

As we have different settings object for each channel, we need to inject them all individually into the SlackNotificationService:

public class SlackNotificationService
{
    private readonly DevChannelSettings _devSettings;
    private readonly GeneralChannelSettings _generalSettings;
    private readonly PublicChannelSettings _publicSettings;

    public SlackNotificationService(
        IOptions<DevChannelSettings> devOptions
        IOptions<GeneralChannelSettings> generalOptions
        IOptions<PublicChannelSettings> publicOptions)
    {
        _devSettings = devOptions;
        _generalSettings = generalOptions;
        _publicSettings = publicOptions;
    }
}

The advantage of this approach is that it allows you to add extra ChannelSettings without editing existing classes. It also makes it possible to inject a subset of the channel settings if that's all that's required. However it also makes things rather more complex to configure and use, with each new channel requiring a new options object, a new call to Configure(), and modifying the constructor of the SlackNotificationService.

3. Use named options

Which brings us to the focal point of this post - named options. Named options are what they sound like - they're strongly-typed configuration options that have a unique name. This lets you retrieve them by name when you need to use them.

With named options, you can have multiple instances of strongly-typed settings which are configured independently. That means we can continue to use the original SlackApiSettings object we defined at the start of the post:

public class SlackApiSettings  
{
    public string WebhookUrl { get; set; }
    public string DisplayName { get; set; }
}

The difference comes in how we configure it:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<SlackApiSettings>("Dev", Configuration.GetSection("SlackApi:DevChannel")); 
    services.Configure<SlackApiSettings>("General", Configuration.GetSection("SlackApi:GeneralChannel")); 
    services.Configure<SlackApiSettings>("Public", Configuration.GetSection("SlackApi:PublicChannel")); 
}

We configure each channel separately using the appropriate configuration section (e.g. "SlackApi:DevChannel"), but we also provide a name as the first parameter to the Configure<T> call. This name allows us to retrieve the specific configuration from our consuming services.

To use these named options, you must inject IOptionsSnapshot<T>, not IOptions<T> into the SlackNotificationService. This gives you access to the IOptionsSnapshot<T>.Get(name) method, that you can use to retrieve the individual options.

public class SlackNotificationService
{
    private readonly SlackApiSettings _devSettings;
    private readonly SlackApiSettings _generalSettings;
    private readonly SlackApiSettings _publicSettings;

    public SlackNotificationService(IOptionsSnapshot<SlackApiSettings> options)
    {
        _devSettings = options.Get("Dev");
        _generalSettings = options.Get("General");
        _publicSettings = options.Get("Public");
    }
}

The big advantage of this approach is that you don't need to create any new classes or methods to add a new channel, you just need to configure a new named SlackApiSettings options object. The constructor of SlackNotifictionService is untouched as well. On the disadvantage side, it's not clear from the SlackNotificationService constructor exactly which settings objects it's dependent on. Also, you're now truly dependent on the scoped IOptionsSnapshot<T> interface, so there's not an easy way to remove the IOptions<> dependency as I've described previously.

Which approach works best for you will depend on your requirements and your general preferences. Option 1 is the simplest in many ways, and if you don't expect any extra instances of the options object to be added then it may be a good choice. Option 2 is handy if additional instances might be added later, but you control when they're added (and so can update the consumer service as required). Option 3 is particularly useful when you don't have control over when new options are added. For example, the ASP.NET Core framework itself uses named options for authentication options, where new authentication handlers can be used that the core framework has no knowledge of.

Summary

In this post I provided a recap on how to use strongly typed settings with the Options pattern in ASP.NET Core. I then discussed a requirement to register multiple instances of strongly typed settings in the ASP.NET Core DI container. I described three possible ways to achieve this: creating a parent settings object, creating separate derived classes for each setting, or using named options. Named options can be retrieved using the IOptionsSnapshot<T> interface, using the Get(name) method.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?