Options as Services

Options as Services

It seems like, whenever I use options (which is practically every day), I end up having to go through a predictable set of steps in order to produce something I can then use in my projects.

For instance, I usually have to:

  1. Create an instance of whatever options class I’m working with.
  2. Get an instance of whatever IConfiguration object I’m using for configuration purposes and bind that configuration to my options instance.
  3. Perform any decryption required for properties that are decorated to indicate they require cypto support.
  4. Validate the resulting options instance, to ensure that we’ve produced an object that meets minimum data standards.

In addition to everything above, I also usually have to register my options instance as a service, with whatever DI container my project is using. That’s because I want to rely on my DI container to supply me with options objects. I don’t want to have to mess with anything configuration related, outside of my startup code.

Like any good software developer, I’m way too lazy to write that code more than once. So, I created a handful of extension methods to encapsulate this work pattern. Let’s go look at that code:

public static bool TryConfigureOptions<TOptions>(
    this IServiceCollection serviceCollection,
    IConfiguration configuration
    ) where TOptions : class, new()
{
    Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection))
        .ThrowIfNull(configuration, nameof(configuration));

    if (false == configuration.GetChildren().Any())
    {
        return false;
    }

    var options = new TOptions();

    configuration.Bind(options);
    configuration.DecryptProperties(options);

    if (options is OptionsBase)
    {
        if (false == (options as OptionsBase).IsValid())
        {
            return false;
        }
    }

    serviceCollection.TryAddSingleton<IOptions<TOptions>>(
        new OptionsWrapper<TOptions>(options)
        );

    return true;
}

All the extension methods I’m about to cover are hung off the IServiceCollection type, since that’s where I’m usually building up my options – somewhere near the code that sets up the DI container (think Startup class).

This particular method, TryConfigureOptions, accepts two parameters: an IServiceCollection instance, and an IConfiguration instance. The method then starts by validating those parameters. After that it checks to see if the configuration lacks any properties. If it does, the method returns false. In other words, if the underlying configuration file is missing, or the section I expect is missing, or the settings within that section are missing, tell the caller that we didn’t configure the options. This gives the caller the ability to then setup a minimally acceptable default set of options. At the very least, that’s what I’m going for, with that check.

Next I create an instance of the specified options class. After that I bind the configuration to that options instance. Then I decrypt any properties, on that options instance, that are decorated with the ProtectedPropertyAttribute, which signifies that it contains a string value in need of decrypting. I wrote about a lot of this earlier. If none of this sounds familiar then go back and read some of my earlier posts about options.

At this point, I should have a populated and decrypted options instance. So, the next thing I do is check to see if the option’s type is derived from the OptionsBase class. If so, I can validate those options now. If the options fail validation, I return false.

Assuming everything has worked up to this point, then the only thing left to do is to configure those options with the DI container. I do that by calling the TryAddSingleton method, on the IServiceCollection instance. I wrap the options instance in an OptionsWrapper object, since any caller will be expecting the options object to be wrapped in the IOptions type. If that seems odd, or if you’re struggling to remember where you’ve seen the IOptions interface before, HERE is a link to the document for the Microsoft options pattern.

The next variation of TryConfigureOptions has three parameters: an IServiceCollection instance, an IConfiguration instance, and an outgoing TOptions parameter. This is for those times when you need to configure your options and then use them right afterwards. The rest of this method is identical to the first variation, so, I’ll save us all from going over it all again. Just realize, if this method returns true, then the outgoing options parameter will contain a valid options instance.

The final overload of the TryConfigureOptions method accepts an IServiceCollection instance and a TOptions instance. Here is the code for that method:

public static bool TryConfigureOptions<TOptions>(
    this IServiceCollection serviceCollection,
    TOptions options
    ) where TOptions : class, new()
{
    Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection))
        .ThrowIfNull(options, nameof(options));

    if (options is OptionsBase)
    {
        if (false == (options as OptionsBase).IsValid())
        {
            return false;
        }
    }

    serviceCollection.TryAddSingleton<IOptions<TOptions>>(
        new OptionsWrapper<TOptions>(options)
        );

    return true;
}

This method makes no attempt to populate or decrypt the incoming options instance. It does, however, check to see if it can perform the validation step. If it can, it does. Afterwards, it registers the options instance with the DI container.

Up ’till now, all the extension methods I’ve been showing return either true or false, depending on whether the operation failed or succeeded. That’s fine for some cases, but, I knew I would also want a variant that would throw an exception if anything went wrong. Enter the next method, named ConfigureOptions:

public static IServiceCollection ConfigureOptions<TOptions>(
    this IServiceCollection serviceCollection,
    IConfiguration configuration
    ) where TOptions : class, new()
{
    Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection))
        .ThrowIfNull(configuration, nameof(configuration));

    var options = new TOptions();

    configuration.Bind(options);
    configuration.DecryptProperties(options);

    (options as OptionsBase)?.ThrowIfInvalid();

    serviceCollection.TryAddSingleton<IOptions<TOptions>>(
        new OptionsWrapper<TOptions>(options)
        );

    return serviceCollection;
}

This method starts by validating the incoming parameters. It then creates an instance of the TOptions type. Next it binds the options instance to the configuration. After that it tries to decrypt any properties, on the TOptions type, that are decorated with the the ProtectedPropertyAttribute attribute, which signifies that it contains a string value in need of decrypting. Next, the method validates the options instance, if it’s derived from OptionsBase, of course. Finally, the method registers the options instance with the DI container, as a singleton service,

The next variation of ConfigureOptions contains the outgoing TOptions parameter, again for when you want to configure the options and immediately use the options instance. Again, I won’t go over the code for that method because it’s so much like the previous variant, except for the outgoing TOptions instance.

Finally, there is also a variant of ConfigureOptions that accepts an incoming TOptions instance. That method looks like this:

public static IServiceCollection ConfigureOptions<TOptions>(
    this IServiceCollection serviceCollection,
    TOptions options
    ) where TOptions : class, new()
{
    Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection))
        .ThrowIfNull(options, nameof(options));

    (options as OptionsBase)?.ThrowIfInvalid();

    serviceCollection.TryAddSingleton<IOptions<TOptions>>(
        new OptionsWrapper<TOptions>(options)
        );

    return serviceCollection;
}

Here I start by validating the incoming parameters. After that I validate the TOptions instance, provided it derives from the OptionsBase class, of course. After that I add the options instance to the DI container, using the TryAddSingleton method on the IServiceCollection instance.

Using these extension methods is easy. Here are some quick examples:

void ConfigureServices(IServiceCollection services)
{
   services.TryConfigureOptions<MyOptions>(
      Configuration.GetSection("MySection")
   );

   // MyOptions is now available as a singleton service.

}

Or, if you want to immediately use the options:

void ConfigureServices(IServiceCollection services)
{
   services.TryConfigureOptions<MyOptions>(
      Configuration.GetSection("MySection"),
      out var options
   );

   // Options is now available as a singleton service
   // options is a valid MyOptions instance
}

Here are the variants that throw exceptions:

void ConfigureServices(IServiceCollection services)
{
   services.ConfigureOptions<MyOptions>(
      Configuration.GetSection("MySection")
   );

   // MyOptions is now available as a singleton service.

}

And, of course:

void ConfigureServices(IServiceCollection services)
{
   services.ConfigureOptions<MyOptions>(
      Configuration.GetSection("MySection"),
      out var options
   );

   // MyOptions is now available as a singleton service.
   // options is a valid MyOptions instance
}

Of course, you can also use the third variant, like this:

void ConfigureServices(IServiceCollection services)
{
   if (false == services.TryConfigureOptions<MyOptions>(
      Configuration.GetSection("MySection"),
      out var options
      ))
   {
      options = new MyOptions();
      // todo decide on default values here.
    
      services.ConfigureOptions(options);   
   }

   // MyOptions is now available as a singleton service.
   // options is a valid MyOptions instance
}

This way, we only throw an exception if we failed to configure the options using the configuration, and, we also failed to create minimal default configuration settings and register those with the DI container.

That’s about it for my options extensions. I’m always tweaking, adding, changing, so it’s best to go see what’s out on the CODEGATOR CG.Options page, on GITHUB, HERE.

Photo by Erik Mclean on Unsplash