Configuration and Options
"The supreme irony of 'Convention over Configuration' is that the Configuration half of the phrase — the literal settings your application needs to run — still depends on conventions to work correctly. You need a convention to name your sections. A convention to validate them. A convention to bind them. Configuration is the domain that named the pattern, and it still hasn't solved the problem."
The Domain That Named the Pattern
Configuration is where the entire "Convention over Configuration" movement began. Rails looked at XML configuration files and said: "If you follow our naming rules, you don't need any of this." That was revolutionary.
But twenty years later, configuration in .NET is still a source of runtime failures. The section name in appsettings.json must match the string you pass to GetSection(). The property names in your C# class must match the JSON keys. Required values must be annotated. Validation must be wired. Startup validation must be enabled.
Every one of those "must" statements is a convention. Every convention needs documentation. Every convention needs enforcement. And every enforcement mechanism is code that someone wrote, tested, and maintains — to compensate for the fact that the compiler doesn't know what your configuration looks like.
Let's trace the four eras of configuration binding and see where the Convention Tax accumulates.
Era 1: Code — ConfigurationManager.AppSettings
In the beginning, configuration was a flat dictionary of strings, accessed by string keys.
// Era 1: Code — raw string-key access to app.config / web.config
public class SmtpService
{
private readonly string _host;
private readonly int _port;
private readonly string _username;
private readonly string _password;
private readonly bool _enableSsl;
public SmtpService()
{
_host = ConfigurationManager.AppSettings["SmtpHost"];
_port = int.Parse(ConfigurationManager.AppSettings["SmtpPort"]);
_username = ConfigurationManager.AppSettings["SmtpUsername"];
_password = ConfigurationManager.AppSettings["SmtpPassword"];
_enableSsl = bool.Parse(ConfigurationManager.AppSettings["SmtpEnableSsl"]);
}
public void Send(string to, string subject, string body)
{
// What if SmtpHost is null? NullReferenceException at runtime.
// What if SmtpPort is "abc"? FormatException at runtime.
// What if SmtpEnableSsl is missing? ArgumentNullException at runtime.
using var client = new SmtpClient(_host, _port);
client.EnableSsl = _enableSsl;
client.Credentials = new NetworkCredential(_username, _password);
client.Send("noreply@example.com", to, subject, body);
}
}// Era 1: Code — raw string-key access to app.config / web.config
public class SmtpService
{
private readonly string _host;
private readonly int _port;
private readonly string _username;
private readonly string _password;
private readonly bool _enableSsl;
public SmtpService()
{
_host = ConfigurationManager.AppSettings["SmtpHost"];
_port = int.Parse(ConfigurationManager.AppSettings["SmtpPort"]);
_username = ConfigurationManager.AppSettings["SmtpUsername"];
_password = ConfigurationManager.AppSettings["SmtpPassword"];
_enableSsl = bool.Parse(ConfigurationManager.AppSettings["SmtpEnableSsl"]);
}
public void Send(string to, string subject, string body)
{
// What if SmtpHost is null? NullReferenceException at runtime.
// What if SmtpPort is "abc"? FormatException at runtime.
// What if SmtpEnableSsl is missing? ArgumentNullException at runtime.
using var client = new SmtpClient(_host, _port);
client.EnableSsl = _enableSsl;
client.Credentials = new NetworkCredential(_username, _password);
client.Send("noreply@example.com", to, subject, body);
}
}The corresponding web.config:
<configuration>
<appSettings>
<add key="SmtpHost" value="smtp.example.com" />
<add key="SmtpPort" value="587" />
<add key="SmtpUsername" value="admin@example.com" />
<add key="SmtpPassword" value="s3cret" />
<add key="SmtpEnableSsl" value="true" />
</appSettings>
</configuration><configuration>
<appSettings>
<add key="SmtpHost" value="smtp.example.com" />
<add key="SmtpPort" value="587" />
<add key="SmtpUsername" value="admin@example.com" />
<add key="SmtpPassword" value="s3cret" />
<add key="SmtpEnableSsl" value="true" />
</appSettings>
</configuration>What Goes Wrong
- Typo in key:
ConfigurationManager.AppSettings["SmtpHots"]returnsnull. No error until theSmtpClientconstructor throws. - Missing key in config: Deploy without
SmtpEnableSslin production config.bool.Parse(null)throwsArgumentNullException. At 2 AM. - Type mismatch: Someone puts
"yes"instead of"true"forSmtpEnableSsl.bool.Parse("yes")throwsFormatException. - No grouping: All settings live in one flat
<appSettings>bag. SMTP settings mix with database settings mix with feature flags.
The developer carries the entire burden. Every key is a string. Every value is a string. Every cast is manual. Every missing key is a runtime surprise.
Era 2: Configuration — appsettings.json and IConfiguration
ASP.NET Core replaced the flat dictionary with a hierarchical JSON model and typed binding.
// appsettings.json
{
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"Username": "admin@example.com",
"Password": "s3cret",
"EnableSsl": true,
"TimeoutSeconds": 30,
"MaxRetries": 3
}
}// appsettings.json
{
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"Username": "admin@example.com",
"Password": "s3cret",
"EnableSsl": true,
"TimeoutSeconds": 30,
"MaxRetries": 3
}
}// Era 2: Configuration — typed binding via IConfiguration
public class SmtpOptions
{
public string Host { get; set; }
public int Port { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public bool EnableSsl { get; set; }
public int TimeoutSeconds { get; set; }
public int MaxRetries { get; set; }
}
// In Startup.cs / Program.cs
public class SmtpService
{
private readonly SmtpOptions _options;
public SmtpService(IConfiguration configuration)
{
_options = configuration.GetSection("Smtp").Get<SmtpOptions>();
}
public void Send(string to, string subject, string body)
{
using var client = new SmtpClient(_options.Host, _options.Port);
client.EnableSsl = _options.EnableSsl;
client.Credentials = new NetworkCredential(
_options.Username, _options.Password);
client.Send("noreply@example.com", to, subject, body);
}
}// Era 2: Configuration — typed binding via IConfiguration
public class SmtpOptions
{
public string Host { get; set; }
public int Port { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public bool EnableSsl { get; set; }
public int TimeoutSeconds { get; set; }
public int MaxRetries { get; set; }
}
// In Startup.cs / Program.cs
public class SmtpService
{
private readonly SmtpOptions _options;
public SmtpService(IConfiguration configuration)
{
_options = configuration.GetSection("Smtp").Get<SmtpOptions>();
}
public void Send(string to, string subject, string body)
{
using var client = new SmtpClient(_options.Host, _options.Port);
client.EnableSsl = _options.EnableSsl;
client.Credentials = new NetworkCredential(
_options.Username, _options.Password);
client.Send("noreply@example.com", to, subject, body);
}
}Progress and Remaining Problems
This is a genuine improvement. We have a typed class. We have hierarchical sections. We have JSON instead of XML. But:
- Silent defaults: Remove
"Port"from JSON.SmtpOptions.Portbecomes0. No error. Your SMTP client connects to port 0 and hangs. - Shape drift: Add a property
RetryDelayMstoSmtpOptionsbut forget to add it toappsettings.json. The property silently defaults to0. - Remove from JSON: Delete
"MaxRetries"from JSON. The property defaults to0. Your retry logic never retries. No warning anywhere. - Section name is a string:
GetSection("Smtp")is matched by string. Rename the class toEmailOptionsand forget to update the section name? Silent binding failure — every property gets its default value. - No validation:
Port = -1in JSON? Bound without complaint.Host = ""in JSON? Bound without complaint.
The binding is typed, but it is not validated, not enforced, and not checked at compile time. Every failure mode is silent.
Era 3: Convention — IOptions<T> with DataAnnotations
The Convention era adds IOptions<T>, DataAnnotations, and startup validation.
// Era 3: Convention — IOptions<T> with DataAnnotations and startup validation
public class SmtpOptions
{
public const string SectionName = "Smtp";
[Required(ErrorMessage = "SMTP host is required")]
public string Host { get; set; } = string.Empty;
[Range(1, 65535, ErrorMessage = "Port must be between 1 and 65535")]
public int Port { get; set; }
[Required(ErrorMessage = "SMTP username is required")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "SMTP password is required")]
public string Password { get; set; } = string.Empty;
public bool EnableSsl { get; set; } = true;
[Range(1, 300, ErrorMessage = "Timeout must be between 1 and 300 seconds")]
public int TimeoutSeconds { get; set; } = 30;
[Range(0, 10, ErrorMessage = "MaxRetries must be between 0 and 10")]
public int MaxRetries { get; set; } = 3;
}
// Registration in Program.cs
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// Usage via DI
public class SmtpService
{
private readonly SmtpOptions _options;
public SmtpService(IOptions<SmtpOptions> options)
{
_options = options.Value;
}
public void Send(string to, string subject, string body)
{
using var client = new SmtpClient(_options.Host, _options.Port);
client.EnableSsl = _options.EnableSsl;
client.Credentials = new NetworkCredential(
_options.Username, _options.Password);
// ...
}
}// Era 3: Convention — IOptions<T> with DataAnnotations and startup validation
public class SmtpOptions
{
public const string SectionName = "Smtp";
[Required(ErrorMessage = "SMTP host is required")]
public string Host { get; set; } = string.Empty;
[Range(1, 65535, ErrorMessage = "Port must be between 1 and 65535")]
public int Port { get; set; }
[Required(ErrorMessage = "SMTP username is required")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "SMTP password is required")]
public string Password { get; set; } = string.Empty;
public bool EnableSsl { get; set; } = true;
[Range(1, 300, ErrorMessage = "Timeout must be between 1 and 300 seconds")]
public int TimeoutSeconds { get; set; } = 30;
[Range(0, 10, ErrorMessage = "MaxRetries must be between 0 and 10")]
public int MaxRetries { get; set; } = 3;
}
// Registration in Program.cs
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// Usage via DI
public class SmtpService
{
private readonly SmtpOptions _options;
public SmtpService(IOptions<SmtpOptions> options)
{
_options = options.Value;
}
public void Send(string to, string subject, string body)
{
using var client = new SmtpClient(_options.Host, _options.Port);
client.EnableSsl = _options.EnableSsl;
client.Credentials = new NetworkCredential(
_options.Username, _options.Password);
// ...
}
}This Works — With Conventions
The ValidateOnStart() call means invalid configuration crashes the app at startup instead of at first use. That is a real improvement. But it depends on a stack of conventions that must be documented and enforced.
THE CONVENTION
"All options classes validated at startup. Section names match class names minus 'Options'. All required properties annotated with DataAnnotations. All registrations call .ValidateDataAnnotations().ValidateOnStart()."
THE DOCUMENTATION (~35 lines)
<!-- wiki/configuration-standards.md -->
## Configuration Management Standards
### Options Class Rules
1. All options classes MUST end with the suffix `Options` (e.g., `SmtpOptions`)
2. Section name = class name without `Options` suffix (e.g., `SmtpOptions` → `"Smtp"`)
3. Each options class MUST define `public const string SectionName`
4. Section name MUST match the JSON path in `appsettings.json`
### Validation Rules
5. All required properties MUST have `[Required]` attribute
6. All numeric properties MUST have `[Range]` with documented bounds
7. String properties with format constraints MUST use `[RegularExpression]`
8. All registrations MUST chain `.ValidateDataAnnotations().ValidateOnStart()`
### Environment Override Rules
9. Use `__` (double underscore) for section separators in env vars
10. Sensitive values (passwords, keys) MUST use env vars, not JSON
11. Per-environment overrides go in `appsettings.{Environment}.json`
### Naming Conventions
12. Boolean properties: prefix with `Enable`, `Is`, `Allow`, or `Use`
13. Timeout properties: suffix with `Seconds` or `Milliseconds` (never ambiguous)
14. Connection strings: use `ConnectionStrings` section, not custom options<!-- wiki/configuration-standards.md -->
## Configuration Management Standards
### Options Class Rules
1. All options classes MUST end with the suffix `Options` (e.g., `SmtpOptions`)
2. Section name = class name without `Options` suffix (e.g., `SmtpOptions` → `"Smtp"`)
3. Each options class MUST define `public const string SectionName`
4. Section name MUST match the JSON path in `appsettings.json`
### Validation Rules
5. All required properties MUST have `[Required]` attribute
6. All numeric properties MUST have `[Range]` with documented bounds
7. String properties with format constraints MUST use `[RegularExpression]`
8. All registrations MUST chain `.ValidateDataAnnotations().ValidateOnStart()`
### Environment Override Rules
9. Use `__` (double underscore) for section separators in env vars
10. Sensitive values (passwords, keys) MUST use env vars, not JSON
11. Per-environment overrides go in `appsettings.{Environment}.json`
### Naming Conventions
12. Boolean properties: prefix with `Enable`, `Is`, `Allow`, or `Use`
13. Timeout properties: suffix with `Seconds` or `Milliseconds` (never ambiguous)
14. Connection strings: use `ConnectionStrings` section, not custom optionsEvery new developer must read this. Every code reviewer must remember it. And the wiki will diverge from the codebase within three months — because someone will register an options class without ValidateOnStart(), and the wiki won't know.
THE ENFORCEMENT CODE (~40 lines)
// test/ConfigurationConventionTests.cs
public class ConfigurationConventionTests
{
[Fact]
public void All_options_classes_have_SectionName_constant()
{
var optionsTypes = typeof(SmtpOptions).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("Options") && !t.IsAbstract);
foreach (var type in optionsTypes)
{
var field = type.GetField("SectionName",
BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(field);
Assert.Equal(typeof(string), field.FieldType);
}
}
[Fact]
public void All_options_registrations_validate_on_start()
{
var services = new ServiceCollection();
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();
// Replay the real registration
Program.ConfigureOptions(services, config);
// Check that every IValidateOptions<T> is registered
var optionsTypes = typeof(SmtpOptions).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("Options") && !t.IsAbstract);
foreach (var type in optionsTypes)
{
var validatorType = typeof(IValidateOptions<>).MakeGenericType(type);
var descriptor = services.FirstOrDefault(
d => d.ServiceType == validatorType);
Assert.NotNull(descriptor);
}
}
}// test/ConfigurationConventionTests.cs
public class ConfigurationConventionTests
{
[Fact]
public void All_options_classes_have_SectionName_constant()
{
var optionsTypes = typeof(SmtpOptions).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("Options") && !t.IsAbstract);
foreach (var type in optionsTypes)
{
var field = type.GetField("SectionName",
BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(field);
Assert.Equal(typeof(string), field.FieldType);
}
}
[Fact]
public void All_options_registrations_validate_on_start()
{
var services = new ServiceCollection();
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();
// Replay the real registration
Program.ConfigureOptions(services, config);
// Check that every IValidateOptions<T> is registered
var optionsTypes = typeof(SmtpOptions).Assembly.GetTypes()
.Where(t => t.Name.EndsWith("Options") && !t.IsAbstract);
foreach (var type in optionsTypes)
{
var validatorType = typeof(IValidateOptions<>).MakeGenericType(type);
var descriptor = services.FirstOrDefault(
d => d.ServiceType == validatorType);
Assert.NotNull(descriptor);
}
}
}The Problem
That is 35 lines of documentation + 40 lines of enforcement code = 75 lines of convention overhead. And the tests run after the code is written. If a developer adds DatabaseOptions and forgets .ValidateOnStart(), the test fails in CI — not in the IDE. They push, wait for CI, read the test failure, fix, push again. Twenty minutes lost to enforce something the compiler could have caught.
And there are failure modes the tests cannot catch:
- The
SectionNameconstant says"Smtp"but the JSON file has"SmtpSettings". The binding succeeds — with all default values. No test catches this unless you also parse the JSON and match section names. - A property is
intin C# but"abc"in JSON. The binding silently returns0. No DataAnnotation catches a binding failure — only missing or out-of-range values after successful binding. - The JSON has a section
"LegacyAuth"that no options class binds to. Dead configuration. No test notices.
Era 4: Contention — [StronglyTypedOptions] Source Generation
Contention eliminates the convention by generating the binder, the validator, and the analyzer diagnostics from a single attribute.
// Era 4: Contention — attribute-driven options with SG + analyzer
[StronglyTypedOptions("Smtp")]
public partial record SmtpOptions
{
[Required]
public required string Host { get; init; }
[Range(1, 65535)]
public required int Port { get; init; }
[Required]
public required string Username { get; init; }
[Required, Sensitive]
public required string Password { get; init; }
public bool EnableSsl { get; init; } = true;
[Range(1, 300)]
public int TimeoutSeconds { get; init; } = 30;
[Range(0, 10)]
public int MaxRetries { get; init; } = 3;
}// Era 4: Contention — attribute-driven options with SG + analyzer
[StronglyTypedOptions("Smtp")]
public partial record SmtpOptions
{
[Required]
public required string Host { get; init; }
[Range(1, 65535)]
public required int Port { get; init; }
[Required]
public required string Username { get; init; }
[Required, Sensitive]
public required string Password { get; init; }
public bool EnableSsl { get; init; } = true;
[Range(1, 300)]
public int TimeoutSeconds { get; init; } = 30;
[Range(0, 10)]
public int MaxRetries { get; init; } = 3;
}That is the entire developer-authored code. One attribute. One record. Constraints on properties.
What the Source Generator Produces
The SG emits three files:
1. The binder extension method:
// Generated: SmtpOptionsRegistration.g.cs
public static partial class OptionsRegistrationExtensions
{
public static IServiceCollection AddSmtpOptions(
this IServiceCollection services,
IConfiguration configuration)
{
services
.AddOptions<SmtpOptions>()
.Bind(configuration.GetSection("Smtp"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<IValidateOptions<SmtpOptions>,
SmtpOptionsValidator>();
return services;
}
}// Generated: SmtpOptionsRegistration.g.cs
public static partial class OptionsRegistrationExtensions
{
public static IServiceCollection AddSmtpOptions(
this IServiceCollection services,
IConfiguration configuration)
{
services
.AddOptions<SmtpOptions>()
.Bind(configuration.GetSection("Smtp"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<IValidateOptions<SmtpOptions>,
SmtpOptionsValidator>();
return services;
}
}2. The validator with binding verification:
// Generated: SmtpOptionsValidator.g.cs
public class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
private readonly IConfiguration _configuration;
public SmtpOptionsValidator(IConfiguration configuration)
{
_configuration = configuration;
}
public ValidateOptionsResult Validate(string? name, SmtpOptions options)
{
var failures = new List<string>();
var section = _configuration.GetSection("Smtp");
// Verify section exists
if (!section.Exists())
{
failures.Add(
"Configuration section 'Smtp' is missing from " +
"appsettings.json. SmtpOptions cannot be bound.");
return ValidateOptionsResult.Fail(failures);
}
// Verify required properties were actually bound (not just defaults)
if (!section.GetSection("Host").Exists())
failures.Add("Required property 'Host' is missing from " +
"configuration section 'Smtp'.");
if (!section.GetSection("Port").Exists())
failures.Add("Required property 'Port' is missing from " +
"configuration section 'Smtp'.");
if (!section.GetSection("Username").Exists())
failures.Add("Required property 'Username' is missing from " +
"configuration section 'Smtp'.");
if (!section.GetSection("Password").Exists())
failures.Add("Required property 'Password' is missing from " +
"configuration section 'Smtp'.");
// DataAnnotation validation
if (string.IsNullOrWhiteSpace(options.Host))
failures.Add("SmtpOptions.Host is required.");
if (options.Port < 1 || options.Port > 65535)
failures.Add("SmtpOptions.Port must be between 1 and 65535.");
if (string.IsNullOrWhiteSpace(options.Username))
failures.Add("SmtpOptions.Username is required.");
if (string.IsNullOrWhiteSpace(options.Password))
failures.Add("SmtpOptions.Password is required.");
if (options.TimeoutSeconds < 1 || options.TimeoutSeconds > 300)
failures.Add(
"SmtpOptions.TimeoutSeconds must be between 1 and 300.");
if (options.MaxRetries < 0 || options.MaxRetries > 10)
failures.Add(
"SmtpOptions.MaxRetries must be between 0 and 10.");
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}// Generated: SmtpOptionsValidator.g.cs
public class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
private readonly IConfiguration _configuration;
public SmtpOptionsValidator(IConfiguration configuration)
{
_configuration = configuration;
}
public ValidateOptionsResult Validate(string? name, SmtpOptions options)
{
var failures = new List<string>();
var section = _configuration.GetSection("Smtp");
// Verify section exists
if (!section.Exists())
{
failures.Add(
"Configuration section 'Smtp' is missing from " +
"appsettings.json. SmtpOptions cannot be bound.");
return ValidateOptionsResult.Fail(failures);
}
// Verify required properties were actually bound (not just defaults)
if (!section.GetSection("Host").Exists())
failures.Add("Required property 'Host' is missing from " +
"configuration section 'Smtp'.");
if (!section.GetSection("Port").Exists())
failures.Add("Required property 'Port' is missing from " +
"configuration section 'Smtp'.");
if (!section.GetSection("Username").Exists())
failures.Add("Required property 'Username' is missing from " +
"configuration section 'Smtp'.");
if (!section.GetSection("Password").Exists())
failures.Add("Required property 'Password' is missing from " +
"configuration section 'Smtp'.");
// DataAnnotation validation
if (string.IsNullOrWhiteSpace(options.Host))
failures.Add("SmtpOptions.Host is required.");
if (options.Port < 1 || options.Port > 65535)
failures.Add("SmtpOptions.Port must be between 1 and 65535.");
if (string.IsNullOrWhiteSpace(options.Username))
failures.Add("SmtpOptions.Username is required.");
if (string.IsNullOrWhiteSpace(options.Password))
failures.Add("SmtpOptions.Password is required.");
if (options.TimeoutSeconds < 1 || options.TimeoutSeconds > 300)
failures.Add(
"SmtpOptions.TimeoutSeconds must be between 1 and 300.");
if (options.MaxRetries < 0 || options.MaxRetries > 10)
failures.Add(
"SmtpOptions.MaxRetries must be between 0 and 10.");
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}3. A centralized registration entry point:
// Generated: AddAllStronglyTypedOptions.g.cs
public static partial class OptionsRegistrationExtensions
{
public static IServiceCollection AddAllStronglyTypedOptions(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddSmtpOptions(configuration);
services.AddDatabaseOptions(configuration);
services.AddCachingOptions(configuration);
services.AddAuthOptions(configuration);
// ... every [StronglyTypedOptions] class in the assembly
return services;
}
}// Generated: AddAllStronglyTypedOptions.g.cs
public static partial class OptionsRegistrationExtensions
{
public static IServiceCollection AddAllStronglyTypedOptions(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddSmtpOptions(configuration);
services.AddDatabaseOptions(configuration);
services.AddCachingOptions(configuration);
services.AddAuthOptions(configuration);
// ... every [StronglyTypedOptions] class in the assembly
return services;
}
}Registration in Program.cs becomes one line:
builder.Services.AddAllStronglyTypedOptions(builder.Configuration);builder.Services.AddAllStronglyTypedOptions(builder.Configuration);No manual registration per options class. No chance of forgetting .ValidateOnStart(). No convention to enforce.
What the Analyzers Enforce
The companion analyzer package produces four diagnostics:
// OPT001: Options class lacks [StronglyTypedOptions]
// Severity: Warning
// Trigger: Class name ends with "Options", is not abstract, and has no
// [StronglyTypedOptions] attribute.
warning OPT001: Type 'RateLimitOptions' follows the options naming convention
(name ends with 'Options') but is not marked
[StronglyTypedOptions]. Add the attribute to generate
registration, binding, and validation code. If this is not
an options class, rename it to avoid the 'Options' suffix.
(RateLimitOptions.cs, line 3)// OPT001: Options class lacks [StronglyTypedOptions]
// Severity: Warning
// Trigger: Class name ends with "Options", is not abstract, and has no
// [StronglyTypedOptions] attribute.
warning OPT001: Type 'RateLimitOptions' follows the options naming convention
(name ends with 'Options') but is not marked
[StronglyTypedOptions]. Add the attribute to generate
registration, binding, and validation code. If this is not
an options class, rename it to avoid the 'Options' suffix.
(RateLimitOptions.cs, line 3)// OPT002: Required property missing from appsettings.json
// Severity: Warning
// Trigger: A [StronglyTypedOptions("X")] class has a 'required' property,
// but appsettings.json section "X" lacks the corresponding key.
warning OPT002: Property 'SmtpOptions.Host' is marked 'required' but
section 'Smtp' in 'appsettings.json' does not contain
key 'Host'. The application will fail at startup with
a validation error. Add the key to appsettings.json
or provide it via environment variable 'Smtp__Host'.
(SmtpOptions.cs, line 6)// OPT002: Required property missing from appsettings.json
// Severity: Warning
// Trigger: A [StronglyTypedOptions("X")] class has a 'required' property,
// but appsettings.json section "X" lacks the corresponding key.
warning OPT002: Property 'SmtpOptions.Host' is marked 'required' but
section 'Smtp' in 'appsettings.json' does not contain
key 'Host'. The application will fail at startup with
a validation error. Add the key to appsettings.json
or provide it via environment variable 'Smtp__Host'.
(SmtpOptions.cs, line 6)// OPT003: Type mismatch between JSON and C# property
// Severity: Error
// Trigger: The JSON value for a key cannot be parsed as the C# property type.
error OPT003: Property 'SmtpOptions.Port' is 'int' but the value in
appsettings.json section 'Smtp.Port' is '"abc"' (string).
Configuration binding will silently produce default(int) = 0.
Fix the value in appsettings.json to be a valid integer.
(SmtpOptions.cs, line 9)// OPT003: Type mismatch between JSON and C# property
// Severity: Error
// Trigger: The JSON value for a key cannot be parsed as the C# property type.
error OPT003: Property 'SmtpOptions.Port' is 'int' but the value in
appsettings.json section 'Smtp.Port' is '"abc"' (string).
Configuration binding will silently produce default(int) = 0.
Fix the value in appsettings.json to be a valid integer.
(SmtpOptions.cs, line 9)// OPT004: Orphaned section in appsettings.json
// Severity: Info
// Trigger: appsettings.json contains a section that no [StronglyTypedOptions]
// class binds to, and the section is not in a known system list
// (Logging, AllowedHosts, ConnectionStrings, Kestrel).
info OPT004: Section 'LegacyAuth' in appsettings.json is not bound by
any [StronglyTypedOptions] class. This may be dead
configuration. Remove it or create a corresponding
options class.
(appsettings.json)// OPT004: Orphaned section in appsettings.json
// Severity: Info
// Trigger: appsettings.json contains a section that no [StronglyTypedOptions]
// class binds to, and the section is not in a known system list
// (Logging, AllowedHosts, ConnectionStrings, Kestrel).
info OPT004: Section 'LegacyAuth' in appsettings.json is not bound by
any [StronglyTypedOptions] class. This may be dead
configuration. Remove it or create a corresponding
options class.
(appsettings.json)What This Catches That Convention Cannot
Consider the silent binding failure scenario from Era 3. The JSON has "SmtpSettings" but the class says [StronglyTypedOptions("Smtp")]. The analyzer reads appsettings.json at compile time, finds no "Smtp" section, and produces OPT002 warnings for every required property. The developer sees the error in their IDE before they even run the application.
Consider the type mismatch scenario. "Port": "abc" in JSON. Era 3 binds this to int Port and silently gets 0, which then fails the [Range(1, 65535)] validation at startup — with an unhelpful message like "Port must be between 1 and 65535." The developer has to figure out why Port is 0. With OPT003, the error says exactly what happened: the JSON value is a string, the property is an int, and binding will produce a silent default.
Consider the orphaned configuration scenario. Era 3 has no mechanism to detect "LegacyAuth" sitting in appsettings.json after the code that used it was deleted. OPT004 flags it as informational — not an error, but a signal that configuration is drifting from the codebase.
Sensitive Value Handling
The [Sensitive] attribute on Password triggers additional generated behavior:
// Generated: SmtpOptions includes ToString override
public partial record SmtpOptions
{
public override string ToString()
{
return $"SmtpOptions {{ Host = {Host}, Port = {Port}, " +
$"Username = {Username}, Password = [REDACTED], " +
$"EnableSsl = {EnableSsl}, TimeoutSeconds = {TimeoutSeconds}, " +
$"MaxRetries = {MaxRetries} }}";
}
}// Generated: SmtpOptions includes ToString override
public partial record SmtpOptions
{
public override string ToString()
{
return $"SmtpOptions {{ Host = {Host}, Port = {Port}, " +
$"Username = {Username}, Password = [REDACTED], " +
$"EnableSsl = {EnableSsl}, TimeoutSeconds = {TimeoutSeconds}, " +
$"MaxRetries = {MaxRetries} }}";
}
}And an additional analyzer:
// OPT005: Sensitive property has value in appsettings.json
// Severity: Warning
// Trigger: A property marked [Sensitive] has a non-placeholder value
// in appsettings.json.
warning OPT005: Property 'SmtpOptions.Password' is marked [Sensitive]
but has value 's3cret' in appsettings.json.
Sensitive values should be provided via environment
variables (Smtp__Password) or a secrets manager,
not committed to source control.
(SmtpOptions.cs, line 14)// OPT005: Sensitive property has value in appsettings.json
// Severity: Warning
// Trigger: A property marked [Sensitive] has a non-placeholder value
// in appsettings.json.
warning OPT005: Property 'SmtpOptions.Password' is marked [Sensitive]
but has value 's3cret' in appsettings.json.
Sensitive values should be provided via environment
variables (Smtp__Password) or a secrets manager,
not committed to source control.
(SmtpOptions.cs, line 14)The convention-era wiki says "sensitive values should use environment variables." The Contention-era analyzer enforces it at compile time.
The Convention Tax — Measured
| Artifact | Convention (Era 3) | Contention (Era 4) |
|---|---|---|
| Options class definition | Manual with DataAnnotations | Same, plus [StronglyTypedOptions] |
| Section name constant | Manual const string per class |
Attribute parameter ("Smtp") |
| DI registration | Manual per class with 4 chained calls | Generated AddAllStronglyTypedOptions() |
| Startup validation wiring | Manual .ValidateOnStart() per class |
Generated automatically |
| Custom validator (binding checks) | Not done — silent binding failures | Generated IValidateOptions<T> |
| Wiki / documentation | 35 lines | 0 — attribute IS the documentation |
| Enforcement tests | 40 lines across 2 tests | 0 — OPT001-OPT005 at compile time |
| Missing section detection | Not caught (silent default binding) | OPT002 at compile time |
| Type mismatch detection | Not caught (silent default value) | OPT003 at compile time |
| Orphaned config detection | Not caught | OPT004 at compile time |
| Sensitive value leakage | Wiki says "use env vars" | OPT005 at compile time |
ToString() redaction |
Manual per class (if at all) | Generated from [Sensitive] |
| Total overhead | 75 lines + silent failure modes | 0 lines |
| Catches missing config | Startup crash (if ValidateOnStart used) | Compile warning (OPT002) |
| Catches type mismatch | Never (silent default) | Compile error (OPT003) |
| New options class workflow | Create class, add annotations, register in DI with 4 calls, verify wiki | Create record with [StronglyTypedOptions]. Build. Done. |
When Does a Missing Configuration Value Get Caught?
Each era moves detection earlier. But only Contention catches the problem before the code runs at all:
- Era 1: Runtime crash — might be in production, might be at 2 AM
- Era 2: Never caught — silent wrong behavior is worse than a crash
- Era 3: Startup crash — better, but still requires running the application
- Era 4: Compile warning — caught in the IDE, before the developer even presses F5
What the Convention Era Gets Right
Convention is not wrong here. IOptions<T> with ValidateDataAnnotations() and ValidateOnStart() is one of the best things in ASP.NET Core. It moved configuration validation from "first use" to "startup" — a massive improvement over the silent-default era.
The problem is not the mechanism. The problem is the gap between the mechanism and the compiler. ValidateOnStart() is a method call. If you forget to call it, the validation doesn't happen. [Required] is an attribute. If you forget to add it, the property silently accepts null. The section name is a string. If it doesn't match the JSON, binding silently fails.
Every one of these gaps is filled by a convention. And every convention costs documentation and enforcement.
Contention closes every gap:
[StronglyTypedOptions("Smtp")]generates the registration withValidateOnStart()baked in. You cannot forget it.requiredkeyword on properties generates binding-existence checks. You cannot skip validation.- The analyzer reads
appsettings.jsonand verifies section names and property types at compile time. The JSON cannot drift from the code.
The irony is complete. The "Configuration" in "Convention over Configuration" — the very thing the pattern was supposed to simplify — is finally simplified. Not by Convention, but by Contention. Not by naming rules, but by an attribute that tells the compiler exactly what it needs to know.
The Philosophy
Configuration is perhaps the purest example of why Don't Put the Burden on Developers matters. Every era adds more ceremony that the developer must remember:
- Era 1: Remember the string key
- Era 2: Remember to match the section name
- Era 3: Remember to add
[Required], remember to call.ValidateOnStart(), remember to read the wiki - Era 4: Add one attribute. The compiler remembers everything else.
The burden does not decrease with experience. A senior developer who has written 200 options classes still has to remember .ValidateOnStart() on the 201st. The convention test catches the mistake — after the code is pushed, after CI runs, after the developer context-switches to something else. Twenty minutes of interrupt-driven correction for something the compiler could have caught immediately.
Contention makes the cost of doing it right equal to the cost of doing it at all. Add [StronglyTypedOptions], put constraints on properties, and the SG produces the correct registration, the correct validator, and the correct binding code. There is no wrong way to use it — short of removing the attribute, which the analyzer will flag.
That is the pattern. Not "remember all the rules." Not "read the wiki." Not "wait for the test to tell you." Just: tell the compiler what you mean, and let it handle the rest.
Next: Part IX: Error Handling and Result Types — where the convention "always pattern-match your Results" has no enforcement mechanism at all, and the only thing preventing unhandled error variants from reaching production is the discipline of individual developers during code review.