Skip to content

Architecture and Core Concepts

This guide explains why Pragmatic.Configuration exists, how its two pillars work (compile-time binding and runtime stores), and how the pieces compose into a complete configuration system. Read this before the individual feature guides.


Configuration management in .NET starts simple and grows painful. Each options class requires the same boilerplate. Secrets are handled differently in each environment. Dynamic configuration changes require custom plumbing. Multi-tenant overrides are an afterthought.

Every IOptions<T> class requires identical registration code:

// BookingOptions -- 3 properties, 12 lines of wiring
services.AddOptions<BookingOptions>()
.Bind(configuration.GetSection("Booking"))
.ValidateDataAnnotations()
.ValidateOnStart();
// PaymentOptions -- same pattern, different names
services.AddOptions<PaymentOptions>()
.Bind(configuration.GetSection("Payment"))
.ValidateDataAnnotations()
.ValidateOnStart();
// NotificationOptions -- and again...
services.AddOptions<NotificationOptions>()
.Bind(configuration.GetSection("Notification"))
.ValidateDataAnnotations()
.ValidateOnStart();

Three options classes, 36 lines of identical structure. Add validation attributes and it grows further. Now multiply by 20 options classes in a real application.

Validation is opt-in and easy to forget:

// Forgot .ValidateDataAnnotations() -- [Required] attributes are ignored
services.AddOptions<PaymentOptions>()
.Bind(configuration.GetSection("Payment"));
// App starts with empty PaymentGatewayUrl, fails at runtime
// Development: user-secrets
// Staging: environment variables
// Production: Azure Key Vault
// Each environment has different secret access patterns
// No unified API, no tenant isolation, no encryption at rest for DB-backed configs

Changing a config value requires a deployment. Runtime changes (A/B testing, feature rollouts, emergency overrides) need custom infrastructure that is rarely built.

// Typical approach: hardcoded switch
var maxGuests = tenantId switch
{
"acme" => 200,
"contoso" => 50,
_ => config.MaxGuests // default
};
// Not scalable, not auditable, not dynamic
ProblemImpact
Boilerplate per options classDeveloper fatigue, copy-paste errors
Forgotten validationRuntime failures from invalid config
No unified secrets APIDifferent code paths per environment
Static configurationDeployments for every config change
No tenant isolationHard-coded overrides, no audit trail
Reflection-based bindingAOT/trimming incompatibility

Pragmatic.Configuration solves this with two pillars:

Pillar 1: Compile-time binding (Source Generator)

Section titled “Pillar 1: Compile-time binding (Source Generator)”

Annotate a class with [Configuration]. The source generator produces all the boilerplate:

[Configuration]
public partial class BookingOptions
{
[Required]
[MaxLength(100)]
public string HotelName { get; set; } = "";
[Range(1, 100)]
public int MaxGuests { get; set; } = 10;
}

The SG generates:

  • Per-type extension: AddBookingOptions(services, configuration) that calls Bind, ValidateDataAnnotations, and ValidateOnStart.
  • Per-assembly aggregator: Add{Prefix}Configuration(services, configuration) that calls every individual Add*Options method.
  • Section path inference: BookingOptions binds to "Booking" (removes Options suffix).

One line in your startup registers everything:

services.AddMyAppConfiguration(configuration);

No boilerplate. No forgotten validation. No reflection at runtime (the binding code is source-generated).

Pillar 2: Runtime stores (Dynamic configuration)

Section titled “Pillar 2: Runtime stores (Dynamic configuration)”

A pluggable store architecture for dynamic values, cascade resolution, and hot-reload:

services.AddPragmaticConfiguration(options =>
{
options.EnvironmentTag = "eu-west";
options.MultiTenant.Enabled = true;
});

This registers IConfigurationStore and ISecretStore with cascade resolution (base -> environment -> tenant) and bridges into Microsoft’s IOptionsMonitor<T> for hot-reload.


Compile-time (SG) Runtime (Stores)
================== ==================
[Configuration] attribute IConfigurationStore
| ISecretStore
v |
Source Generator v
| ConfigurationResolver
v (cascade: base -> env -> tenant)
AddOptions<T>() |
.Bind(section) v
.ValidateDataAnnotations() PragmaticConfigurationProvider
.ValidateOnStart() (bridge to IConfiguration)
| |
v v
IOptions<T> IOptionsMonitor<T>
IOptionsSnapshot<T> (hot-reload)
IOptionsMonitor<T>

The two pillars are independent. You can use the source generator without runtime stores (static config from appsettings.json). You can use runtime stores without the source generator (manual IOptions<T> binding). Together, they provide the complete solution.


[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class ConfigurationAttribute : Attribute
{
public string? SectionPath { get; init; } // Override inferred path
public bool ValidateOnStart { get; init; } = true; // Fail fast on invalid config
}

The class must be partial (the SG emits code into it). It cannot be static or abstract.

The section path is inferred by removing the Options suffix from the class name:

Class NameInferred Section
BookingOptions"Booking"
PaymentOptions"Payment"
MyConfig"MyConfig" (no suffix to remove)

Override explicitly when the convention does not match:

[Configuration(SectionPath = "Services:OrderApi")]
public partial class OrderApiOptions { ... }

For each [Configuration] class, the SG generates a per-type extension method:

BookingOptionsConfigurationExtensions.g.cs
public static class BookingOptionsConfigurationExtensions
{
public static IServiceCollection AddBookingOptions(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions<BookingOptions>()
.Bind(configuration.GetSection("Booking"))
.ValidateDataAnnotations()
.ValidateOnStart();
return services;
}
}

And a per-assembly aggregator that calls all individual methods:

MyAppConfigurationExtensions.g.cs
public static class MyAppConfigurationExtensions
{
public static IServiceCollection AddMyAppConfiguration(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddBookingOptions(configuration);
services.AddPaymentOptions(configuration);
services.AddNotificationOptions(configuration);
return services;
}
}

The SG detects System.ComponentModel.DataAnnotations attributes and generates .ValidateDataAnnotations():

  • [Required]
  • [Range]
  • [MaxLength], [MinLength], [StringLength]
  • [RegularExpression]
  • [EmailAddress], [Phone], [Url]

Properties with the required keyword are treated as required even without [Required].

IDSeverityDescription
PRAG2000Error[Configuration] class must be declared as partial
PRAG2001Error[Configuration] class cannot be static or abstract
PRAG2050WarningProperty has [Required] but also has a default value

Both interfaces are defined in Pragmatic.Abstractions, so any module can depend on them without pulling in the full runtime.

IConfigurationStore — backend-agnostic key-value storage:

public interface IConfigurationStore
{
Task<string?> GetAsync(string key, CancellationToken ct = default);
Task<string?> GetAsync(string key, string tenantId, CancellationToken ct = default);
Task<IReadOnlyDictionary<string, string>> GetSectionAsync(string prefix, CancellationToken ct = default);
Task<IReadOnlyDictionary<string, string>> GetSectionAsync(string prefix, string tenantId, CancellationToken ct = default);
Task SetAsync(string key, string value, string? tenantId = null, CancellationToken ct = default);
Task DeleteAsync(string key, string? tenantId = null, CancellationToken ct = default);
IAsyncEnumerable<ConfigurationChange> WatchAsync(string keyPattern, CancellationToken ct = default);
}

ISecretStore — read-only secrets vault:

public interface ISecretStore
{
Task<string?> GetSecretAsync(string key, CancellationToken ct = default);
Task<string?> GetSecretAsync(string key, string tenantId, CancellationToken ct = default);
}

Secrets are set out-of-band (vault UI, CI/CD, deployment scripts). The ISecretStore only reads.

BackendPackageConfig StoreSecret StoreBest For
In-MemoryPragmatic.ConfigurationInMemoryConfigurationStoreInMemorySecretStoreDevelopment, testing
DatabasePragmatic.Configuration.DatabaseDatabaseConfigurationStoreDatabaseSecretStoreSelf-hosted, full control
AzurePragmatic.Configuration.AzureAzureAppConfigurationStoreAzureKeyVaultSecretStoreAzure deployments

The in-memory stores are registered via TryAdd, so any backend registered before AddPragmaticConfiguration() takes precedence.


ConfigurationResolver resolves values by walking layers from most general to most specific. Last-found wins.

1. Base value (key = "MaxRetries")
2. Environment layer (key in environment-specific prefix)
3. Environment + tag (key in environment+tag-specific prefix)
4. Tenant override (key scoped to tenant ID)

Wraps the current environment into a resolution chain:

var profile = EnvironmentProfile.From("Staging", "eu-west");
// profile.ResolutionChain = ["base", "staging", "staging-eu-west"]

The resolution chain determines overlay order:

  • "base" is always first.
  • Environment name (lowercased) is added unless Production.
  • Environment + tag is added if a tag is specified.

Production has a shorter chain: ["base"] — no environment overlay, because production values are the base values.

For a single key (ResolveAsync):

  1. Check tenant override first (returns immediately if found).
  2. Walk environment chain from most specific to least specific.
  3. Fall back to base value.

For a section (ResolveSectionAsync):

  1. Load base values.
  2. Overlay environment-specific values (general to specific — last wins).
  3. Overlay tenant-specific values last.
// Example: resolving "MaxRetries" in staging-eu-west for tenant "acme"
// 1. Check store.GetAsync("MaxRetries", "acme") -- tenant override
// 2. Check store.GetAsync("staging-eu-west/MaxRetries") -- env+tag
// 3. Check store.GetAsync("staging/MaxRetries") -- env
// 4. Check store.GetAsync("MaxRetries") -- base

Enable tenant isolation:

services.AddPragmaticConfiguration(options =>
{
options.MultiTenant.Enabled = true;
options.MultiTenant.FallbackToBase = true;
});

When enabled, ConfigurationResolver reads ITenantContext from DI (from Pragmatic.MultiTenancy) to resolve tenant-specific overrides. If FallbackToBase is true and no tenant override exists, the base value is returned.


PragmaticConfigurationProvider bridges IConfigurationStore into Microsoft’s IConfiguration pipeline. This enables IOptions<T> and IOptionsMonitor<T> to read from any Pragmatic store backend.

builder.Configuration.AddPragmaticStore(store, environmentProfile, keyPrefix: null);
  1. Startup: Load() reads all values from the store (base + environment layers).
  2. Background: Subscribes to WatchAsync() for change notifications.
  3. On change: Re-loads all values and calls OnReload(), which triggers IOptionsMonitor<T> change callbacks.
  4. Key normalization: / is replaced with : for Microsoft Configuration compatibility.

The watcher runs in the background with robust error handling:

  • Reload failures are logged but do not kill the watcher.
  • OperationCanceledException is expected on dispose.
  • Unexpected watch stream failures are logged and the watcher stops.
public class BookingService(IOptionsMonitor<BookingOptions> options)
{
public int MaxGuests => options.CurrentValue.MaxGuests;
// Automatically picks up changes from any backend
}

When a value changes in the store:

  1. Store emits ConfigurationChange via WatchAsync.
  2. PragmaticConfigurationProvider re-loads all data.
  3. IOptionsMonitor<T> fires change callbacks.
  4. options.CurrentValue returns updated values.

ADO.NET-pure — zero EF Core dependency. Supports PostgreSQL, SQL Server, and SQLite via ISqlDialect.

services.AddDatabaseConfigurationStore(options =>
{
options.Provider = DatabaseProvider.PostgreSql;
options.AutoCreateSchema = true;
options.Environment = "staging";
options.AuditUser = "system";
options.PollingInterval = TimeSpan.FromSeconds(30);
});
// Optional: encrypted secret store
services.AddDatabaseSecretStore();

Three tables are auto-created (idempotent via ConfigurationSchemaManager):

TablePurpose
pragmatic_configKey-value pairs with tenant_id, environment, version
pragmatic_secretsEncrypted values (AES-256-GCM) with tenant isolation
pragmatic_config_auditFull audit trail: entity, action, old/new value, changed_by, timestamp

Each provider has different upsert, timestamp, and constraint semantics:

ProviderUpsertTimestampsEncryption Column
PostgreSQLON CONFLICT DO UPDATETIMESTAMPTZBYTEA
SQL ServerMERGEDATETIMEOFFSETVARBINARY(MAX)
SQLiteON CONFLICT + COALESCE sentinelTEXTBLOB

SQLite uses COALESCE(tenant_id, '') in unique indexes because SQLite treats NULL != NULL in unique constraints.

Secrets are encrypted at rest with AES-256-GCM:

Format: [12-byte nonce][16-byte auth tag][ciphertext]
  • Random nonce per encryption (no nonce reuse).
  • 32-byte (256-bit) key, base64-encoded.
  • Key from DatabaseConfigurationOptions.EncryptionKey or PRAGMATIC_SECRET_KEY environment variable.
  • Authenticated encryption: tampering is detected.

Azure App Configuration for dynamic config, Azure Key Vault for secrets.

// Both stores at once
services.AddAzureConfiguration(options =>
{
options.AppConfigurationEndpoint = "https://myapp.azconfig.io";
options.KeyVaultUri = "https://myapp-vault.vault.azure.net";
options.KeyPrefix = "Pragmatic";
options.CacheExpiration = TimeSpan.FromSeconds(30);
options.SecretCacheExpiration = TimeSpan.FromMinutes(5);
});
// Or individually
services.AddAzureAppConfigurationStore(options => { ... });
services.AddAzureKeyVaultSecretStore(options => { ... });
ConceptAzure Format
Config key{prefix}:{key}
Environment label{environment} or {environment}-{tag}
Tenant labeltenant-{tenantId}
Secret name{prefix}--{key} (: replaced by -- for Key Vault)
Tenant secret{prefix}--tenant--{tenantId}--{key}
Sentinel key{prefix}:Sentinel (change detection trigger)

Authentication uses DefaultAzureCredential (managed identity, Azure CLI, etc.) or explicit connection strings.


Configuration values read from remote backends are cached via ICacheStack (from Pragmatic.Caching).

Default behavior:

  • An internal InMemoryConfigurationCacheStack (ConcurrentDictionary with TTL) is registered via TryAdd.
  • When Pragmatic.Caching is referenced, it automatically replaces this with HybridCacheStack (L1 memory + L2 distributed).

Cache TTLs:

CategoryDefault TTL
Configuration values30 seconds
Secrets5 minutes
Feature flags10 seconds

Implement IConfigurationStore and/or ISecretStore:

public class RedisConfigurationStore : IConfigurationStore
{
// Implement all interface methods
}
// Register before AddPragmaticConfiguration() so TryAdd doesn't override
services.AddSingleton<IConfigurationStore, RedisConfigurationStore>();
services.AddPragmaticConfiguration(); // TryAdd won't replace your store

The in-memory defaults use TryAdd, so any store registered before AddPragmaticConfiguration() takes precedence.


Use standard Microsoft IOptions<T> patterns — the generator and store system handle the wiring:

// Real-time values (recommended for long-lived services)
public class BookingService(IOptionsMonitor<BookingOptions> options)
{
public int MaxGuests => options.CurrentValue.MaxGuests;
}
// Per-request snapshot
public class BookingHandler(IOptionsSnapshot<BookingOptions> options) { }
// Singleton (reads once at startup)
public class StaticService(IOptions<BookingOptions> options) { }
PatternLifetimeHot-ReloadUse Case
IOptions<T>SingletonNoStatic config that never changes
IOptionsSnapshot<T>ScopedPer-requestConfig that changes between requests
IOptionsMonitor<T>SingletonYes (callbacks)Config that changes during app lifetime

ModuleIntegration
Pragmatic.CompositionAddPragmaticConfiguration() in IStartupStep.ConfigureServices
Pragmatic.FeatureFlagsSeparate IFeatureFlagStore for evaluation-based flags (not key-value)
Pragmatic.MultiTenancyITenantContext feeds ConfigurationResolver for tenant overrides
Pragmatic.CachingConfiguration values cached via ICacheStack
Pragmatic.SourceGeneratorGenerates IOptions<T> binding, validation, DI registration

TopicLocation
Getting Startedgetting-started.md
Store Backendsstores.md
Common Mistakescommon-mistakes.md
Troubleshootingtroubleshooting.md