Codebelt
Cuemon options pattern icon with a configured parameter object feeding a validated component.

Options Pattern Beyond ASP.NET Core

Cuemon.Kernel carries the Options Pattern out of ASP.NET Core and into ordinary .NET types with parameter-object contracts, `Patterns.Configure`, and `Validator` guards that post-configure, validate, and enforce state without requiring `IOptions` or a DI container.

LIBRARIES
  • .NET
  • ASP.NET Core
  • Options Pattern
  • Parameter Object
  • Configuration
  • Validation
  • IOptions
  • DevEx
Cuemon options pipeline showing a configuration delegate flowing through post-configuration and validation into a reusable .NET component.Cuemon options pipeline showing a configuration delegate flowing through post-configuration and validation into a reusable .NET component.

Microsoft's options guidance is intentionally practical: use a class to represent a related group of settings, keep it non-abstract, give it a public parameterless constructor, and expose public read-write properties so configuration or code can populate it. In hosted .NET applications, that model usually flows through IOptions<TOptions>, IOptionsSnapshot<TOptions>, IOptionsMonitor<TOptions>, and an IOptionsFactory<TOptions> that applies configuration and post-configuration.

That baseline is solid. It is also deliberately host-centric.

Reusable .NET components often need the same discipline when there is no IServiceCollection, no bound configuration section, and no IOptions<TOptions> wrapper in sight. A formatter, retry helper, middleware-like component, parser, decorator, or infrastructure primitive still benefits from a strongly typed parameter object, a place to normalize defaults, and one consistent validation path before the component is allowed to run.

That is where Cuemon.Kernel provides a useful adaptation. It carries the Options Pattern into a small set of primitives that are not tied to ASP.NET Core or dependency injection, while preserving the parts that make the pattern valuable.

The official baseline is about configuration and lifetime

The official Microsoft documentation describes the Options Pattern as a way to provide strongly typed access to related settings. In the standard hosted model:

  • the options type is a reference type with a public parameterless constructor;
  • configuration or code populates its public properties;
  • consumers receive the result through an options interface;
  • and IOptionsFactory<TOptions> is responsible for creating configured instances.

That last detail matters. Microsoft documents that the default IOptionsFactory<TOptions> runs all registered IConfigureOptions<TOptions> implementations first and all IPostConfigureOptions<TOptions> implementations after that. In other words, the official stack already recognizes that option objects often need two phases:

  1. assign values;
  2. normalize or complete values before use.

Microsoft also recommends that library authors expose overloads such as Action<TOptions> so consumers can configure a library without manually constructing the entire object graph. That is a strong API shape because it keeps the configuration object explicit and strongly typed.

The limitation is not correctness. The limitation is reach.

If you are building a reusable library instead of a host registration surface, the full Microsoft.Extensions.Options pipeline can be more infrastructure than you need. You may want the shape of the pattern without the dependency on the hosting stack.

What Cuemon keeps from the pattern

Cuemon does not reject the official model. It keeps the core idea and removes the hosting assumption.

At the foundation is IParameterObject, a marker interface for types that participate in the Cuemon options flow. Like the official options guidance, the generic APIs constrain the option type to class, new(), and a strongly typed contract. That means a component can insist on the same practical rules as Microsoft guidance without taking a dependency on configuration binding or DI registration.

From there, Cuemon splits the rest of the lifecycle into three focused interfaces and guards:

  • IParameterObject says, "this type is an options object";
  • IPostConfigurableParameterObject says, "after values are assigned, I may need to normalize or complete them";
  • IValidatableParameterObject says, "before use, I can prove that my public state is valid";
  • Validator.ThrowIfInvalidConfigurator(...) turns an Action<TOptions> into a validated options instance;
  • Validator.ThrowIfInvalidOptions(...) validates an already built options object;
  • Validator.ThrowIfInvalidState(...) is the invalid-state guard, both inside ValidateOptions() and after construction.

That is the whole invention. It is small. It is also the reason the pattern scales beyond ASP.NET Core.

A real retry API needs no options factory

A real library method makes the intent clear. Cuemon's Awaiter accepts Action<AsyncRunOptions> and resolves the options at the API boundary:

var result = await Awaiter.RunUntilSuccessfulOrTimeoutAsync(
    ReadFromStoreAsync,
    options =>
    {
        options.Timeout = TimeSpan.FromSeconds(30);
        options.Delay = TimeSpan.FromMilliseconds(250);
        options.MaximumAttempts = 5;
    });

AsyncRunOptions already supplies a five-second timeout and a 100-millisecond delay. Its validator rejects negative values and prevents a zero-delay retry loop unless a positive maximum attempt count is configured. The caller only overrides what matters for this operation.

The implementation is just as direct:

public static Task<ConditionalValue> RunUntilSuccessfulOrTimeoutAsync(
    Func<Task<ConditionalValue>> method,
    Action<AsyncRunOptions> setup = null)
{
    Validator.ThrowIfNull(method);
    Validator.ThrowIfInvalidConfigurator(setup, out var options);
    return RunUntilSuccessfulOrTimeoutCoreAsync(method, options);
}

ThrowIfInvalidConfigurator calls Patterns.Configure, runs post-configuration and validation through ThrowIfInvalidOptions, and returns the ready object through out var options. The same boundary works in a static helper, a constructor, a factory, or a service-registration method.

This is the real library-author intent: accept one typed setup delegate, centralize defaulting and validation, then pass a concrete options object to the implementation.

Post-configure, then validate, in one place

The next enabling piece is Validator.ThrowIfInvalidOptions<TOptions>().

Its implementation is intentionally direct:

  1. reject null;
  2. if the options object implements IPostConfigurableParameterObject, call PostConfigureOptions();
  3. if it implements IValidatableParameterObject, call ValidateOptions();
  4. if either phase throws, wrap the failure in an ArgumentException.

That behavior is grounded in both the source and the tests. PostConfigurableOptions passes because its PostConfigureOptions() method assigns a new Guid before validation checks the state. FailPostConfigurableOptions fails because it overrides PostConfigureOptions() and leaves the object invalid. EssentialOptions, which only implements IParameterObject, passes through without extra work.

This is a careful design choice.

It means post-configuration and validation are opt-in, not mandatory ceremony. If a component only needs a shaped options object, IParameterObject is enough. If it needs defaults or derivation, add IPostConfigurableParameterObject. If it needs hard invariants, add IValidatableParameterObject. If it needs both, implement both and let one guard coordinate the sequence.

That is more than convenience. It makes configuration behavior discoverable and repeatable. Every consumer of the options object gets the same rules in the same order.

It also makes Validator.ThrowIfInvalidState(...) a natural convenience inside ValidateOptions(). IValidatableParameterObject is explicitly about proving that the current public state is valid, and ThrowIfInvalidState appends the failing condition through CallerArgumentExpression. That means a failing rule such as Delay <= TimeSpan.Zero or MaxAttempts <= 0 can surface both a clear message and the exact expression that failed, while ThrowIfInvalidOptions(...) still wraps the result as an ArgumentException at the constructor or factory boundary.

Configurable<TOptions> turns the rule into a class contract

The third piece is Configurable<TOptions>.

Configurable<TOptions> is an abstract base class that implements IConfigurable<TOptions> and exposes a single Options property. Its constructor does one important thing before storing the instance:

Validator.ThrowIfInvalidOptions(options);
Options = options;

That single line is what turns the pattern from a set of helper methods into a class-level contract.

Any component deriving from Configurable<TOptions> gets the same fail-fast behavior at construction time. The object cannot exist with a null options instance. It also cannot exist with an options object that should have been post-configured or validated but was not.

That is why the design travels well across the broader .NET ecosystem. A filter, middleware base, utility type, or domain component can all inherit the same contract without caring whether the options came from JSON, code, tests, a factory, or another library.

Microsoft's official model separates options creation from component consumption through DI wrappers. Cuemon's model keeps the object itself at the center and makes constructor validation the boundary. Neither approach is "more correct" in the abstract. They solve different problems. Cuemon's version is better suited to library surfaces that must remain lightweight and host-agnostic.

Derived settings belong in post-configuration

Codebelt.Extensions.BenchmarkDotNet shows why post-configuration is more than a second validation hook. BenchmarkWorkspaceOptions creates a usable BenchmarkDotNet configuration, resolves repository and target-framework defaults, and exposes folder names that callers can override without rebuilding the object graph.

var workspace = new BenchmarkWorkspace(new BenchmarkWorkspaceOptions
{
    RepositoryReportsFolder = "artifacts/benchmarks",
    SkipBenchmarksWithReports = true
});

After the caller's values are applied, PostConfigureOptions() derives Configuration.ArtifactsPath from RepositoryPath and RepositoryReportsFolder when the BenchmarkDotNet configuration does not already define it. ValidateOptions() then checks the configuration, repository path, report and tuning folders, target framework, and project suffix. The component receives a complete workspace configuration, while the caller sets only the values that differ from the defaults.

The DI registration follows the same rule and still needs no private options factory:

Validator.ThrowIfInvalidConfigurator(setup, out var options);

return services
    .AddSingleton<IBenchmarkWorkspace, TWorkspace>()
    .Configure(setup ?? (_ => { }))
    .AddSingleton(options);

The resolved instance is available as IOptions<BenchmarkWorkspaceOptions> and as the concrete options object. The Configure registration preserves standard DI options consumption without making the options type depend on the host. Savvy I/O's separate AddConfiguredOptions<TOptions> helper goes further by registering the setup delegate as well.

Savvy I/O uses the pattern across its I/O boundaries

Savvy I/O is a useful real-world test of this design because options appear throughout the library rather than in one registration class.

SavvyioOptions implements IParameterObject and owns handler and dispatcher registrations. Its fluent methods enable discovery and add only type pairs that satisfy the expected handler or dispatcher contracts. A service can configure those choices without manually assembling lists of Type values:

services.AddSavvyIO(options =>
{
    options
        .EnableHandlerDiscovery()
        .EnableDispatcherDiscovery()
        .EnableHandlerServicesDescriptor();
});

AddSavvyIO creates the options object from the setup delegate, uses the configured discovery flags and registrations to build services, and then configures the service locator. The options object is the input to the registration process, not a bag of values read later by unrelated code.

The same pattern applies below the DI boundary. MessageAsyncEnumerable<T> validates its source and calls Validator.ThrowIfInvalidConfigurator(setup, out var options) before storing the options. MessageAsyncEnumerableOptions<T> supplies a concurrent acknowledged-properties collection by default, then requires a message callback before iteration can start. Invalid messaging configuration therefore fails when the enumerable is created, before it can consume messages.

This gives the pattern several practical benefits:

  • dispatchers and handlers can be configured with typed, chainable operations;
  • message options can carry per-operation state such as identifiers, timestamps, callbacks, and acknowledgements;
  • transport options can reject invalid connection, credential, or endpoint combinations before network I/O;
  • the same option contract can be used by console applications, workers, tests, and DI-hosted services.

ThrowIfInvalidConfigurator is the missing bridge for Action<TOptions>

Microsoft's library-author guidance explicitly recommends Action<TOptions> overloads as one valid way to let callers configure a library. Cuemon embraces that same API shape and closes the loop.

Validator.ThrowIfInvalidConfigurator<TOptions>(Action<TOptions> argument, out TOptions options, ...) does two things in one call:

  • builds the options instance through Patterns.Configure;
  • immediately runs ThrowIfInvalidOptions on the result.

That is the bridge between an ergonomic API and a validated object. Savvy I/O uses it in MessageAsyncEnumerable<T> and in its generic AddConfiguredOptions<TOptions> registration helper. BenchmarkDotNet uses it when registering BenchmarkWorkspaceOptions. Each consumer receives the configured instance directly from the guard.

The result is less ad hoc guard code in constructors and factory methods. A library author can keep the public API ergonomic while still centralizing correctness.

ThrowIfInvalidState carries invalid-state semantics across the lifecycle

The final piece in the set is Validator.ThrowIfInvalidState(bool condition, ...).

Its purpose is different from the two options guards, but not isolated from them. ThrowIfInvalidConfigurator and ThrowIfInvalidOptions decide whether the component can be constructed with a valid configuration. ThrowIfInvalidState is the convenience guard used when some current state is invalid, whether that state belongs to the parameter object during ValidateOptions() or to the constructed component during a later operation.

That distinction is important because configuration validity and runtime validity are not the same thing.

An asynchronous operation may be configured correctly and still finish unsuccessfully because its timeout or maximum attempt count was reached. A message enumerable may be configured correctly and still reject a later operation because its underlying source is exhausted or disposed. Configuration validity and runtime state remain separate concerns.

Important

ThrowIfInvalidState(...) is still not an argument guard for public member parameters. Cuemon's own XML documentation calls that out explicitly. But IValidatableParameterObject.ValidateOptions() is itself a state-validation hook, so the same guard fits naturally there and produces an InvalidOperationException with both a clear message and the failing expression.

This is another reason the design works well: it keeps the lifecycle boundaries clear.

  • configuration delegate validity
  • options object state during post-configuration and ValidateOptions()
  • runtime object-state validity

Those are different failure modes, and Cuemon gives each one a deliberate API.

The convenience layer stays optional

If you want a more fluent entry point, Cuemon.Extensions.Core adds ActionExtensions.Configure<TOptions>(), which simply forwards to Patterns.Configure(setup).

That matters because it shows where the real abstraction lives.

The convenience method is not the pattern. The pattern lives in Cuemon.Kernel: the parameter-object contracts, the constructor contract in Configurable<TOptions>, and the Validator guards that enforce the lifecycle. The extension package only adds a friendlier call shape when a codebase wants it.

This separation is good design in its own right. The foundational rule remains small and dependency-light. The syntax sugar stays outside the kernel.

Use this Cuemon model when you are authoring reusable .NET types that need strongly typed configuration but should not be coupled to the hosting stack:

  • library components that accept an Action<TOptions>;
  • infrastructure primitives that run in tests, console apps, workers, and web apps alike;
  • middleware, filters, decorators, parsers, or builders that need defaulting plus validation;
  • components with derived settings, such as benchmark artifact paths or transport client configuration;
  • messaging and data-access boundaries where invalid options must fail before I/O;
  • service-registration extensions that need to expose both DI options and a concrete validated instance;
  • packages that want fail-fast construction without forcing consumers into IOptions<TOptions>.

Stay with the standard Microsoft options stack when the main problem is hosted application configuration, named options, reloadable configuration, or change notifications. IOptionsMonitor<TOptions> and the rest of Microsoft.Extensions.Options are the right tools for those scenarios.

Use Cuemon when the main problem is smaller and more local: a reusable object needs a valid parameter object before it can do any useful work.

The stable 10.7.0 line makes that model available in Cuemon.Kernel for .NET 10, .NET 9, and .NET Standard 2.0. The 11.0.0 preview continues the same placement while completing the Cuemon.Core to Cuemon.Kernel assembly boundary.

A small abstraction with concrete effects

Good options design is not only about how values are assigned. It is about where defaults are completed, where invariants are enforced, and how many times each component has to solve the same problem for itself.

Cuemon's adaptation works because it turns those concerns into a tiny, composable contract:

  • mark an options object;
  • optionally post-configure it;
  • optionally validate it;
  • enforce the rule in one constructor base class and three guard methods.

That is enough to carry the Options Pattern beyond ASP.NET Core without pretending to replace ASP.NET Core.

The official Microsoft model remains the baseline for hosts. Cuemon takes the same engineering instinct and applies it where ordinary .NET types live: constructors, reusable components, and lightweight library APIs that still deserve strong invariants, fast feedback, and less repeated code.

Timeline

The current API is the result of several iterations rather than one ASP.NET Core port. Version links point to the first relevant Git tag when one is available.

  1. v5.1.2019 2016-11-20

    The first options-shaped helper lived in Cuemon.Core.

    DelegateUtility.ConfigureAction<TOptions> created a default options object and applied an Action<TOptions>. The same change set applied the Options Pattern to encoding and transient-operation scenarios.

  2. v5.1.2019 2018-09-09

    The configured object became a class-level contract.

    IConfigurable<TOptions> and Configurable<TOptions> made options part of a reusable component contract, then settled under Cuemon.Core.Configuration.

  3. v7.0.0 2022-11-09

    The parameter-object lifecycle became explicit.

    IParameters became IParameterObject, IValidatableParameters became IValidatableParameterObject, and ThrowIfInvalidConfigurator, ThrowIfInvalidOptions, and Patterns.Configure made typed setup plus validation explicit.

  4. v8.1.0 2024-02-11

    Validation became opt-in.

    The guards moved from requiring IValidatableParameterObject to accepting IParameterObject, while validation remained available through the optional interface.

  5. v10.1.0 2025-12-06

    Post-configuration added a derivation phase before validation.

    IPostConfigurableParameterObject added a place to normalize or derive values after setup, and Validator began coordinating post-configuration before validation.

  6. v10.5.0 2026-04-17

    The infrastructure moved into Cuemon.Kernel.

    Cuemon.Kernel became the lightweight home for Patterns, Validator, the parameter-object contracts, and later Configurable and IConfigurable. Cuemon.Core used type forwarding during the transition, so this was a move of the infrastructure rather than a second introduction.

  7. v11.0.0-preview.7 2026-08-12

    The compatibility bridge was retired.

    The preview release notes remove the remaining Cuemon.Core forwarding bridge for foundational APIs. New consumers reference Cuemon.Kernel directly, completing the assembly boundary without restarting the feature's history.

  8. v10.7.0 2026-08-12 (current)

    The active stable contracts live in Cuemon.Kernel.

    The current contracts and guards live under src/Cuemon.Kernel, while consumers such as Awaiter, BenchmarkDotNet, and Savvy I/O build on the same options lifecycle.

Sources