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.
The Problem
Section titled “The Problem”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.
Boilerplate per options class
Section titled “Boilerplate per options class”Every IOptions<T> class requires identical registration code:
// BookingOptions -- 3 properties, 12 lines of wiringservices.AddOptions<BookingOptions>() .Bind(configuration.GetSection("Booking")) .ValidateDataAnnotations() .ValidateOnStart();
// PaymentOptions -- same pattern, different namesservices.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.
Missing validation
Section titled “Missing validation”Validation is opt-in and easy to forget:
// Forgot .ValidateDataAnnotations() -- [Required] attributes are ignoredservices.AddOptions<PaymentOptions>() .Bind(configuration.GetSection("Payment")); // App starts with empty PaymentGatewayUrl, fails at runtimeSecrets management varies by environment
Section titled “Secrets management varies by environment”// 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 configsNo dynamic reconfiguration
Section titled “No dynamic reconfiguration”Changing a config value requires a deployment. Runtime changes (A/B testing, feature rollouts, emergency overrides) need custom infrastructure that is rarely built.
Per-tenant overrides are ad-hoc
Section titled “Per-tenant overrides are ad-hoc”// Typical approach: hardcoded switchvar maxGuests = tenantId switch{ "acme" => 200, "contoso" => 50, _ => config.MaxGuests // default};// Not scalable, not auditable, not dynamicThe consequences
Section titled “The consequences”| Problem | Impact |
|---|---|
| Boilerplate per options class | Developer fatigue, copy-paste errors |
| Forgotten validation | Runtime failures from invalid config |
| No unified secrets API | Different code paths per environment |
| Static configuration | Deployments for every config change |
| No tenant isolation | Hard-coded overrides, no audit trail |
| Reflection-based binding | AOT/trimming incompatibility |
The Solution
Section titled “The Solution”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 callsBind,ValidateDataAnnotations, andValidateOnStart. - Per-assembly aggregator:
Add{Prefix}Configuration(services, configuration)that calls every individualAdd*Optionsmethod. - Section path inference:
BookingOptionsbinds to"Booking"(removesOptionssuffix).
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.
How It Works
Section titled “How It Works”Architecture overview
Section titled “Architecture overview” 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.
Pillar 1: Source Generator Details
Section titled “Pillar 1: Source Generator Details”The [Configuration] attribute
Section titled “The [Configuration] attribute”[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.
Section path inference
Section titled “Section path inference”The section path is inferred by removing the Options suffix from the class name:
| Class Name | Inferred 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 { ... }What the SG generates
Section titled “What the SG generates”For each [Configuration] class, the SG generates a per-type extension method:
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:
public static class MyAppConfigurationExtensions{ public static IServiceCollection AddMyAppConfiguration( this IServiceCollection services, IConfiguration configuration) { services.AddBookingOptions(configuration); services.AddPaymentOptions(configuration); services.AddNotificationOptions(configuration); return services; }}Supported validation attributes
Section titled “Supported validation attributes”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].
Diagnostics
Section titled “Diagnostics”| ID | Severity | Description |
|---|---|---|
| PRAG2000 | Error | [Configuration] class must be declared as partial |
| PRAG2001 | Error | [Configuration] class cannot be static or abstract |
| PRAG2050 | Warning | Property has [Required] but also has a default value |
Pillar 2: Runtime Stores
Section titled “Pillar 2: Runtime Stores”Store interfaces
Section titled “Store interfaces”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.
Built-in backends
Section titled “Built-in backends”| Backend | Package | Config Store | Secret Store | Best For |
|---|---|---|---|---|
| In-Memory | Pragmatic.Configuration | InMemoryConfigurationStore | InMemorySecretStore | Development, testing |
| Database | Pragmatic.Configuration.Database | DatabaseConfigurationStore | DatabaseSecretStore | Self-hosted, full control |
| Azure | Pragmatic.Configuration.Azure | AzureAppConfigurationStore | AzureKeyVaultSecretStore | Azure deployments |
The in-memory stores are registered via TryAdd, so any backend registered before AddPragmaticConfiguration() takes precedence.
Cascade Resolution
Section titled “Cascade Resolution”ConfigurationResolver resolves values by walking layers from most general to most specific. Last-found wins.
Resolution order
Section titled “Resolution order”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)EnvironmentProfile
Section titled “EnvironmentProfile”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.
How it works
Section titled “How it works”For a single key (ResolveAsync):
- Check tenant override first (returns immediately if found).
- Walk environment chain from most specific to least specific.
- Fall back to base value.
For a section (ResolveSectionAsync):
- Load base values.
- Overlay environment-specific values (general to specific — last wins).
- 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") -- baseMulti-tenant configuration
Section titled “Multi-tenant configuration”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.
Bridge and Hot-Reload
Section titled “Bridge and Hot-Reload”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);How it works
Section titled “How it works”- Startup:
Load()reads all values from the store (base + environment layers). - Background: Subscribes to
WatchAsync()for change notifications. - On change: Re-loads all values and calls
OnReload(), which triggersIOptionsMonitor<T>change callbacks. - 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.
OperationCanceledExceptionis expected on dispose.- Unexpected watch stream failures are logged and the watcher stops.
Hot-reload in practice
Section titled “Hot-reload in practice”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:
- Store emits
ConfigurationChangeviaWatchAsync. PragmaticConfigurationProviderre-loads all data.IOptionsMonitor<T>fires change callbacks.options.CurrentValuereturns updated values.
Database Backend
Section titled “Database Backend”ADO.NET-pure — zero EF Core dependency. Supports PostgreSQL, SQL Server, and SQLite via ISqlDialect.
Registration
Section titled “Registration”services.AddDatabaseConfigurationStore(options =>{ options.Provider = DatabaseProvider.PostgreSql; options.AutoCreateSchema = true; options.Environment = "staging"; options.AuditUser = "system"; options.PollingInterval = TimeSpan.FromSeconds(30);});
// Optional: encrypted secret storeservices.AddDatabaseSecretStore();Schema
Section titled “Schema”Three tables are auto-created (idempotent via ConfigurationSchemaManager):
| Table | Purpose |
|---|---|
pragmatic_config | Key-value pairs with tenant_id, environment, version |
pragmatic_secrets | Encrypted values (AES-256-GCM) with tenant isolation |
pragmatic_config_audit | Full audit trail: entity, action, old/new value, changed_by, timestamp |
SQL dialect abstraction
Section titled “SQL dialect abstraction”Each provider has different upsert, timestamp, and constraint semantics:
| Provider | Upsert | Timestamps | Encryption Column |
|---|---|---|---|
| PostgreSQL | ON CONFLICT DO UPDATE | TIMESTAMPTZ | BYTEA |
| SQL Server | MERGE | DATETIMEOFFSET | VARBINARY(MAX) |
| SQLite | ON CONFLICT + COALESCE sentinel | TEXT | BLOB |
SQLite uses COALESCE(tenant_id, '') in unique indexes because SQLite treats NULL != NULL in unique constraints.
Encryption
Section titled “Encryption”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.EncryptionKeyorPRAGMATIC_SECRET_KEYenvironment variable. - Authenticated encryption: tampering is detected.
Azure Backend
Section titled “Azure Backend”Azure App Configuration for dynamic config, Azure Key Vault for secrets.
Registration
Section titled “Registration”// Both stores at onceservices.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 individuallyservices.AddAzureAppConfigurationStore(options => { ... });services.AddAzureKeyVaultSecretStore(options => { ... });Key conventions
Section titled “Key conventions”| Concept | Azure Format |
|---|---|
| Config key | {prefix}:{key} |
| Environment label | {environment} or {environment}-{tag} |
| Tenant label | tenant-{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 Caching
Section titled “Configuration Caching”Configuration values read from remote backends are cached via ICacheStack (from Pragmatic.Caching).
Default behavior:
- An internal
InMemoryConfigurationCacheStack(ConcurrentDictionary with TTL) is registered viaTryAdd. - When
Pragmatic.Cachingis referenced, it automatically replaces this withHybridCacheStack(L1 memory + L2 distributed).
Cache TTLs:
| Category | Default TTL |
|---|---|
| Configuration values | 30 seconds |
| Secrets | 5 minutes |
| Feature flags | 10 seconds |
Implementing a Custom Store
Section titled “Implementing a Custom Store”Implement IConfigurationStore and/or ISecretStore:
public class RedisConfigurationStore : IConfigurationStore{ // Implement all interface methods}
// Register before AddPragmaticConfiguration() so TryAdd doesn't overrideservices.AddSingleton<IConfigurationStore, RedisConfigurationStore>();services.AddPragmaticConfiguration(); // TryAdd won't replace your storeThe in-memory defaults use TryAdd, so any store registered before AddPragmaticConfiguration() takes precedence.
Consuming Options
Section titled “Consuming Options”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 snapshotpublic class BookingHandler(IOptionsSnapshot<BookingOptions> options) { }
// Singleton (reads once at startup)public class StaticService(IOptions<BookingOptions> options) { }| Pattern | Lifetime | Hot-Reload | Use Case |
|---|---|---|---|
IOptions<T> | Singleton | No | Static config that never changes |
IOptionsSnapshot<T> | Scoped | Per-request | Config that changes between requests |
IOptionsMonitor<T> | Singleton | Yes (callbacks) | Config that changes during app lifetime |
Ecosystem Integration
Section titled “Ecosystem Integration”| Module | Integration |
|---|---|
| Pragmatic.Composition | AddPragmaticConfiguration() in IStartupStep.ConfigureServices |
| Pragmatic.FeatureFlags | Separate IFeatureFlagStore for evaluation-based flags (not key-value) |
| Pragmatic.MultiTenancy | ITenantContext feeds ConfigurationResolver for tenant overrides |
| Pragmatic.Caching | Configuration values cached via ICacheStack |
| Pragmatic.SourceGenerator | Generates IOptions<T> binding, validation, DI registration |
See Also
Section titled “See Also”| Topic | Location |
|---|---|
| Getting Started | getting-started.md |
| Store Backends | stores.md |
| Common Mistakes | common-mistakes.md |
| Troubleshooting | troubleshooting.md |