Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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);
    }
}

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>

What Goes Wrong

  1. Typo in key: ConfigurationManager.AppSettings["SmtpHots"] returns null. No error until the SmtpClient constructor throws.
  2. Missing key in config: Deploy without SmtpEnableSsl in production config. bool.Parse(null) throws ArgumentNullException. At 2 AM.
  3. Type mismatch: Someone puts "yes" instead of "true" for SmtpEnableSsl. bool.Parse("yes") throws FormatException.
  4. 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
  }
}
// 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:

  1. Silent defaults: Remove "Port" from JSON. SmtpOptions.Port becomes 0. No error. Your SMTP client connects to port 0 and hangs.
  2. Shape drift: Add a property RetryDelayMs to SmtpOptions but forget to add it to appsettings.json. The property silently defaults to 0.
  3. Remove from JSON: Delete "MaxRetries" from JSON. The property defaults to 0. Your retry logic never retries. No warning anywhere.
  4. Section name is a string: GetSection("Smtp") is matched by string. Rename the class to EmailOptions and forget to update the section name? Silent binding failure — every property gets its default value.
  5. No validation: Port = -1 in 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);
        // ...
    }
}

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

Every 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);
        }
    }
}

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 SectionName constant 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 int in C# but "abc" in JSON. The binding silently returns 0. 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;
}

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;
    }
}

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;
    }
}

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;
    }
}

Registration in Program.cs becomes one line:

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)
// 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)
// 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} }}";
    }
}

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)

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?

Diagram

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 with ValidateOnStart() baked in. You cannot forget it.
  • required keyword on properties generates binding-existence checks. You cannot skip validation.
  • The analyzer reads appsettings.json and 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.