← Back to all posts

Options Pattern - V2

Options Pattern - V2

Reading Time: 6 minutes

Why a V2?

The baseline Options Pattern is great for single-tenant apps with one configuration shape. But in real systems, requirements often include:

  • Multiple providers (for example, SendGrid, Smtp, MailKit) in the same app
  • Per-tenant settings with tenant-specific overrides
  • Runtime updates without restarts
  • Strong validation and safe defaults across all variants

This post dives into advanced patterns to solve those scenarios cleanly.

1. Named Options: Multiple Config Variants of the Same Type

Named options let you keep one options type and bind multiple named instances.

Example use case

You may need two email pipelines:

  • Transactional email (password reset, verification)
  • Marketing email (campaigns, digests)

Both share the same shape, but values differ.

Options model

public sealed class EmailOptions
{
    public string Provider { get; set; } = "SendGrid";
    public string ApiKey { get; set; } = string.Empty;
    public string FromAddress { get; set; } = string.Empty;
    public int TimeoutSeconds { get; set; } = 30;
}

appsettings.json

{
  "Email": {
    "Transactional": {
      "Provider": "SendGrid",
      "ApiKey": "txn-key",
      "FromAddress": "[email protected]",
      "TimeoutSeconds": 15
    },
    "Marketing": {
      "Provider": "SendGrid",
      "ApiKey": "mkt-key",
      "FromAddress": "[email protected]",
      "TimeoutSeconds": 60
    }
  }
}

Registration

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddOptions<EmailOptions>("Transactional")
    .Bind(builder.Configuration.GetSection("Email:Transactional"))
    .Validate(o => !string.IsNullOrWhiteSpace(o.ApiKey), "Transactional ApiKey is required")
    .ValidateOnStart();

builder.Services
    .AddOptions<EmailOptions>("Marketing")
    .Bind(builder.Configuration.GetSection("Email:Marketing"))
    .Validate(o => !string.IsNullOrWhiteSpace(o.ApiKey), "Marketing ApiKey is required")
    .ValidateOnStart();

Consumption with IOptionsSnapshot

public sealed class CampaignService
{
    private readonly IOptionsSnapshot<EmailOptions> _email;

    public CampaignService(IOptionsSnapshot<EmailOptions> email)
    {
        _email = email;
    }

    public Task SendDigestAsync()
    {
        var options = _email.Get("Marketing");
        // Use Marketing config
        return Task.CompletedTask;
    }

    public Task SendPasswordResetAsync()
    {
        var options = _email.Get("Transactional");
        // Use Transactional config
        return Task.CompletedTask;
    }
}

Named options tips

  • Use constants for names (public const string Transactional = "Transactional") to avoid string typos.
  • Keep names business-oriented, not technical.
  • Validate each named option independently.
  • Prefer IOptionsSnapshot<T> in request pipelines and IOptionsMonitor<T> in long-running services.

2. PostConfigure and ConfigureAll for Safe Defaults

As systems evolve, configuration sections can become inconsistent across environments.

Use PostConfigure and ConfigureAll to enforce global rules.

builder.Services.ConfigureAll<EmailOptions>(o =>
{
    if (o.TimeoutSeconds <= 0)
    {
        o.TimeoutSeconds = 30;
    }
});

builder.Services.PostConfigure<EmailOptions>("Marketing", o =>
{
    // Example: enforce a longer timeout for marketing provider calls
    if (o.TimeoutSeconds < 30)
    {
        o.TimeoutSeconds = 30;
    }
});

This helps harden behavior without duplicating logic in every caller.

3. Multi-Tenant Configuration Binding

Named options solve “many variants” globally. Multi-tenant systems add a harder dimension: per-tenant config selection at runtime.

Typical requirement

Each tenant can override:

  • API keys
  • Feature flags
  • throttling limits
  • provider selection

while still inheriting shared defaults.

4. Pattern A: Tenant Dictionary + Resolver Service (Simple, Effective)

Bind all tenant configs once and resolve per request using current tenant context.

appsettings.json

{
  "Tenants": {
    "acme": {
      "Email": {
        "Provider": "SendGrid",
        "ApiKey": "acme-key",
        "FromAddress": "[email protected]",
        "TimeoutSeconds": 20
      }
    },
    "globex": {
      "Email": {
        "Provider": "Smtp",
        "ApiKey": "globex-secret",
        "FromAddress": "[email protected]",
        "TimeoutSeconds": 45
      }
    }
  }
}

Models

public sealed class TenantCatalogOptions
{
    public Dictionary<string, TenantConfig> Tenants { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

public sealed class TenantConfig
{
    public EmailOptions Email { get; set; } = new();
}

Registration

builder.Services
    .AddOptions<TenantCatalogOptions>()
    .Bind(builder.Configuration)
    .Validate(o => o.Tenants.Count > 0, "At least one tenant must be configured")
    .ValidateOnStart();

Tenant context abstraction

public interface ITenantContextAccessor
{
    string TenantId { get; }
}

Resolver

public interface ITenantEmailOptionsResolver
{
    EmailOptions GetCurrentTenantEmailOptions();
}

public sealed class TenantEmailOptionsResolver : ITenantEmailOptionsResolver
{
    private readonly ITenantContextAccessor _tenantContext;
    private readonly IOptionsSnapshot<TenantCatalogOptions> _catalog;

    public TenantEmailOptionsResolver(
        ITenantContextAccessor tenantContext,
        IOptionsSnapshot<TenantCatalogOptions> catalog)
    {
        _tenantContext = tenantContext;
        _catalog = catalog;
    }

    public EmailOptions GetCurrentTenantEmailOptions()
    {
        var tenantId = _tenantContext.TenantId;

        if (!_catalog.Value.Tenants.TryGetValue(tenantId, out var tenantConfig))
        {
            throw new InvalidOperationException($"No Email configuration found for tenant '{tenantId}'.");
        }

        return tenantConfig.Email;
    }
}

This pattern is explicit and test-friendly. For many applications, it is enough.

5. Pattern B: Tenant-Aware Named Options (Advanced)

You can combine tenant identity with named options using a name format like:

  • "tenant:acme"
  • "tenant:globex"

Then resolve via IOptionsMonitor<EmailOptions>.Get(name).

This pattern is powerful when:

  • You already use named options heavily
  • You want one unified retrieval model
  • You need monitor-based updates in background workers

But it introduces complexity around naming, invalidation, and fallback behavior. If your team is not already comfortable with advanced options internals, Pattern A is usually safer.

6. Fallback Composition: Global Defaults + Tenant Overrides

A common production strategy:

  1. Bind global defaults (EmailDefaults)
  2. Bind tenant partial overrides
  3. Merge at runtime

Example merge logic:

public static EmailOptions Merge(EmailOptions defaults, EmailOptions tenant)
{
    return new EmailOptions
    {
        Provider = string.IsNullOrWhiteSpace(tenant.Provider) ? defaults.Provider : tenant.Provider,
        ApiKey = string.IsNullOrWhiteSpace(tenant.ApiKey) ? defaults.ApiKey : tenant.ApiKey,
        FromAddress = string.IsNullOrWhiteSpace(tenant.FromAddress) ? defaults.FromAddress : tenant.FromAddress,
        TimeoutSeconds = tenant.TimeoutSeconds <= 0 ? defaults.TimeoutSeconds : tenant.TimeoutSeconds
    };
}

This avoids duplicating every setting for every tenant and reduces config drift.

7. Validation Strategy for Multi-Tenant Options

Do not validate only the root object. Validate each tenant entry.

builder.Services
    .AddOptions<TenantCatalogOptions>()
    .Bind(builder.Configuration)
    .Validate(o => o.Tenants.All(kvp =>
        !string.IsNullOrWhiteSpace(kvp.Key) &&
        !string.IsNullOrWhiteSpace(kvp.Value.Email.ApiKey) &&
        !string.IsNullOrWhiteSpace(kvp.Value.Email.FromAddress)),
        "Each tenant must define valid Email settings")
    .ValidateOnStart();

For complex rules, create IValidateOptions<T> implementations so validation stays maintainable.

8. Tenant Isolation and Security Considerations

When resolving tenant options:

  • Never allow a request to specify an arbitrary tenant name without authorization
  • Derive tenant identity from trusted context (domain, token claims, mTLS, gateway header policy)
  • Avoid logging raw secrets from options objects
  • Keep secrets in a secure store (for example, Key Vault) and load references or secret names in config

9. Testing Patterns

Unit tests

  • Mock ITenantContextAccessor to simulate tenant identity
  • Provide in-memory IOptionsSnapshot<TenantCatalogOptions> values
  • Assert fallback and validation behavior

Integration tests

  • Run with multiple tenants configured
  • Verify request A cannot read tenant B settings
  • Verify startup fails on invalid tenant config

10. When to Use Which Pattern

  • Single tenant, one config shape: regular IOptions<T>
  • Multiple variants, no tenant dimension: named options
  • Per-tenant settings with moderate complexity: tenant dictionary + resolver (Pattern A)
  • Highly dynamic runtime config with advanced team maturity: tenant-aware named options + monitor

Summary

Named options and multi-tenant binding are natural extensions of the Options Pattern once your architecture scales.

The most important production principles are:

  • Keep option models small and focused
  • Validate aggressively at startup
  • Make tenant resolution explicit and trusted
  • Prefer simple resolver patterns unless complexity is justified

Mastering these patterns lets you scale one codebase across many environments, providers, and tenants without turning configuration into technical debt.

References