Automatically bind configuration sections to strongly-typed options classes with compile-time code generation.
Key Benefits:
- π― Zero boilerplate - No manual
AddOptions<T>().Bind()calls needed - π§ Smart section inference - Auto-detects section names from class names or constants
- π‘οΈ Built-in validation - Automatic DataAnnotations validation and startup checks
- π§ Multi-project support - Smart naming for assembly-specific registration methods
- β‘ Native AOT ready - Pure compile-time generation with zero reflection
Quick Example:
// Input: Decorate your options class
[OptionsBinding("Database")]
public partial class DatabaseOptions
{
[Required] public string ConnectionString { get; set; } = string.Empty;
}
// Generated: Registration extension method
services.AddOptions<DatabaseOptions>()
.Bind(configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart();- π Feature Roadmap - See all implemented and planned features
- π― Sample Projects - Working code examples with architecture diagrams
- βοΈ Options Binding Source Generator
- π Documentation Navigation
- π Table of Contents
- π Overview
- π Quick Start
- π Configuration Examples
- β¨ Features
- π¦ Installation
- π‘ Usage
- π§ How It Works
- π― Advanced Scenarios
- π’ Multiple Assemblies
- β¨ Smart Naming
- π Nested Configuration (Feature #6: Bind Configuration Subsections to Properties)
- β‘ Early Access to Options (Avoid BuildServiceProvider Anti-Pattern)
- π Environment-Specific Configuration
- π Named Options (Multiple Configurations)
- π― Child Sections (Simplified Named Options)
- π‘οΈ Diagnostics
- β ATCOPT001: Options class must be partial
- β ATCOPT002: Section name cannot be null or empty
β οΈ ATCOPT003: Invalid options binding configuration- β ATCOPT003: Const section name cannot be null or empty
- β ATCOPT004-007: OnChange Callback Diagnostics
- β ATCOPT008-010: PostConfigure Callback Diagnostics
- β ATCOPT011-013: ConfigureAll Callback Diagnostics
- β ATCOPT014-016: ChildSections Diagnostics
- π Native AOT Compatibility
- π Examples
- π Additional Resources
- β FAQ
- π License
The Options Binding Source Generator eliminates the boilerplate code required to bind configuration sections to options classes. Simply decorate your options class with [OptionsBinding], and the generator creates the necessary registration code at compile time.
// appsettings.json
{
"Database": {
"ConnectionString": "Server=localhost;...",
"MaxRetries": 5
}
}
// DatabaseOptions.cs
public class DatabaseOptions
{
public string ConnectionString { get; set; }
public int MaxRetries { get; set; }
}
// Program.cs - Manual binding
services.AddOptions<DatabaseOptions>()
.Bind(configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart();// DatabaseOptions.cs
[OptionsBinding("Database", ValidateDataAnnotations = true, ValidateOnStart = true)]
public partial class DatabaseOptions
{
[Required]
public string ConnectionString { get; set; }
[Range(1, 10)]
public int MaxRetries { get; set; }
}
// Program.cs - Generated extension method
services.AddOptionsFromApp(configuration);dotnet add package Atc.SourceGeneratorsusing Atc.SourceGenerators.Annotations;
using System.ComponentModel.DataAnnotations;
namespace MyApp.Configuration;
[OptionsBinding("Database", ValidateDataAnnotations = true)]
public partial class DatabaseOptions
{
[Required]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
}{
"Database": {
"ConnectionString": "Server=localhost;Database=MyDb;",
"MaxRetries": 5
}
}var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();
var services = new ServiceCollection();
// Use the generated extension method
services.AddOptionsFromApp(configuration);
var serviceProvider = services.BuildServiceProvider();
// Access your options
var dbOptions = serviceProvider.GetRequiredService<IOptions<DatabaseOptions>>();
Console.WriteLine(dbOptions.Value.ConnectionString);This section demonstrates all possible ways to create options classes and map them to appsettings.json sections.
We'll use these two JSON sections throughout the examples:
appsettings.json:
{
"PetMaintenanceService": {
"RepeatIntervalInSeconds": 10,
"EnableAutoCleanup": true,
"MaxPetsPerBatch": 50
},
"PetOtherServiceOptions": {
"RepeatIntervalInSeconds": 10,
"EnableAutoCleanup": true,
"MaxPetsPerBatch": 50
}
}Two different scenarios:
"PetMaintenanceService"- Section name that doesn't match any class name (requires explicit mapping)"PetOtherServiceOptions"- Section name that exactly matches a class name (can use auto-inference)
Use when you want full control over the section name:
// Maps to "PetMaintenanceService" section
[OptionsBinding("PetMaintenanceService")]
public partial class PetMaintenanceServiceOptions
{
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
public int MaxPetsPerBatch { get; set; }
}When to use:
- β When section name doesn't match class name
- β
When using nested configuration paths (e.g.,
"App:Services:Database") - β When you want explicit, readable code
Use when you want the section name defined as a constant in the class:
// Maps to "PetMaintenanceService" section
[OptionsBinding]
public partial class PetMaintenanceServiceOptions
{
public const string SectionName = "PetMaintenanceService";
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
public int MaxPetsPerBatch { get; set; }
}When to use:
- β When you want the section name accessible as a constant
- β When other code needs to reference the same section name
- β When building configuration paths dynamically
Use as an alternative to SectionName:
// Maps to "PetMaintenanceService" section
[OptionsBinding]
public partial class PetMaintenanceServiceOptions
{
public const string NameTitle = "PetMaintenanceService";
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
public int MaxPetsPerBatch { get; set; }
}When to use:
- β When following specific naming conventions
- β
When
SectionNameis not preferred in your codebase
Another alternative for section name definition:
// Maps to "PetMaintenanceService" section
[OptionsBinding]
public partial class PetMaintenanceServiceOptions
{
public const string Name = "PetMaintenanceService";
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
public int MaxPetsPerBatch { get; set; }
}When to use:
- β When following specific naming conventions
- β
When
Namefits your code style better
The generator uses the full class name as-is:
// Maps to "PetOtherServiceOptions" section (full class name)
[OptionsBinding]
public partial class PetOtherServiceOptions
{
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
public int MaxPetsPerBatch { get; set; }
}When to use:
- β When section name matches class name exactly
- β When you want minimal code
- β When following convention-over-configuration
Important: The class name is used as-is - no suffix removal or transformation:
DatabaseOptionsβ"DatabaseOptions"(NOT"Database")ApiSettingsβ"ApiSettings"(NOT"Api")CacheConfigβ"CacheConfig"(NOT"Cache")PetOtherServiceOptionsβ"PetOtherServiceOptions"β (Matches our JSON section!)
using System.ComponentModel.DataAnnotations;
// Maps to "PetMaintenanceService" section
[OptionsBinding("PetMaintenanceService", ValidateDataAnnotations = true)]
public partial class PetMaintenanceServiceOptions
{
[Range(1, 3600)]
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
[Range(1, 1000)]
public int MaxPetsPerBatch { get; set; }
}Generated code includes:
services.AddOptions<PetMaintenanceServiceOptions>()
.Bind(configuration.GetSection("PetMaintenanceService"))
.ValidateDataAnnotations();// Maps to "PetMaintenanceService" section
[OptionsBinding("PetMaintenanceService", ValidateOnStart = true)]
public partial class PetMaintenanceServiceOptions
{
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
public int MaxPetsPerBatch { get; set; }
}Generated code includes:
services.AddOptions<PetMaintenanceServiceOptions>()
.Bind(configuration.GetSection("PetMaintenanceService"))
.ValidateOnStart();using System.ComponentModel.DataAnnotations;
// Maps to "PetMaintenanceService" section
[OptionsBinding("PetMaintenanceService",
ValidateDataAnnotations = true,
ValidateOnStart = true)]
public partial class PetMaintenanceServiceOptions
{
[Required]
[Range(1, 3600, ErrorMessage = "Interval must be between 1 and 3600 seconds")]
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
[Range(1, 1000)]
public int MaxPetsPerBatch { get; set; }
}Generated code includes:
services.AddOptions<PetMaintenanceServiceOptions>()
.Bind(configuration.GetSection("PetMaintenanceService"))
.ValidateDataAnnotations()
.ValidateOnStart();Best for options that don't change during application lifetime:
// Default: Lifetime = OptionsLifetime.Singleton
[OptionsBinding("PetMaintenanceService")]
public partial class PetMaintenanceServiceOptions
{
public int RepeatIntervalInSeconds { get; set; }
}
// Usage:
public class PetMaintenanceService
{
public PetMaintenanceService(IOptions<PetMaintenanceServiceOptions> options)
{
var config = options.Value; // Cached singleton value
}
}Generated code comment:
// Configure PetMaintenanceServiceOptions - Inject using IOptions<T>Best for options that may change per request/scope:
[OptionsBinding("PetMaintenanceService", Lifetime = OptionsLifetime.Scoped)]
public partial class PetMaintenanceServiceOptions
{
public int RepeatIntervalInSeconds { get; set; }
}
// Usage:
public class PetRequestHandler
{
public PetRequestHandler(IOptionsSnapshot<PetMaintenanceServiceOptions> options)
{
var config = options.Value; // Fresh value per scope/request
}
}Generated code comment:
// Configure PetMaintenanceServiceOptions - Inject using IOptionsSnapshot<T>Best for options that need change notifications and hot-reload:
[OptionsBinding("PetMaintenanceService", Lifetime = OptionsLifetime.Monitor)]
public partial class PetMaintenanceServiceOptions
{
public int RepeatIntervalInSeconds { get; set; }
}
// Usage:
public class PetMaintenanceService
{
public PetMaintenanceService(IOptionsMonitor<PetMaintenanceServiceOptions> options)
{
var config = options.CurrentValue; // Always current value
// Subscribe to configuration changes
options.OnChange(newConfig =>
{
Console.WriteLine($"Configuration changed! New interval: {newConfig.RepeatIntervalInSeconds}");
});
}
}Generated code comment:
// Configure PetMaintenanceServiceOptions - Inject using IOptionsMonitor<T>Here's an example using all features together:
appsettings.json:
{
"PetMaintenanceService": {
"RepeatIntervalInSeconds": 10,
"EnableAutoCleanup": true,
"MaxPetsPerBatch": 50,
"NotificationEmail": "admin@petstore.com"
}
}Options class:
using System.ComponentModel.DataAnnotations;
using Atc.SourceGenerators.Annotations;
namespace PetStore.Domain.Options;
/// <summary>
/// Configuration options for the pet maintenance service.
/// </summary>
[OptionsBinding("PetMaintenanceService",
ValidateDataAnnotations = true,
ValidateOnStart = true,
Lifetime = OptionsLifetime.Monitor)]
public partial class PetMaintenanceServiceOptions
{
/// <summary>
/// The interval in seconds between maintenance runs.
/// </summary>
[Required]
[Range(1, 3600, ErrorMessage = "Interval must be between 1 and 3600 seconds")]
public int RepeatIntervalInSeconds { get; set; }
/// <summary>
/// Whether to enable automatic cleanup of old records.
/// </summary>
public bool EnableAutoCleanup { get; set; }
/// <summary>
/// Maximum number of pets to process in a single batch.
/// </summary>
[Range(1, 1000)]
public int MaxPetsPerBatch { get; set; } = 50;
/// <summary>
/// Email address for maintenance notifications.
/// </summary>
[Required]
[EmailAddress]
public string NotificationEmail { get; set; } = string.Empty;
}Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Register all options from Domain assembly
builder.Services.AddOptionsFromDomain(builder.Configuration);
var app = builder.Build();
app.Run();Usage in service:
public class PetMaintenanceService : BackgroundService
{
private readonly IOptionsMonitor<PetMaintenanceServiceOptions> _options;
private readonly ILogger<PetMaintenanceService> _logger;
public PetMaintenanceService(
IOptionsMonitor<PetMaintenanceServiceOptions> options,
ILogger<PetMaintenanceService> logger)
{
_options = options;
_logger = logger;
// React to configuration changes
_options.OnChange(newOptions =>
{
_logger.LogInformation(
"Configuration updated! New interval: {Interval}s",
newOptions.RepeatIntervalInSeconds);
});
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var config = _options.CurrentValue;
_logger.LogInformation(
"Running maintenance with interval {Interval}s, batch size {BatchSize}",
config.RepeatIntervalInSeconds,
config.MaxPetsPerBatch);
// Perform maintenance...
await Task.Delay(
TimeSpan.FromSeconds(config.RepeatIntervalInSeconds),
stoppingToken);
}
}
}When multiple section name sources are present, the generator uses this priority:
| Priority | Source | Example |
|---|---|---|
| 1οΈβ£ Highest | Attribute parameter | [OptionsBinding("Database")] |
| 2οΈβ£ | const string SectionName |
public const string SectionName = "DB"; |
| 3οΈβ£ | const string NameTitle |
public const string NameTitle = "DB"; |
| 4οΈβ£ | const string Name |
public const string Name = "DB"; |
| 5οΈβ£ Lowest | Auto-inferred from class name | Class DatabaseOptions β "DatabaseOptions" |
Example showing priority:
// This maps to "ExplicitSection" (priority 1 wins)
[OptionsBinding("ExplicitSection")]
public partial class MyOptions
{
public const string SectionName = "SectionNameConst"; // Ignored (priority 2)
public const string NameTitle = "NameTitleConst"; // Ignored (priority 3)
public const string Name = "NameConst"; // Ignored (priority 4)
// Class name "MyOptions" would be used if no explicit section (priority 5)
}Here's how to map both JSON sections from our base configuration:
appsettings.json:
{
"PetMaintenanceService": {
"RepeatIntervalInSeconds": 10,
"EnableAutoCleanup": true,
"MaxPetsPerBatch": 50
},
"PetOtherServiceOptions": {
"RepeatIntervalInSeconds": 10,
"EnableAutoCleanup": true,
"MaxPetsPerBatch": 50
}
}Options classes:
// Case 1: Section name doesn't match class name - Use explicit mapping
[OptionsBinding("PetMaintenanceService")] // β
Explicit section name required
public partial class PetMaintenanceServiceOptions
{
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
public int MaxPetsPerBatch { get; set; }
}
// Case 2: Section name matches class name exactly - Auto-inference works!
[OptionsBinding] // β
No section name needed - infers "PetOtherServiceOptions"
public partial class PetOtherServiceOptions
{
public int RepeatIntervalInSeconds { get; set; }
public bool EnableAutoCleanup { get; set; }
public int MaxPetsPerBatch { get; set; }
}Program.cs:
// Both registered with a single call
services.AddOptionsFromYourProject(configuration);
// Use the options
var maintenanceOptions = provider.GetRequiredService<IOptions<PetMaintenanceServiceOptions>>();
var otherOptions = provider.GetRequiredService<IOptions<PetOtherServiceOptions>>();
Console.WriteLine($"Maintenance interval: {maintenanceOptions.Value.RepeatIntervalInSeconds}s");
Console.WriteLine($"Other interval: {otherOptions.Value.RepeatIntervalInSeconds}s");- π§ Automatic section name inference - Smart resolution from explicit names, const fields (
SectionName,NameTitle,Name), or auto-inferred from class names - π Built-in validation - Integrated DataAnnotations validation (
ValidateDataAnnotations) and startup validation (ValidateOnStart) - π― Custom validation - Support for
IValidateOptions<T>for complex business rules beyond DataAnnotations - π¨ Error on missing keys - Fail-fast validation when configuration sections are missing (
ErrorOnMissingKeys) to catch deployment issues at startup - π Configuration change callbacks - Automatically respond to configuration changes at runtime with
OnChangecallbacks (requires Monitor lifetime) - π§ Post-configuration support - Normalize or transform values after binding with
PostConfigurecallbacks (e.g., ensure paths have trailing slashes, lowercase URLs) - ποΈ ConfigureAll support - Set common default values for all named options instances before individual binding with
ConfigureAllcallbacks (e.g., baseline retry/timeout settings) - π Named options - Multiple configurations of the same options type with different names (e.g., Primary/Secondary email servers)
- π― Child sections - Simplified syntax for creating multiple named instances from configuration subsections (e.g., Email β Primary/Secondary/Fallback)
- β‘ Early access to options - Access bound and validated options during service registration without BuildServiceProvider() anti-pattern (via
GetOrAdd*methods) - π― Explicit section paths - Support for nested sections like
"App:Database"or"Services:Email" - π Nested subsection binding - Automatically bind complex properties to configuration subsections (e.g.,
StorageOptions.Database.Retryβ"Storage:Database:Retry") - π¦ Multiple options classes - Register multiple configuration sections in a single assembly with one method call
- ποΈ Multi-project support - Smart naming generates assembly-specific extension methods (e.g.,
AddOptionsFromDomain(),AddOptionsFromDataAccess()) - π Transitive registration - Automatically discover and register options from referenced assemblies (4 overloads: default, auto-detect all, selective by name, selective multiple)
- β±οΈ Flexible lifetimes - Choose between Singleton (
IOptions<T>), Scoped (IOptionsSnapshot<T>), or Monitor (IOptionsMonitor<T>) patterns - β‘ Native AOT ready - Pure compile-time code generation with zero reflection, fully trimming-safe for modern .NET deployments
- π‘οΈ Compile-time safety - Catch configuration errors during build, not at runtime
- π§ Partial class requirement - Simple
partialkeyword enables seamless extension method generation
Section Name Resolution Priority:
- Explicit attribute parameter:
[OptionsBinding("SectionName")] - Const field:
public const string SectionName = "..."; - Const field:
public const string NameTitle = "..."; - Const field:
public const string Name = "..."; - Auto-inferred from class name
Transitive Registration Overloads:
// Overload 1: Base (current assembly only)
services.AddOptionsFrom{Assembly}(configuration);
// Overload 2: Auto-detect all referenced assemblies
services.AddOptionsFrom{Assembly}(configuration, includeReferencedAssemblies: true);
// Overload 3: Register specific referenced assembly
services.AddOptionsFrom{Assembly}(configuration, "DataAccess");
// Overload 4: Register multiple specific assemblies
services.AddOptionsFrom{Assembly}(configuration, "DataAccess", "Infrastructure");Required:
dotnet add package Atc.SourceGeneratorsOptional (recommended for better IntelliSense):
dotnet add package Atc.SourceGenerators.AnnotationsOr in your .csproj:
<ItemGroup>
<!-- Required: Source generator -->
<PackageReference Include="Atc.SourceGenerators" Version="1.0.0" />
<!-- Optional: Attribute definitions with XML documentation -->
<PackageReference Include="Atc.SourceGenerators.Annotations" Version="1.0.0" />
</ItemGroup>Note: The generator emits fallback attributes automatically, so the Annotations package is optional. However, it provides better XML documentation and IntelliSense. If you include it, suppress the expected CS0436 warning: <NoWarn>$(NoWarn);CS0436</NoWarn>
The simplest usage with automatic section name inference:
using Atc.SourceGenerators.Annotations;
namespace MyApp.Options;
[OptionsBinding] // Section name inferred as "Database"
public partial class DatabaseOptions
{
public string ConnectionString { get; set; } = string.Empty;
public int MaxRetries { get; set; } = 3;
public int TimeoutSeconds { get; set; } = 30;
}appsettings.json:
{
"Database": {
"ConnectionString": "Server=localhost;Database=MyDb;",
"MaxRetries": 5,
"TimeoutSeconds": 60
}
}Generated Code:
public static class OptionsBindingExtensions
{
public static IServiceCollection AddOptionsFromApp(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<global::MyApp.Options.DatabaseOptions>()
.Bind(configuration.GetSection("Database"))
;
return services;
}
}Specify the exact configuration path:
[OptionsBinding("App:ExternalServices:PaymentGateway")]
public partial class PaymentOptions
{
public string ApiKey { get; set; } = string.Empty;
public string BaseUrl { get; set; } = string.Empty;
}appsettings.json:
{
"App": {
"ExternalServices": {
"PaymentGateway": {
"ApiKey": "your-api-key",
"BaseUrl": "https://payment-api.com"
}
}
}
}using System.ComponentModel.DataAnnotations;
[OptionsBinding("Email", ValidateDataAnnotations = true)]
public partial class EmailOptions
{
[Required, EmailAddress]
public string SmtpServer { get; set; } = string.Empty;
[Range(1, 65535)]
public int Port { get; set; } = 587;
[Required]
public string Username { get; set; } = string.Empty;
}Generated Code:
services.AddOptions<global::MyApp.Options.EmailOptions>()
.Bind(configuration.GetSection("Email"))
.ValidateDataAnnotations()
;Ensure options are valid when the application starts:
[OptionsBinding("Database", ValidateOnStart = true)]
public partial class DatabaseOptions
{
public string ConnectionString { get; set; } = string.Empty;
}Generated Code:
services.AddOptions<global::MyApp.Options.DatabaseOptions>()
.Bind(configuration.GetSection("Database"))
.ValidateOnStart()
;[OptionsBinding("Database", ValidateDataAnnotations = true, ValidateOnStart = true)]
public partial class DatabaseOptions
{
[Required, MinLength(10)]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
}For complex validation logic that goes beyond DataAnnotations, use custom validators implementing IValidateOptions<T>:
using Microsoft.Extensions.Options;
// Options class with custom validator
[OptionsBinding("Database",
ValidateDataAnnotations = true,
ValidateOnStart = true,
Validator = typeof(DatabaseOptionsValidator))]
public partial class DatabaseOptions
{
[Required, MinLength(10)]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
public int TimeoutSeconds { get; set; } = 30;
}
// Custom validator with complex business rules
public class DatabaseOptionsValidator : IValidateOptions<DatabaseOptions>
{
public ValidateOptionsResult Validate(string? name, DatabaseOptions options)
{
var failures = new List<string>();
// Custom validation: timeout must be at least 10 seconds
if (options.TimeoutSeconds < 10)
{
failures.Add("TimeoutSeconds must be at least 10 seconds for reliable operations");
}
// Custom validation: connection string must contain Server or Data Source
if (!string.IsNullOrWhiteSpace(options.ConnectionString))
{
var connStr = options.ConnectionString.ToLowerInvariant();
if (!connStr.Contains("server=") && !connStr.Contains("data source="))
{
failures.Add("ConnectionString must contain 'Server=' or 'Data Source=' parameter");
}
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}Generated Code:
services.AddOptions<global::MyApp.Options.DatabaseOptions>()
.Bind(configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<global::Microsoft.Extensions.Options.IValidateOptions<global::MyApp.Options.DatabaseOptions>,
global::MyApp.Options.DatabaseOptionsValidator>();Key Features:
- Supports complex validation logic beyond DataAnnotations
- Validator is automatically registered as a singleton
- Runs during options validation pipeline
- Can validate cross-property dependencies
- Returns detailed failure messages
The ErrorOnMissingKeys feature provides fail-fast validation when configuration sections are missing, preventing runtime errors from invalid or missing configuration.
When to use:
- Critical configuration that must be present (database connections, API keys, etc.)
- Detect configuration issues at application startup instead of later at runtime
- Ensure deployment validation catches missing configuration files or sections
Example:
using System.ComponentModel.DataAnnotations;
[OptionsBinding("Database",
ValidateDataAnnotations = true,
ValidateOnStart = true,
ErrorOnMissingKeys = true)]
public partial class DatabaseOptions
{
[Required, MinLength(10)]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
public int TimeoutSeconds { get; set; } = 30;
}Generated Code:
services.AddOptions<global::MyApp.Options.DatabaseOptions>()
.Bind(configuration.GetSection("Database"))
.Validate(options =>
{
var section = configuration.GetSection("Database");
if (!section.Exists())
{
throw new global::System.InvalidOperationException(
"Configuration section 'Database' is missing. " +
"Ensure the section exists in your appsettings.json or other configuration sources.");
}
return true;
})
.ValidateDataAnnotations()
.ValidateOnStart();Behavior:
- Validates that the configuration section exists using
IConfigurationSection.Exists() - Throws
InvalidOperationExceptionwith descriptive message if section is missing - Combines with
ValidateOnStart = trueto fail at startup (recommended) - Error message includes the section name for easy troubleshooting
Best Practices:
- Always combine with
ValidateOnStart = trueto catch missing configuration at startup - Use for production-critical configuration (databases, external services, etc.)
- Avoid for optional configuration with reasonable defaults
- Ensure deployment processes validate configuration files exist
Example Error Message:
System.InvalidOperationException: Configuration section 'Database' is missing.
Ensure the section exists in your appsettings.json or other configuration sources.
Control which options interface consumers should inject. All three interfaces are always available, but the Lifetime property indicates the recommended interface for your use case:
// Singleton lifetime - Use IOptions<T>
// Best for: Options that don't change during app lifetime
[OptionsBinding("Logging", Lifetime = OptionsLifetime.Singleton)]
public partial class LoggingOptions { }
// Scoped lifetime - Use IOptionsSnapshot<T>
// Best for: Options that may change per request/scope, supports reloading
[OptionsBinding("Request", Lifetime = OptionsLifetime.Scoped)]
public partial class RequestOptions { }
// Monitor lifetime - Use IOptionsMonitor<T>
// Best for: Options that need change notifications and hot-reload support
[OptionsBinding("Feature", Lifetime = OptionsLifetime.Monitor)]
public partial class FeatureOptions { }How to consume:
// Singleton - IOptions<T> (default, most common)
public class MyService
{
public MyService(IOptions<LoggingOptions> options)
{
var logOptions = options.Value;
}
}
// Scoped - IOptionsSnapshot<T> (reloads per request)
public class RequestHandler
{
public RequestHandler(IOptionsSnapshot<RequestOptions> options)
{
var reqOptions = options.Value; // Fresh value per request
}
}
// Monitor - IOptionsMonitor<T> (supports change notifications)
public class FeatureManager
{
public FeatureManager(IOptionsMonitor<FeatureOptions> options)
{
var features = options.CurrentValue; // Current value
// Subscribe to changes
options.OnChange(newOptions =>
{
// Handle configuration changes
});
}
}Important Notes:
AddOptions<T>()registers all three interfaces automatically- The
Lifetimeproperty is a recommendation for which interface to inject - Default is
Singleton(IOptions) if not specified - The generated code includes comments indicating the recommended interface
Automatically respond to configuration changes at runtime using the OnChange property. This feature enables hot-reload of configuration without restarting your application.
Requirements:
- Must use
Lifetime = OptionsLifetime.Monitor - Requires appsettings.json with
reloadOnChange: true - Cannot be used with named options
- Callback method must have signature:
static void MethodName(TOptions options, string? name)
Basic Example:
[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))]
public partial class FeaturesOptions
{
public bool EnableNewUI { get; set; }
public bool EnableBetaFeatures { get; set; }
internal static void OnFeaturesChanged(FeaturesOptions options, string? name)
{
Console.WriteLine("[OnChange] Feature flags changed:");
Console.WriteLine($" EnableNewUI: {options.EnableNewUI}");
Console.WriteLine($" EnableBetaFeatures: {options.EnableBetaFeatures}");
}
}Generated Code:
The generator automatically creates an IHostedService that registers the callback:
// Registration in extension method
services.AddOptions<FeaturesOptions>()
.Bind(configuration.GetSection("Features"));
services.AddHostedService<FeaturesOptionsChangeListener>();
// Generated hosted service
internal sealed class FeaturesOptionsChangeListener : IHostedService
{
private readonly IOptionsMonitor<FeaturesOptions> _monitor;
private IDisposable? _changeToken;
public FeaturesOptionsChangeListener(IOptionsMonitor<FeaturesOptions> monitor)
{
_monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
}
public Task StartAsync(CancellationToken cancellationToken)
{
_changeToken = _monitor.OnChange((options, name) =>
FeaturesOptions.OnFeaturesChanged(options, name));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_changeToken?.Dispose();
return Task.CompletedTask;
}
}Usage Scenarios:
// β
Feature toggles that change without restart
[OptionsBinding("Features", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnFeaturesChanged))]
public partial class FeaturesOptions
{
public bool EnableNewUI { get; set; }
internal static void OnFeaturesChanged(FeaturesOptions options, string? name)
{
// Update feature flag cache, notify observers, etc.
}
}
// β
Logging configuration changes
[OptionsBinding("Logging", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnLoggingChanged))]
public partial class LoggingOptions
{
public string Level { get; set; } = "Information";
internal static void OnLoggingChanged(LoggingOptions options, string? name)
{
// Reconfigure logging providers with new level
}
}
// β
Combined with validation
[OptionsBinding("Database",
Lifetime = OptionsLifetime.Monitor,
ValidateDataAnnotations = true,
ValidateOnStart = true,
OnChange = nameof(OnDatabaseChanged))]
public partial class DatabaseOptions
{
[Required] public string ConnectionString { get; set; } = string.Empty;
internal static void OnDatabaseChanged(DatabaseOptions options, string? name)
{
// Refresh connection pools, update database context, etc.
}
}Validation Errors:
The generator performs compile-time validation of OnChange callbacks:
-
ATCOPT004: OnChange callback requires Monitor lifetime
// β Error: Must use Lifetime = OptionsLifetime.Monitor [OptionsBinding("Settings", OnChange = nameof(OnChanged))] public partial class Settings { }
-
ATCOPT005: OnChange callback not supported with named options
// β Error: Named options don't support OnChange [OptionsBinding("Email", Name = "Primary", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnChanged))] public partial class EmailOptions { }
-
ATCOPT006: OnChange callback method not found
// β Error: Method 'OnSettingsChanged' does not exist [OptionsBinding("Settings", Lifetime = OptionsLifetime.Monitor, OnChange = "OnSettingsChanged")] public partial class Settings { }
-
ATCOPT007: OnChange callback method has invalid signature
// β Error: Must be static void with (TOptions, string?) parameters [OptionsBinding("Settings", Lifetime = OptionsLifetime.Monitor, OnChange = nameof(OnChanged))] public partial class Settings { private void OnChanged(Settings options) { } // Wrong: not static, missing 2nd parameter }
Important Notes:
- Change detection only works with file-based configuration providers (e.g., appsettings.json with
reloadOnChange: true) - The callback is invoked whenever the configuration file changes and is reloaded
- The hosted service is automatically registered when the application starts
- Callback method can be
internalorpublic(notprivate) - The
nameparameter is useful when dealing with named options in other scenarios (always null for unnamed options)
Automatically normalize, validate, or transform configuration values after binding using the PostConfigure property. This feature enables applying defaults, normalizing paths, lowercasing URLs, or computing derived properties.
Requirements:
- Cannot be used with named options
- Callback method must have signature:
static void MethodName(TOptions options) - Runs after binding and validation
Basic Example:
[OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths))]
public partial class StoragePathsOptions
{
public string BasePath { get; set; } = string.Empty;
public string CachePath { get; set; } = string.Empty;
public string TempPath { get; set; } = string.Empty;
private static void NormalizePaths(StoragePathsOptions options)
{
// Ensure all paths end with directory separator
options.BasePath = EnsureTrailingSlash(options.BasePath);
options.CachePath = EnsureTrailingSlash(options.CachePath);
options.TempPath = EnsureTrailingSlash(options.TempPath);
}
private static string EnsureTrailingSlash(string path)
{
if (string.IsNullOrWhiteSpace(path))
return path;
return path.EndsWith(Path.DirectorySeparatorChar)
? path
: path + Path.DirectorySeparatorChar;
}
}Generated Code:
The generator automatically calls .PostConfigure() after binding:
services.AddOptions<StoragePathsOptions>()
.Bind(configuration.GetSection("Storage"))
.PostConfigure(options => StoragePathsOptions.NormalizePaths(options));Usage Scenarios:
// Path normalization - ensure trailing slashes
[OptionsBinding("Storage", PostConfigure = nameof(NormalizePaths))]
public partial class StoragePathsOptions
{
public string BasePath { get; set; } = string.Empty;
public string CachePath { get; set; } = string.Empty;
private static void NormalizePaths(StoragePathsOptions options)
{
options.BasePath = EnsureTrailingSlash(options.BasePath);
options.CachePath = EnsureTrailingSlash(options.CachePath);
}
private static string EnsureTrailingSlash(string path)
=> string.IsNullOrWhiteSpace(path) || path.EndsWith(Path.DirectorySeparatorChar)
? path
: path + Path.DirectorySeparatorChar;
}
// URL normalization - lowercase and remove trailing slashes
[OptionsBinding("ExternalApi", PostConfigure = nameof(NormalizeUrls))]
public partial class ExternalApiOptions
{
public string BaseUrl { get; set; } = string.Empty;
public string CallbackUrl { get; set; } = string.Empty;
private static void NormalizeUrls(ExternalApiOptions options)
{
options.BaseUrl = NormalizeUrl(options.BaseUrl);
options.CallbackUrl = NormalizeUrl(options.CallbackUrl);
}
private static string NormalizeUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
return url;
// Lowercase and remove trailing slash
return url.ToLowerInvariant().TrimEnd('/');
}
}
// Combined with validation
[OptionsBinding("Database",
ValidateDataAnnotations = true,
ValidateOnStart = true,
PostConfigure = nameof(ApplyDefaults))]
public partial class DatabaseOptions
{
[Required] public string ConnectionString { get; set; } = string.Empty;
public int CommandTimeout { get; set; }
private static void ApplyDefaults(DatabaseOptions options)
{
// Apply default timeout if not set
if (options.CommandTimeout <= 0)
{
options.CommandTimeout = 30;
}
}
}Validation Errors:
The generator performs compile-time validation of PostConfigure callbacks:
-
ATCOPT008: PostConfigure callback not supported with named options
// Error: Named options don't support PostConfigure [OptionsBinding("Email", Name = "Primary", PostConfigure = nameof(Normalize))] public partial class EmailOptions { }
-
ATCOPT009: PostConfigure callback method not found
// Error: Method 'ApplyDefaults' does not exist [OptionsBinding("Settings", PostConfigure = "ApplyDefaults")] public partial class Settings { }
-
ATCOPT010: PostConfigure callback method has invalid signature
// Error: Must be static void with (TOptions) parameter [OptionsBinding("Settings", PostConfigure = nameof(Configure))] public partial class Settings { private void Configure() { } // Wrong: not static, missing parameter }
Important Notes:
- PostConfigure runs after binding and validation
- Callback method can be
internalorpublic(notprivate) - Cannot be combined with named options (use manual
.PostConfigure()if needed) - Perfect for normalizing user input, applying business rules, or computed properties
- Order of execution: Bind β Validate β PostConfigure
Set default values for all named options instances before individual configuration binding. This feature is perfect for establishing common baseline settings across multiple named configurations that can then be selectively overridden.
Requirements:
- Requires multiple named instances (at least 2)
- Callback method must have signature:
static void MethodName(TOptions options) - Runs before individual
Configure()calls
Basic Example:
[OptionsBinding("Email:Primary", Name = "Primary", ConfigureAll = nameof(SetDefaults))]
[OptionsBinding("Email:Secondary", Name = "Secondary")]
[OptionsBinding("Email:Fallback", Name = "Fallback")]
public partial class EmailOptions
{
public string SmtpServer { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true;
public int TimeoutSeconds { get; set; } = 30;
public int MaxRetries { get; set; }
internal static void SetDefaults(EmailOptions options)
{
// Set common defaults for ALL email configurations
options.UseSsl = true;
options.TimeoutSeconds = 30;
options.MaxRetries = 3;
options.Port = 587;
}
}Generated Code:
The generator automatically calls .ConfigureAll() before individual configurations:
// Configure defaults for ALL named instances FIRST
services.ConfigureAll<EmailOptions>(options => EmailOptions.SetDefaults(options));
// Then configure individual instances (can override defaults)
services.Configure<EmailOptions>("Primary", configuration.GetSection("Email:Primary"));
services.Configure<EmailOptions>("Secondary", configuration.GetSection("Email:Secondary"));
services.Configure<EmailOptions>("Fallback", configuration.GetSection("Email:Fallback"));Usage Scenarios:
// Notification channels - common defaults for all channels
[OptionsBinding("Notifications:Email", Name = "Email", ConfigureAll = nameof(SetCommonDefaults))]
[OptionsBinding("Notifications:SMS", Name = "SMS")]
[OptionsBinding("Notifications:Push", Name = "Push")]
public partial class NotificationOptions
{
public bool Enabled { get; set; }
public int TimeoutSeconds { get; set; } = 30;
public int MaxRetries { get; set; } = 3;
public int RateLimitPerMinute { get; set; }
internal static void SetCommonDefaults(NotificationOptions options)
{
// All notification channels start with these defaults
options.TimeoutSeconds = 30;
options.MaxRetries = 3;
options.RateLimitPerMinute = 60;
options.Enabled = true;
}
}
// Database connections - common retry and timeout defaults
[OptionsBinding("Database:Primary", Name = "Primary", ConfigureAll = nameof(SetConnectionDefaults))]
[OptionsBinding("Database:ReadReplica", Name = "ReadReplica")]
[OptionsBinding("Database:Analytics", Name = "Analytics")]
public partial class DatabaseConnectionOptions
{
public string ConnectionString { get; set; } = string.Empty;
public int MaxRetries { get; set; }
public int CommandTimeoutSeconds { get; set; }
public bool EnableRetry { get; set; }
internal static void SetConnectionDefaults(DatabaseConnectionOptions options)
{
// All database connections start with these baseline settings
options.MaxRetries = 3;
options.CommandTimeoutSeconds = 30;
options.EnableRetry = true;
}
}Validation Errors:
The generator performs compile-time validation of ConfigureAll callbacks:
-
ATCOPT011: ConfigureAll requires multiple named options
// Error: ConfigureAll needs at least 2 named instances [OptionsBinding("Settings", Name = "Default", ConfigureAll = nameof(SetDefaults))] public partial class Settings { }
-
ATCOPT012: ConfigureAll callback method not found
// Error: Method 'SetDefaults' does not exist [OptionsBinding("Email", Name = "Primary", ConfigureAll = "SetDefaults")] [OptionsBinding("Email", Name = "Secondary")] public partial class EmailOptions { }
-
ATCOPT013: ConfigureAll callback method has invalid signature
// Error: Must be static void with (TOptions) parameter [OptionsBinding("Email", Name = "Primary", ConfigureAll = nameof(Configure))] [OptionsBinding("Email", Name = "Secondary")] public partial class EmailOptions { private void Configure() { } // Wrong: not static, missing parameter }
Important Notes:
- ConfigureAll runs before individual named instance configurations
- Individual configurations can override defaults set by ConfigureAll
- Callback method can be
internalorpublic(notprivate) - Requires multiple named instances - cannot be used with single unnamed instance
- Perfect for establishing baseline settings across multiple configurations
- Order of execution: ConfigureAll β Configure("Name1") β Configure("Name2") β ...
- Can be specified on any one of the
[OptionsBinding]attributes (only processed once)
The generator scans your code for classes decorated with [OptionsBinding]:
[OptionsBinding("Database")]
public partial class DatabaseOptions { }The generator resolves section names in the following priority order:
-
Explicit section name - Provided in the attribute constructor parameter
[OptionsBinding("App:Database")] public partial class DatabaseOptions { } // Uses "App:Database"
-
public const string SectionName- Defined in the options class (2nd highest priority)[OptionsBinding] public partial class DatabaseOptions { public const string SectionName = "CustomDatabase"; // Uses "CustomDatabase" }
-
public const string NameTitle- Defined in the options class (takes priority overName)[OptionsBinding] public partial class CacheOptions { public const string NameTitle = "MyCache"; // Uses "MyCache" }
-
public const string Name- Defined in the options class[OptionsBinding] public partial class EmailOptions { public const string Name = "EmailConfig"; // Uses "EmailConfig" }
-
Auto-inferred - Uses the full class name as-is:
DatabaseOptionsβ"DatabaseOptions"ApiSettingsβ"ApiSettings"LoggingConfigβ"LoggingConfig"CacheConfigurationβ"CacheConfiguration"
Generates an extension method for your assembly:
public static IServiceCollection AddOptionsFrom{AssemblyName}(
this IServiceCollection services,
IConfiguration configuration)
{
// Registration code for each options class
}All code is generated at compile time, ensuring:
- β Type safety
- β No runtime reflection
- β IntelliSense support
- β Easy debugging
Each assembly gets its own extension method:
MyApp.Core:
[OptionsBinding("Database")]
public partial class DatabaseOptions { }
// Generated: AddOptionsFromAppCore(configuration)MyApp.Api:
[OptionsBinding("Api")]
public partial class ApiOptions { }
// Generated: AddOptionsFromAppApi(configuration)Program.cs:
services.AddOptionsFromCore(configuration);
services.AddOptionsFromApi(configuration);The generator uses smart suffix-based naming to create cleaner, more readable method names:
How it works:
- β If the assembly suffix (last segment after final dot) is unique among all assemblies β use short suffix
β οΈ If multiple assemblies have the same suffix β use full sanitized name to avoid conflicts
Examples:
// β
Unique suffixes (cleaner names)
PetStore.Domain β AddOptionsFromDomain(configuration)
PetStore.DataAccess β AddOptionsFromDataAccess(configuration)
PetStore.Api β AddOptionsFromApi(configuration)
// β οΈ Conflicting suffixes (full names prevent collisions)
PetStore.Domain β AddOptionsFromPetStoreDomain(configuration)
AnotherApp.Domain β AddOptionsFromAnotherAppDomain(configuration)Benefits:
- π― Cleaner API: Shorter method names when there are no conflicts
- π‘οΈ Automatic Conflict Prevention: Fallback to full names prevents naming collisions
- β‘ Zero Configuration: Works automatically based on compilation context
- π Context-Aware: Method names adapt to the assemblies in your solution
The generator automatically handles nested configuration subsections through Microsoft's .Bind() method. Complex properties are automatically bound to their corresponding configuration subsections.
When you have properties that are complex types (not primitives like string, int, etc.), the configuration binder automatically:
- Detects the property is a complex type
- Looks for a subsection with the same name
- Recursively binds that subsection to the property
This works for:
- Nested objects - Properties with custom class types
- Collections - List, IEnumerable, arrays
- Dictionaries - Dictionary<string, string>, Dictionary<string, T>
- Multiple levels - Deeply nested structures (e.g., CloudStorage β Azure β Blob)
[OptionsBinding("Email")]
public partial class EmailOptions
{
public string From { get; set; } = string.Empty;
// Automatically binds to "Email:Smtp" subsection
public SmtpSettings Smtp { get; set; } = new();
}
public class SmtpSettings
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public bool UseSsl { get; set; }
}{
"Email": {
"From": "noreply@example.com",
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"UseSsl": true
}
}
}[OptionsBinding("Storage", ValidateDataAnnotations = true)]
public partial class StorageOptions
{
// Automatically binds to "Storage:Database" subsection
public DatabaseSettings Database { get; set; } = new();
}
public class DatabaseSettings
{
[Required]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 1000)]
public int MaxConnections { get; set; } = 100;
// Automatically binds to "Storage:Database:Retry" subsection (3 levels deep!)
public DatabaseRetryPolicy Retry { get; set; } = new();
}
public class DatabaseRetryPolicy
{
[Range(0, 10)]
public int MaxAttempts { get; set; } = 3;
[Range(100, 10000)]
public int DelayMilliseconds { get; set; } = 500;
}{
"Storage": {
"Database": {
"ConnectionString": "Server=localhost;Database=PetStoreDb;",
"MaxConnections": 100,
"Retry": {
"MaxAttempts": 3,
"DelayMilliseconds": 500
}
}
}
}[OptionsBinding("CloudStorage", ValidateDataAnnotations = true, ValidateOnStart = true)]
public partial class CloudStorageOptions
{
[Required]
public string Provider { get; set; } = string.Empty;
// Binds to "CloudStorage:Azure"
public AzureStorageSettings Azure { get; set; } = new();
// Binds to "CloudStorage:Aws"
public AwsS3Settings Aws { get; set; } = new();
// Binds to "CloudStorage:RetryPolicy"
public RetryPolicy RetryPolicy { get; set; } = new();
}
public class AzureStorageSettings
{
[Required]
public string ConnectionString { get; set; } = string.Empty;
public string ContainerName { get; set; } = string.Empty;
// Binds to "CloudStorage:Azure:Blob" (deeply nested!)
public BlobSettings Blob { get; set; } = new();
}
public class BlobSettings
{
public int MaxBlockSize { get; set; } = 4194304; // 4 MB
public int ParallelOperations { get; set; } = 8;
}{
"CloudStorage": {
"Provider": "Azure",
"Azure": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=myaccount;",
"ContainerName": "my-container",
"Blob": {
"MaxBlockSize": 4194304,
"ParallelOperations": 8
}
},
"Aws": {
"AccessKey": "AKIAIOSFODNN7EXAMPLE",
"SecretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"Region": "us-west-2",
"BucketName": "my-bucket"
},
"RetryPolicy": {
"MaxRetries": 3,
"DelayMilliseconds": 1000,
"UseExponentialBackoff": true
}
}
}- Zero extra configuration - Just declare properties with complex types
- Automatic path construction - "Parent:Child:GrandChild" paths are built automatically
- Works with validation - DataAnnotations validation applies to all nested levels
- Unlimited depth - Support for deeply nested structures
- Collections supported - List, arrays, dictionaries all work automatically
You can also explicitly specify the full nested path in the attribute:
[OptionsBinding("App:Services:Email:Smtp")]
public partial class SmtpOptions
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 587;
}{
"App": {
"Services": {
"Email": {
"Smtp": {
"Host": "smtp.example.com",
"Port": 587
}
}
}
}
}Problem: Sometimes you need to access options values during service registration to make conditional decisions, but calling BuildServiceProvider() in the middle of registration is an anti-pattern that causes:
- β Memory leaks
- β Scope issues
- β Application instability
Solution: The generator provides three APIs for early access to bound and validated options without building the service provider:
| Method | Reads Cache | Writes Cache | Use Case |
|---|---|---|---|
Get[Type]From[Assembly]() |
β Yes | β No | Efficient retrieval (uses cached if available, no side effects) |
GetOrAdd[Type]From[Assembly]() |
β Yes | β Yes | Early access with caching for idempotency |
GetOptions<T>() |
β Yes | β No | Smart dispatcher (calls Get internally, multi-assembly support) |
- β Efficient caching - Get methods read from cache when available (no unnecessary instance creation)
- β No side effects - Get and GetOptions don't populate cache (only GetOrAdd does)
- β Idempotent GetOrAdd - Safe to call multiple times, returns the same instance
- β Smart dispatcher - GetOptions() works in multi-assembly projects (routes to correct Get method)
- β Immediate validation - DataAnnotations and ErrorOnMissingKeys validation happens immediately
- β PostConfigure support - Applies PostConfigure callbacks before returning
- β
Full integration - Works seamlessly with normal
AddOptionsFrom*methods - β Unnamed options only - Named options are excluded (use standard Options pattern for those)
Approach 1: Get Methods (Pure Retrieval)
// Reads cache but doesn't populate - efficient, no side effects
var dbOptions1 = services.GetDatabaseOptionsFromDomain(configuration);
var dbOptions2 = services.GetDatabaseOptionsFromDomain(configuration);
// If GetOrAdd was never called: dbOptions1 != dbOptions2 (creates fresh instances)
// If GetOrAdd was called first: dbOptions1 == dbOptions2 (returns cached)
if (dbOptions1.EnableFeatureX)
{
services.AddScoped<IFeatureX, FeatureXService>();
}Approach 2: GetOrAdd Methods (With Caching)
// Populates cache on first call - use for idempotency
var dbOptions1 = services.GetOrAddDatabaseOptionsFromDomain(configuration);
var dbOptions2 = services.GetOrAddDatabaseOptionsFromDomain(configuration);
// dbOptions1 == dbOptions2 (always true - cached and reused)
if (dbOptions1.EnableFeatureX)
{
services.AddScoped<IFeatureX, FeatureXService>();
}Approach 3: Generic Smart Dispatcher (Multi-Assembly)
// Convenience method - routes to Get method internally (no caching side effects)
// Works in multi-assembly projects! No CS0121 ambiguity
var dbOptions = services.GetOptions<DatabaseOptions>(configuration);
if (dbOptions.EnableFeatureX)
{
services.AddScoped<IFeatureX, FeatureXService>();
}Use Get[Type]... when:
- β You want efficient retrieval (benefits from cache if available)
- β You don't want side effects (no cache population)
- β You're okay with fresh instances if cache is empty
Use GetOrAdd[Type]... when:
- β You need idempotency (same instance on repeated calls)
- β You want to explicitly populate cache for later use
- β You prefer explicit cache management
Use GetOptions<T>() when:
- β You want concise, generic syntax
- β Working in multi-assembly projects (smart dispatcher routes correctly)
- β You want same behavior as Get (efficient, no side effects)
// Step 1: Get options early during service registration
// Option A: Assembly-specific (always works, recommended)
var dbOptions = services.GetOrAddDatabaseOptionsFromDomain(configuration);
// Option B: Generic (only in single-assembly projects)
// var dbOptions = services.GetOptions<DatabaseOptions>(configuration);
// Step 2: Use options to make conditional registration decisions
if (dbOptions.EnableFeatureX)
{
services.AddScoped<IFeatureX, FeatureXService>();
}
if (dbOptions.MaxRetries > 0)
{
services.AddSingleton<IRetryPolicy>(new RetryPolicy(dbOptions.MaxRetries));
}
// Step 3: Continue with normal registration (idempotent, no duplication)
services.AddOptionsFromDomain(configuration);
// The options are now available via IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>// First call - creates, binds, validates, and caches
var dbOptions1 = services.GetOrAddDatabaseOptionsFromDomain(configuration);
Console.WriteLine($"MaxRetries: {dbOptions1.MaxRetries}");
// Second call - returns cached instance (no re-binding, no re-validation)
var dbOptions2 = services.GetOrAddDatabaseOptionsFromDomain(configuration);
// Both references point to the same instance
Console.WriteLine($"Same instance? {ReferenceEquals(dbOptions1, dbOptions2)}"); // True// Options class with validation
[OptionsBinding("Database", ValidateDataAnnotations = true, ErrorOnMissingKeys = true)]
public partial class DatabaseOptions
{
[Required]
[MinLength(10)]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; }
}
// Early access - validation happens immediately
try
{
var dbOptions = services.GetOrAddDatabaseOptionsFromDomain(configuration);
// Validation passed - options are ready to use
}
catch (ValidationException ex)
{
// Validation failed - caught at registration time, not runtime!
Console.WriteLine($"Configuration error: {ex.Message}");
}1. Conditional Feature Registration:
var featuresOptions = services.GetOrAddFeaturesOptionsFromDomain(configuration);
if (featuresOptions.EnableRedisCache)
{
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = featuresOptions.RedisCacheConnectionString;
});
}
else
{
services.AddDistributedMemoryCache();
}2. Dynamic Service Configuration:
var storageOptions = services.GetOrAddStorageOptionsFromDomain(configuration);
services.AddScoped<IFileStorage>(sp =>
{
return storageOptions.Provider switch
{
"Azure" => new AzureBlobStorage(storageOptions.AzureConnectionString),
"AWS" => new S3Storage(storageOptions.AwsAccessKey, storageOptions.AwsSecretKey),
_ => new LocalFileStorage(storageOptions.LocalPath)
};
});3. Validation-Based Registration:
var apiOptions = services.GetOrAddApiOptionsFromDomain(configuration);
// Only register rate limiting if enabled in config
if (apiOptions.EnableRateLimiting && apiOptions.RateLimitPerMinute > 0)
{
services.AddRateLimiting(options =>
{
options.RequestsPerMinute = apiOptions.RateLimitPerMinute;
});
}-
First Call to
GetOrAdd{OptionsName}():- Creates new options instance
- Binds from configuration section
- Validates (DataAnnotations, ErrorOnMissingKeys)
- Applies PostConfigure callbacks
- Adds to internal cache
- Registers via
services.Configure<T>() - Returns bound instance
-
Subsequent Calls:
- Checks internal cache
- Returns existing instance (no re-binding, no re-validation)
-
Normal
AddOptionsFrom*Call:- Automatically populates cache via
.PostConfigure() - Options available via
IOptions<T>,IOptionsSnapshot<T>,IOptionsMonitor<T>
- Automatically populates cache via
- Unnamed options only - Named options (
Nameproperty) do not generateGetOrAdd*methods - Singleton lifetime - Early access options are registered as singleton
- No OnChange support - Early access is for registration-time decisions only
β DO:
- Use early access for conditional service registration
- Use early access for dynamic service configuration
- Call
GetOrAdd*methods beforeAddOptionsFrom* - Validate options at registration time
β DON'T:
- Use early access for runtime decisions (use
IOptions<T>instead) - Call
BuildServiceProvider()during registration - Mix early access with named options (not supported)
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables()
.Build();
services.AddOptionsFromApp(configuration);Named Options allow you to have multiple configurations of the same options type with different names. This is useful when you need different configurations for the same logical service (e.g., Primary/Secondary email servers, Production/Staging databases).
- π Fallback Servers: Primary, Secondary, and Fallback email/database servers
- π Multi-Region: Different API endpoints for different regions (US, EU, Asia)
- π― Multi-Tenant: Tenant-specific configurations
- π§ Environment Tiers: Production, Staging, Development endpoints
Define options with multiple named instances:
[OptionsBinding("Email:Primary", Name = "Primary")]
[OptionsBinding("Email:Secondary", Name = "Secondary")]
[OptionsBinding("Email:Fallback", Name = "Fallback")]
public partial class EmailOptions
{
public string SmtpServer { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true;
public string FromAddress { get; set; } = string.Empty;
}Configure appsettings.json:
{
"Email": {
"Primary": {
"SmtpServer": "smtp.primary.example.com",
"Port": 587,
"UseSsl": true,
"FromAddress": "noreply@primary.example.com"
},
"Secondary": {
"SmtpServer": "smtp.secondary.example.com",
"Port": 587,
"UseSsl": true,
"FromAddress": "noreply@secondary.example.com"
},
"Fallback": {
"SmtpServer": "smtp.fallback.example.com",
"Port": 25,
"UseSsl": false,
"FromAddress": "noreply@fallback.example.com"
}
}
}Access named options using IOptionsSnapshot:
public class EmailService
{
private readonly IOptionsSnapshot<EmailOptions> _emailOptionsSnapshot;
public EmailService(IOptionsSnapshot<EmailOptions> emailOptionsSnapshot)
{
_emailOptionsSnapshot = emailOptionsSnapshot;
}
public async Task SendAsync(string to, string body)
{
// Try primary first
var primaryOptions = _emailOptionsSnapshot.Get("Primary");
if (await TrySendAsync(primaryOptions, to, body))
return;
// Fallback to secondary
var secondaryOptions = _emailOptionsSnapshot.Get("Secondary");
if (await TrySendAsync(secondaryOptions, to, body))
return;
// Last resort: fallback server
var fallbackOptions = _emailOptionsSnapshot.Get("Fallback");
await TrySendAsync(fallbackOptions, to, body);
}
}// Generated registration methods
services.Configure<EmailOptions>("Primary", configuration.GetSection("Email:Primary"));
services.Configure<EmailOptions>("Secondary", configuration.GetSection("Email:Secondary"));
services.Configure<EmailOptions>("Fallback", configuration.GetSection("Email:Fallback"));- π Use
IOptionsSnapshot<T>: Named options are accessed viaIOptionsSnapshot<T>.Get(name), notIOptions<T>.Value - π« No Validation Chain: Named options use the simpler
Configure<T>(name, section)pattern without validation support - π AllowMultiple: The
[OptionsBinding]attribute supportsAllowMultiple = trueto enable multiple configurations
You can have both named and unnamed options on the same class:
// Default unnamed instance
[OptionsBinding("Email")]
// Named instances for specific use cases
[OptionsBinding("Email:Backup", Name = "Backup")]
public partial class EmailOptions
{
public string SmtpServer { get; set; } = string.Empty;
public int Port { get; set; } = 587;
}// Access default (unnamed) instance
var defaultEmail = serviceProvider.GetRequiredService<IOptions<EmailOptions>>();
// Access named instances
var emailSnapshot = serviceProvider.GetRequiredService<IOptionsSnapshot<EmailOptions>>();
var backupEmail = emailSnapshot.Get("Backup");Child Sections provide a concise syntax for creating multiple named options instances from configuration subsections. Instead of writing multiple [OptionsBinding] attributes for each named instance, you can use a single ChildSections property.
- π Fallback Servers: Automatically create Primary/Secondary/Fallback email configurations
- π’ Notification Channels: Email, SMS, Push notification configurations from a single attribute
- π Multi-Region: Different regional configurations (USEast, USWest, EUWest)
- π§ Multi-Tenant: Tenant-specific configurations (Tenant1, Tenant2, Tenant3)
Before (Multiple Attributes):
[OptionsBinding("Email:Primary", Name = "Primary")]
[OptionsBinding("Email:Secondary", Name = "Secondary")]
[OptionsBinding("Email:Fallback", Name = "Fallback")]
public partial class EmailOptions
{
public string SmtpServer { get; set; } = string.Empty;
public int Port { get; set; } = 587;
}After (With ChildSections):
[OptionsBinding("Email", ChildSections = new[] { "Primary", "Secondary", "Fallback" })]
public partial class EmailOptions
{
public string SmtpServer { get; set; } = string.Empty;
public int Port { get; set; } = 587;
}Both generate identical code:
services.Configure<EmailOptions>("Primary", configuration.GetSection("Email:Primary"));
services.Configure<EmailOptions>("Secondary", configuration.GetSection("Email:Secondary"));
services.Configure<EmailOptions>("Fallback", configuration.GetSection("Email:Fallback"));{
"Email": {
"Primary": {
"SmtpServer": "smtp.primary.example.com",
"Port": 587
},
"Secondary": {
"SmtpServer": "smtp.secondary.example.com",
"Port": 587
},
"Fallback": {
"SmtpServer": "smtp.fallback.example.com",
"Port": 25
}
}
}With Validation:
[OptionsBinding("Database",
ChildSections = new[] { "Primary", "Secondary" },
ValidateDataAnnotations = true,
ValidateOnStart = true)]
public partial class DatabaseOptions
{
[Required]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
}Generated Code:
services.AddOptions<DatabaseOptions>("Primary")
.Bind(configuration.GetSection("Database:Primary"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<DatabaseOptions>("Secondary")
.Bind(configuration.GetSection("Database:Secondary"))
.ValidateDataAnnotations()
.ValidateOnStart();With ConfigureAll (Common Defaults):
[OptionsBinding("Notifications",
ChildSections = new[] { "Email", "SMS", "Push" },
ConfigureAll = nameof(SetCommonDefaults))]
public partial class NotificationOptions
{
public bool Enabled { get; set; }
public int TimeoutSeconds { get; set; } = 30;
public int MaxRetries { get; set; } = 3;
internal static void SetCommonDefaults(NotificationOptions options)
{
options.TimeoutSeconds = 30;
options.MaxRetries = 3;
options.Enabled = true;
}
}Generated Code:
// Set defaults for ALL notification channels FIRST
services.ConfigureAll<NotificationOptions>(options =>
NotificationOptions.SetCommonDefaults(options));
// Then configure individual channels
services.Configure<NotificationOptions>("Email", configuration.GetSection("Notifications:Email"));
services.Configure<NotificationOptions>("SMS", configuration.GetSection("Notifications:SMS"));
services.Configure<NotificationOptions>("Push", configuration.GetSection("Notifications:Push"));ChildSections works with nested configuration paths:
[OptionsBinding("App:Services:Cache", ChildSections = new[] { "Redis", "Memory" })]
public partial class CacheOptions
{
public string Provider { get; set; } = string.Empty;
public int ExpirationMinutes { get; set; }
}Generated paths:
"App:Services:Cache:Redis""App:Services:Cache:Memory"
The generator performs compile-time validation:
-
ATCOPT014: ChildSections cannot be used with
Nameproperty// β Error: Cannot use both ChildSections and Name [OptionsBinding("Email", Name = "Primary", ChildSections = new[] { "A", "B" })] public partial class EmailOptions { }
-
ATCOPT015: ChildSections requires at least 2 items
// β Error: Must have at least 2 child sections [OptionsBinding("Email", ChildSections = new[] { "Primary" })] public partial class EmailOptions { } // β Error: Empty array not allowed [OptionsBinding("Email", ChildSections = new string[] { })] public partial class EmailOptions { }
-
ATCOPT016: ChildSections items cannot be null or empty
// β Error: Array contains empty string [OptionsBinding("Email", ChildSections = new[] { "Primary", "", "Secondary" })] public partial class EmailOptions { }
- Less Boilerplate: One attribute instead of many
- Clearer Intent: Explicitly shows related configurations are grouped
- Easier Maintenance: Add/remove sections by updating the array
- Same Features: Supports all named options features (validation, ConfigureAll, etc.)
| Feature | Multiple [OptionsBinding] |
ChildSections |
|---|---|---|
| Verbosity | 3+ attributes | 1 attribute |
| Named instances | β Yes | β Yes |
| Validation | β Yes | β Yes |
| ConfigureAll | β Yes | β Yes |
| Custom validators | β Yes | β Yes |
| ErrorOnMissingKeys | β Yes | β Yes |
| Clarity | Explicit | Concise |
| Use case | Few instances | Many instances |
Recommendation:
- Use
ChildSectionswhen you have 2+ related configurations under a common parent section - Use multiple
[OptionsBinding]attributes when configurations come from different sections
Notification system with multiple channels:
/// <summary>
/// Notification channel options with support for multiple named configurations.
/// </summary>
[OptionsBinding("Notifications",
ChildSections = new[] { "Email", "SMS", "Push" },
ConfigureAll = nameof(SetCommonDefaults),
ValidateDataAnnotations = true,
ValidateOnStart = true)]
public partial class NotificationOptions
{
[Required]
public string Provider { get; set; } = string.Empty;
public bool Enabled { get; set; }
[Range(1, 300)]
public int TimeoutSeconds { get; set; } = 30;
[Range(0, 10)]
public int MaxRetries { get; set; } = 3;
internal static void SetCommonDefaults(NotificationOptions options)
{
options.TimeoutSeconds = 30;
options.MaxRetries = 3;
options.RateLimitPerMinute = 60;
options.Enabled = true;
}
}appsettings.json:
{
"Notifications": {
"Email": {
"Provider": "SendGrid",
"Enabled": true,
"TimeoutSeconds": 30,
"MaxRetries": 3
},
"SMS": {
"Provider": "Twilio",
"Enabled": false,
"TimeoutSeconds": 15,
"MaxRetries": 2
},
"Push": {
"Provider": "Firebase",
"Enabled": true,
"TimeoutSeconds": 20,
"MaxRetries": 3
}
}
}Usage:
public class NotificationService
{
private readonly IOptionsSnapshot<NotificationOptions> _notificationOptions;
public NotificationService(IOptionsSnapshot<NotificationOptions> notificationOptions)
{
_notificationOptions = notificationOptions;
}
public async Task SendAsync(string channel, string message)
{
var options = _notificationOptions.Get(channel);
if (!options.Enabled)
{
return; // Channel disabled
}
// Send using the configured provider
await SendViaProvider(options.Provider, message, options.TimeoutSeconds);
}
}The generator provides helpful compile-time diagnostics:
Error:
[OptionsBinding("Database")]
public class DatabaseOptions { } // β Missing 'partial' keywordFix:
[OptionsBinding("Database")]
public partial class DatabaseOptions { } // β
CorrectError:
[OptionsBinding("")] // β Empty section name
public partial class DatabaseOptions { }Fix:
[OptionsBinding("Database")] // β
Provide section name
public partial class DatabaseOptions { }
// Or let it be inferred
[OptionsBinding] // β
Inferred as "Database"
public partial class DatabaseOptions { }Warning:
General warning for invalid configuration scenarios.
Error:
[OptionsBinding]
public partial class DatabaseOptions
{
public const string Name = ""; // β Empty const value
}[OptionsBinding]
public partial class ApiOptions
{
public const string NameTitle = null; // β Null const value
}Fix:
// Provide a valid const value
[OptionsBinding]
public partial class DatabaseOptions
{
public const string Name = "MyDatabase"; // β
Valid const value
}
// Or remove the const field to use auto-inference
[OptionsBinding]
public partial class DatabaseOptions // β
Inferred as "Database"
{
// No const Name/NameTitle field
}See Configuration Change Callbacks section for details.
See Post-Configuration Support section for details.
See ConfigureAll Support section for details.
See Child Sections (Simplified Named Options) section for details.
Quick reference:
- ATCOPT014: ChildSections cannot be used with Name property
- ATCOPT015: ChildSections requires at least 2 items
- ATCOPT016: ChildSections items cannot be null or empty
The Options Binding Generator is fully compatible with Native AOT compilation, producing code that meets all AOT requirements:
- Zero reflection - All options binding uses
IConfiguration.Bind()without reflection-based discovery - Compile-time generation - Binding code is generated during build, not at runtime
- Trimming-safe - No dynamic type discovery or metadata dependencies
- Static method calls - All registration uses concrete extension method calls
- Static analysis friendly - All code paths are visible to the AOT compiler
- Build-time analysis: The generator scans classes with
[OptionsBinding]attributes during compilation - Method generation: Creates static extension methods with concrete
IConfiguration.GetSection()andBind()calls - Options API integration: Uses standard .NET Options pattern (
AddOptions<T>(),Bind(),Validate()) - AOT compilation: The generated code compiles to native machine code with full optimizations
// Source: [OptionsBinding("Database")] public partial class DatabaseOptions { ... }
// Generated AOT-safe code:
public static IServiceCollection AddOptionsFromYourProject(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<DatabaseOptions>()
.Bind(configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart();
return services;
}Why This Is AOT-Safe:
- No
Activator.CreateInstance()calls (reflection) - No dynamic assembly scanning
- All types resolved at compile time via generic parameters
- Configuration binding uses built-in AOT-compatible
IConfiguration.Bind() - Validation uses standard DataAnnotations attributes
Even transitive options registration remains fully AOT-compatible:
// Auto-detect and register referenced assemblies - still AOT-safe!
services.AddOptionsFromApp(configuration, includeReferencedAssemblies: true);The generator produces concrete method calls to each referenced assembly's registration method, ensuring the entire dependency chain compiles to efficient native code.
// Options class
[OptionsBinding]
public partial class AppOptions
{
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
}
// appsettings.json
{
"App": {
"Name": "My Application",
"Version": "1.0.0"
}
}
// Program.cs
services.AddOptionsFromApp(configuration);
var appOptions = serviceProvider.GetRequiredService<IOptions<AppOptions>>();
Console.WriteLine($"{appOptions.Value.Name} v{appOptions.Value.Version}");[OptionsBinding("Database", ValidateDataAnnotations = true, ValidateOnStart = true)]
public partial class DatabaseOptions
{
[Required, MinLength(10)]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
[Range(1, 300)]
public int TimeoutSeconds { get; set; } = 30;
}appsettings.json:
{
"Database": {
"ConnectionString": "Server=localhost;Database=MyDb;"
},
"Api": {
"BaseUrl": "https://api.example.com",
"Timeout": 30
},
"Logging": {
"Level": "Information",
"EnableConsole": true
}
}Options Classes:
[OptionsBinding("Database")]
public partial class DatabaseOptions
{
public string ConnectionString { get; set; } = string.Empty;
}
[OptionsBinding("Api")]
public partial class ApiOptions
{
public string BaseUrl { get; set; } = string.Empty;
public int Timeout { get; set; } = 30;
}
[OptionsBinding("Logging")]
public partial class LoggingOptions
{
public string Level { get; set; } = "Information";
public bool EnableConsole { get; set; } = true;
}Program.cs:
// Single call registers all options
services.AddOptionsFromApp(configuration);
// Use options
var dbOpts = provider.GetRequiredService<IOptions<DatabaseOptions>>();
var apiOpts = provider.GetRequiredService<IOptions<ApiOptions>>();
var logOpts = provider.GetRequiredService<IOptions<LoggingOptions>>();- Sample Project - Working example
- Source Generator Documentation - Microsoft Docs
- Options Pattern - Microsoft Docs
Q: Do I need to make my options class partial?
A: Yes, the partial keyword is required for source generators to add generated code to your class.
Q: Can I use this with ASP.NET Core?
A: Absolutely! It works with any .NET application that uses Microsoft.Extensions.Options.
Q: What if I don't specify a section name?
A: The generator infers the section name by removing common suffixes (Options, Settings, Config, Configuration) from your class name.
Q: Can I validate options at startup?
A: Yes, use ValidateOnStart = true in the attribute. Combined with ValidateDataAnnotations = true, your options will be validated when the application starts.
Q: Does this work with reloadable configuration?
A: Yes, when you use reloadOnChange: true with your configuration source, the bound options will reflect changes automatically.
[License information here]