
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:
- assign values;
- 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:
IParameterObjectsays, "this type is an options object";IPostConfigurableParameterObjectsays, "after values are assigned, I may need to normalize or complete them";IValidatableParameterObjectsays, "before use, I can prove that my public state is valid";Validator.ThrowIfInvalidConfigurator(...)turns anAction<TOptions>into a validated options instance;Validator.ThrowIfInvalidOptions(...)validates an already built options object;Validator.ThrowIfInvalidState(...)is the invalid-state guard, both insideValidateOptions()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:
- reject
null; - if the options object implements
IPostConfigurableParameterObject, callPostConfigureOptions(); - if it implements
IValidatableParameterObject, callValidateOptions(); - 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
ThrowIfInvalidOptionson 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.
Recommended usage
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.
v5.1.20192016-11-20The first options-shaped helper lived in
Cuemon.Core.DelegateUtility.ConfigureAction<TOptions>created a default options object and applied anAction<TOptions>. The same change set applied the Options Pattern to encoding and transient-operation scenarios.v5.1.20192018-09-09The configured object became a class-level contract.
IConfigurable<TOptions>andConfigurable<TOptions>made options part of a reusable component contract, then settled underCuemon.Core.Configuration.v7.0.02022-11-09The parameter-object lifecycle became explicit.
IParametersbecameIParameterObject,IValidatableParametersbecameIValidatableParameterObject, andThrowIfInvalidConfigurator,ThrowIfInvalidOptions, andPatterns.Configuremade typed setup plus validation explicit.v8.1.02024-02-11Validation became opt-in.
The guards moved from requiring
IValidatableParameterObjectto acceptingIParameterObject, while validation remained available through the optional interface.v10.1.02025-12-06Post-configuration added a derivation phase before validation.
IPostConfigurableParameterObjectadded a place to normalize or derive values after setup, andValidatorbegan coordinating post-configuration before validation.v10.5.02026-04-17The infrastructure moved into
Cuemon.Kernel.Cuemon.Kernelbecame the lightweight home forPatterns,Validator, the parameter-object contracts, and laterConfigurableandIConfigurable.Cuemon.Coreused type forwarding during the transition, so this was a move of the infrastructure rather than a second introduction.v11.0.0-preview.72026-08-12The compatibility bridge was retired.
The preview release notes remove the remaining
Cuemon.Coreforwarding bridge for foundational APIs. New consumers referenceCuemon.Kerneldirectly, completing the assembly boundary without restarting the feature's history.v10.7.02026-08-12(current)The active stable contracts live in
Cuemon.Kernel.The current contracts and guards live under
src/Cuemon.Kernel, while consumers such asAwaiter, BenchmarkDotNet, and Savvy I/O build on the same options lifecycle.
Sources
- Cuemon.Kernel package page
- Cuemon.Extensions.Core package page
- Cuemon.Kernel on NuGet
- Cuemon changelog
- Validator.cs,
ThrowIfInvalidConfigurator,ThrowIfInvalidOptions,ThrowIfInvalidState - Configurable<TOptions> source
- IConfigurable<TOptions> source
- IParameterObject source
- IPostConfigurableParameterObject source
- IValidatableParameterObject source
- Patterns.cs,
Configure<TOptions>source - ActionExtensions.cs,
Configure<TOptions>extension Awaiter.RunUntilSuccessfulOrTimeoutAsyncsourceAsyncRunOptionssourceBenchmarkWorkspaceOptionssourceAddBenchmarkWorkspacesourceSavvyioOptionssource- Savvy I/O service registration source
MessageAsyncEnumerable<T>sourceMessageAsyncEnumerableOptions<T>sourceMessageOptionssourceAzureQueueOptionssource- Patterns tests, configure order and exchange helpers
- Validator tests, post-configuration, validation, and state guards
- Options pattern, Microsoft Learn
- Options pattern guidance for .NET library authors, Microsoft Learn
- IOptionsFactory<TOptions>, Microsoft Learn
- IPostConfigureOptions<TOptions>, Microsoft Learn
