Options pattern guidance for .NET library authors

With the help of dependency injection, registering your services and their corresponding configurations can make use of the options pattern. The options pattern enables consumers of your library (and your services) to require instances of options interfaces where TOptions is your options class. Consuming configuration options through strongly-typed objects helps to ensure consistent value representation, enables validation with data annotations, and removes the burden of manually parsing string values. There are many configuration providers for consumers of your library to use. With these providers, consumers can configure your library in many ways.

As a .NET library author, you'll learn general guidance on how to correctly expose the options pattern to consumers of your library. There are various ways to achieve the same thing, and several considerations to make.

Naming conventions

By convention, extension methods responsible for registering services are named Add{Service}, where {Service} is a meaningful and descriptive name. Add{Service} extension methods are commonplace in ASP.NET Core and .NET alike.

✔️ CONSIDER names that disambiguate your service from other offerings.

❌ DO NOT use names that are already part of the .NET ecosystem from official Microsoft packages.

✔️ CONSIDER naming static classes that expose extension methods as {Type}Extensions, where {Type} is the type that you're extending.

Namespace guidance

Microsoft packages make use of the Microsoft.Extensions.DependencyInjection namespace to unify the registration of various service offerings.

✔️ CONSIDER a namespace that clearly identifies your package offering.

❌ DO NOT use the Microsoft.Extensions.DependencyInjection namespace for non-official Microsoft packages.

Parameterless

If your service can work with minimal or no explicit configuration, consider a parameterless extension method.

using Microsoft.Extensions.DependencyInjection;

namespace ExampleLibrary.Extensions.DependencyInjection;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddMyLibraryService(
        this IServiceCollection services)
    {
        services.AddOptions<LibraryOptions>()
            .Configure(options =>
            {
                // Specify default option values
            });

        // Register lib services here...
        // services.AddScoped<ILibraryService, DefaultLibraryService>();

        return services;
    }
}

In the preceding code, the AddMyLibraryService:

IConfiguration parameter

When you author a library that exposes many options to consumers, you may want to consider requiring an IConfiguration parameter extension method. The expected IConfiguration instance should be scoped to a named section of the configuration by using the IConfiguration.GetSection function.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace ExampleLibrary.Extensions.DependencyInjection;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddMyLibraryService(
      this IServiceCollection services,
      IConfiguration namedConfigurationSection)
    {
        // Default library options are overridden
        // by bound configuration values.
        services.Configure<LibraryOptions>(namedConfigurationSection);

        // Register lib services here...
        // services.AddScoped<ILibraryService, DefaultLibraryService>();

        return services;
    }
}

In the preceding code, the AddMyLibraryService:

Consumers in this pattern provide the scoped IConfiguration instance of the named section:

using ExampleLibrary.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMyLibraryService(
    builder.Configuration.GetSection("LibraryOptions"));

using IHost host = builder.Build();

// Application code should start here.

await host.RunAsync();

The call to .AddMyLibraryService is made on the IServiceCollection type.

As the library author, specifying default values is up to you.

Note

It is possible to bind configuration to an options instance. However, there is a risk of name collisions - which will cause errors. Additionally, when manually binding in this way, you limit the consumption of your options pattern to read-once. Changes to settings will not be re-bound, as such consumers will not be able to use the IOptionsMonitor interface.

services.AddOptions<LibraryOptions>()
    .Configure<IConfiguration>(
        (options, configuration) =>
            configuration.GetSection("LibraryOptions").Bind(options));

Instead, you should use the BindConfiguration extension method. This extension method binds the configuration to the options instance, and also registers a change token source for the configuration section. This allows consumers to use the IOptionsMonitor interface.

Configuration section path parameter

Consumers of your library may want to specify the configuration section path to bind your underlying TOptions type. In this scenario, you define a string parameter in your extension method.

using Microsoft.Extensions.DependencyInjection;

namespace ExampleLibrary.Extensions.DependencyInjection;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddMyLibraryService(
      this IServiceCollection services,
      string configSectionPath)
    {
        services.AddOptions<SupportOptions>()
            .BindConfiguration(configSectionPath)
            .ValidateDataAnnotations()
            .ValidateOnStart();

        // Register lib services here...
        // services.AddScoped<ILibraryService, DefaultLibraryService>();

        return services;
    }
}

In the preceding code, the AddMyLibraryService:

In the next example, the Microsoft.Extensions.Options.DataAnnotations NuGet package is used to enable data annotation validation. The SupportOptions class is defined as follows:

using System.ComponentModel.DataAnnotations;

public sealed class SupportOptions
{
    [Url]
    public string? Url { get; set; }

    [Required, EmailAddress]
    public required string Email { get; set; }

    [Required, DataType(DataType.PhoneNumber)]
    public required string PhoneNumber { get; set; }
}

Imagine that the following JSON appsettings.json file is used:

{
    "Support": {
        "Url": "https://support.example.com",
        "Email": "help@support.example.com",
        "PhoneNumber": "+1(888)-SUPPORT"
    }
}

Action<TOptions> parameter

Consumers of your library may be interested in providing a lambda expression that yields an instance of your options class. In this scenario, you define an Action<LibraryOptions> parameter in your extension method.

using Microsoft.Extensions.DependencyInjection;

namespace ExampleLibrary.Extensions.DependencyInjection;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddMyLibraryService(
        this IServiceCollection services,
        Action<LibraryOptions> configureOptions)
    {
        services.Configure(configureOptions);

        // Register lib services here...
        // services.AddScoped<ILibraryService, DefaultLibraryService>();

        return services;
    }
}

In the preceding code, the AddMyLibraryService:

Consumers in this pattern provide a lambda expression (or a delegate that satisfies the Action<LibraryOptions> parameter):

using ExampleLibrary.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMyLibraryService(options =>
{
    // User defined option values
    // options.SomePropertyValue = ...
});
                                                                        
using IHost host = builder.Build();

// Application code should start here.

await host.RunAsync();

Options instance parameter

Consumers of your library might prefer to provide an inlined options instance. In this scenario, you expose an extension method that takes an instance of your options object, LibraryOptions.

using Microsoft.Extensions.DependencyInjection;

namespace ExampleLibrary.Extensions.DependencyInjection;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddMyLibraryService(
      this IServiceCollection services,
      LibraryOptions userOptions)
    {
        services.AddOptions<LibraryOptions>()
            .Configure(options =>
            {
                // Overwrite default option values
                // with the user provided options.
                // options.SomeValue = userOptions.SomeValue;
            });

        // Register lib services here...
        // services.AddScoped<ILibraryService, DefaultLibraryService>();

        return services;
    }
}

In the preceding code, the AddMyLibraryService:

Consumers in this pattern provide an instance of the LibraryOptions class, defining desired property values inline:

using ExampleLibrary.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMyLibraryService(new LibraryOptions
{
    // Specify option values
    // SomePropertyValue = ...
});

using IHost host = builder.Build();

// Application code should start here.

await host.RunAsync();

Post configuration

After all configuration option values are bound or specified, post configuration functionality is available. Exposing the same Action<TOptions> parameter detailed earlier, you could choose to call PostConfigure. Post configure runs after all .Configure calls. There are few reasons why you'd want to consider using PostConfigure:

  • Execution order: You can override any configuration values that were set in the .Configure calls.
  • Validation: You can validate the default values have been set after all other configurations have been applied.
using Microsoft.Extensions.DependencyInjection;

namespace ExampleLibrary.Extensions.DependencyInjection;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddMyLibraryService(
      this IServiceCollection services,
      Action<LibraryOptions> configureOptions)
    {
        services.PostConfigure(configureOptions);

        // Register lib services here...
        // services.AddScoped<ILibraryService, DefaultLibraryService>();

        return services;
    }
}

In the preceding code, the AddMyLibraryService:

Consumers in this pattern provide a lambda expression (or a delegate that satisfies the Action<LibraryOptions> parameter), just as they would with the Action<TOptions> parameter in a non-post configuration scenario:

using ExampleLibrary.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMyLibraryService(options =>
{
    // Specify option values
    // options.SomePropertyValue = ...
});

using IHost host = builder.Build();

// Application code should start here.

await host.RunAsync();

See also