You can find the complete example and its tests in the example repository on GitHub.

To apply the pattern to your own .NET project, use this AI prompt, then review and test the changes it makes.

In your .NET project, reading a value from appsettings.json is easy. The harder part is making sure every class reads it consistently, without repeating section names as strings throughout the code. If a section name changes, finding and updating every reference can be difficult. We also need to check that the configuration values make sense before we start using them.

Let’s say we have an API that returns a list of products. We add a configuration section to define the default page size and the maximum number of items allowed per page:

{
  "Pagination": {
    "DefaultPageSize": 20,
    "MaxPageSize": 100
  }
}

DefaultPageSize defines the size to use when the caller does not specify one. MaxPageSize defines the upper limit. Our service will use these values to return 20 by default and cap requests at 100.

Nothing complicated so far.

To get those values, we could inject IConfiguration into the service and read Pagination:DefaultPageSize and Pagination:MaxPageSize directly. But as more services need those settings, the same configuration keys and assumptions can spread through the code.

What happens if someone sets the maximum to zero? Or changes it to a value smaller than the default?

The application might still build. That does not mean its configuration is usable.

This is where the options pattern helps. Instead of making each service understand the configuration structure, we give it a small class containing the settings it needs. We can also define what valid settings look like. That is the basic idea behind Microsoft’s options pattern.

Start with a small example

This example uses ASP.NET Core on .NET 10. You need the .NET 10 SDK and a basic understanding of dependency injection: registering a service, then letting the framework supply it where it is needed. The sample was developed with SDK 10.0.301.

We are only calculating a page size here. There is no database or product query, so we can focus on configuration without building a whole API around it.

Create an empty web project:

dotnet new web -n OptionsDemo --framework net10.0

This creates a minimal ASP.NET Core project named OptionsDemo. Add the files below to this project.

Replace the project’s appsettings.json with the Pagination configuration shown earlier.

Now we add PaginationOptions.cs:

using System.ComponentModel.DataAnnotations;

namespace OptionsDemo;

public sealed class PaginationOptions
{
    public const string SectionName = "Pagination";

    [Range(1, 500)]
    public int DefaultPageSize { get; set; } = 20;

    [Range(1, 500)]
    public int MaxPageSize { get; set; } = 100;
}

The properties describe the settings our code expects, and the range attributes describe acceptable values. The upper bound of 500 is a teaching choice, not a recommended limit for every API. SectionName gives us one place to keep the configuration section’s name.

The property defaults are deliberate. If an individual key is absent, its default can remain. Having a typed class does not prove that every key was supplied. For this example, we will require the section itself, while allowing defaults for its individual settings.

Bind the settings and check them early

Add PaginationRegistration.cs:

namespace OptionsDemo;

public static class PaginationRegistration
{
    public static IServiceCollection AddPagination(
        this IServiceCollection services, IConfiguration configuration)
    {
        services.AddOptions<PaginationOptions>()
            .Bind(configuration.GetRequiredSection(PaginationOptions.SectionName))
            .ValidateDataAnnotations()
            .Validate(options => options.DefaultPageSize <= options.MaxPageSize,
                "Pagination:DefaultPageSize must not exceed MaxPageSize.")
            .ValidateOnStart();

        services.AddSingleton<PageSizer>();
        return services;
    }
}

We require the Pagination section and register the binding to our options class.

Next, we enable the attribute checks. Then, we add the rule that involves both properties: the default cannot be larger than the maximum.

Finally, ValidateOnStart() asks the host to validate these options during startup, rather than waiting for a request to need them. We will add PageSizer next.

That last step matters. Writing validation rules and deciding when they run are two different things. The ValidateOnStart API makes startup the checkpoint. In this application, app.Run() starts the host; calling builder.Build() alone is not that checkpoint.

The registration lives in a small extension method so the application and the companion tests can use the same rules. In a smaller application, the same registration chain could live directly in Program.cs.

Let the service use the settings

Add PageSizer.cs:

using Microsoft.Extensions.Options;

namespace OptionsDemo;

public sealed class PageSizer(IOptions<PaginationOptions> options)
{
    private readonly PaginationOptions settings = options.Value;

    public int GetPageSize(int? requested) =>
        requested is null
            ? settings.DefaultPageSize
            : Math.Clamp(requested.Value, 1, settings.MaxPageSize);
}

Dependency injection supplies the options wrapper, and Value gives the service its settings.

Then, the service uses the default when no size was requested, or clamps the requested size into the allowed range. It does not need to know the JSON path.

Clamping is another explicit choice in this example. A real API could return a validation error for an invalid request instead. That request-handling decision is separate from validating the application’s own configuration.

Replace Program.cs with:

using OptionsDemo;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPagination(builder.Configuration);

var app = builder.Build();
app.MapGet("/page-size", (int? requested, PageSizer pageSizer) =>
    new { pageSize = pageSizer.GetPageSize(requested) });

app.Run();

We register the options and the service, then map the endpoint. Finally, app.Run() starts the application and triggers startup validation. When a request arrives, the endpoint asks the service to calculate a page size.

The flow is now easier to follow:

flowchart TD
  accTitle: From configuration to a page-size decision
  accDescr: JSON and environment settings feed configuration, which binds PaginationOptions. Validation checks the settings at startup before PageSizer serves requests.
  Sources[JSON and environment settings] --> Configuration[IConfiguration]
  Configuration --> Options[PaginationOptions]
  Options --> Validation[Startup validation]
  Validation --> Service[PageSizer]
  Service --> Endpoint[Page-size response]

First, the configuration providers supply the values. Next, those values are bound and checked as part of startup. Then, the service uses the validated settings when the endpoint receives a request. The diagram shows the responsibilities, not a fresh binding operation for every request.

Try a valid configuration, then break it

Start the application:

dotnet run --project OptionsDemo --no-launch-profile --urls http://localhost:5050

This runs the OptionsDemo project on an explicit local address without applying a launch profile. Keep that terminal open, then use another terminal for the requests.

curl "http://localhost:5050/page-size"
curl "http://localhost:5050/page-size?requested=200"

The request without a size returns {"pageSize":20}. Then, the request for 200 returns {"pageSize":100}, because the service applies the configured maximum.

Now stop the app with Ctrl+C, change MaxPageSize to 0, and run it again. Startup should fail with an OptionsValidationException. Restore the maximum to 100 afterward.

You can also try a default of 80 and a maximum of 50. Both numbers pass their individual range checks, but the relationship between them is wrong, so the custom rule rejects them.

A missing Pagination section fails earlier through GetRequiredSection. A value such as "many" for an integer setting fails during binding. These are different failures, but none should wait for a user to discover them through a broken page-size response.

Which options interface should we use?

The three similar names can make the pattern look more complicated than it needs to be. Start with one question: do we need to read changed settings without restarting the application?

InterfaceHow values behaveWhen it fits
IOptions<T>Caches the value after it is first created; does not refresh it on configuration reload.Settings that stay fixed while the application runs.
IOptionsSnapshot<T>Creates and caches an options value per DI scope when accessed, normally one scope per HTTP request.Request-scoped code that should see updated configuration on later requests.
IOptionsMonitor<T>Provides current options and change notifications when the underlying configuration supports them.Long-lived services that need updated settings.

IOptionsSnapshot<T> is scoped, so do not inject it into a singleton. Both IOptions<T> and IOptionsMonitor<T> can be used by singleton services.

Microsoft’s options interface guidance explains these lifetimes in more detail.

Our PageSizer uses IOptions<T> intentionally. Changing the JSON while it is running will not refresh the value it already holds. Restart it to apply the change.

With a monitor, read CurrentValue when an operation needs it. Keeping that value in a field forever would keep the old object, even if the monitor later exposes a new one. It can also be useful to capture the current value once at the beginning of an operation, so that operation uses a consistent set of settings. See IOptionsMonitor.CurrentValue.

Reloading still depends on the provider. An options interface is not a mechanism for changing environment variables inside an already-running process. And startup validation does not make future configuration changes automatically safe; if you enable reloads, decide how your application will handle invalid updates.

The source can change without changing the service

So far we have used JSON, but the service depends on PaginationOptions, not on a particular file.

With the default WebApplication.CreateBuilder configuration, environment variables override JSON settings. A double underscore separates nested keys. See Microsoft’s configuration provider ordering.

Stop the application, then run this in Bash or Zsh:

Pagination__MaxPageSize=50 dotnet run --project OptionsDemo --no-launch-profile --urls http://localhost:5050

This supplies a maximum of 50 to the new process. Then, the same request for 200 returns {"pageSize":50} without changing the service or the JSON file. Stop this process when finished; this command does not export the variable into later shell commands.

What this actually improves

The useful part is not replacing a string lookup with a longer registration chain. It is making the application’s assumptions visible.

We now have a place for related settings, rules that explain which combinations are valid, and a startup check that catches mistakes before the endpoint serves requests. The service can focus on its decision instead of knowing where every setting came from.

That does not mean every one-off configuration lookup needs a new class. When several settings belong together and affect the same behavior, though, the options pattern gives us a clear place to describe and check them.