Pragmatic.Configuration
Zero-boilerplate configuration binding, validation and DI registration via Source Generator — plus runtime stores with cascade resolution, multi-tenancy, hot-reload, and pluggable backends (Database, Azure).
The Problem
Section titled “The Problem”Configuration management in .NET starts simple and grows painful. Every IOptions<T> class requires identical boilerplate for binding, validation, and DI registration. Multiply by 20 options classes and you have hundreds of lines of ceremony that adds no value. Validation is opt-in and easy to forget — one missing .ValidateDataAnnotations() means [Required] attributes are silently ignored and the app fails at runtime.
// 12 lines of boilerplate per options class. Repeat for every config type.services.AddOptions<BookingOptions>() .Bind(configuration.GetSection("Booking")) .ValidateDataAnnotations() .ValidateOnStart();
services.AddOptions<PaymentOptions>() .Bind(configuration.GetSection("Payment")) .ValidateDataAnnotations() .ValidateOnStart();
// And again, and again...Beyond the boilerplate: secrets management varies by environment. Dynamic reconfiguration requires a deployment. Per-tenant overrides are hardcoded switch statements. There is no unified API for config vs. secrets vs. feature flags.
The Solution
Section titled “The Solution”Pragmatic.Configuration solves this with two independent pillars:
Pillar 1 — Compile-time binding: Annotate a class with [Configuration]. The source generator produces all IOptions<T> binding, DataAnnotation validation, and DI registration. One line registers everything.
[Configuration]public partial class BookingOptions{ [Required] public string HotelName { get; set; } = ""; [Range(1, 100)] public int MaxGuests { get; set; } = 10;}
// One line in startup -- registers ALL [Configuration] classes in the assemblyservices.AddMyAppConfiguration(configuration);Pillar 2 — Runtime stores: A pluggable store architecture with cascade resolution (base -> environment -> tenant), hot-reload via WatchAsync, and a bridge to IOptionsMonitor<T>.
services.AddPragmaticConfiguration(options =>{ options.EnvironmentTag = "eu-west"; options.MultiTenant.Enabled = true;});Installation
Section titled “Installation”# Core (SG binding + in-memory stores)dotnet add package Pragmatic.Configuration
# Database backend (PostgreSQL, SQL Server, SQLite)dotnet add package Pragmatic.Configuration.Database
# Azure backend (App Configuration + Key Vault)dotnet add package Pragmatic.Configuration.AzureFor source generator support:
<ProjectReference Include="..\Pragmatic.SourceGenerator\src\Pragmatic.SourceGenerator\Pragmatic.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />Feature Catalog
Section titled “Feature Catalog”| Problem | Solution |
|---|---|
Boilerplate IOptions<T> binding per class | [Configuration] attribute + source generator |
| Validation scattered or missing | DataAnnotation attributes with .ValidateDataAnnotations().ValidateOnStart() |
| Many option classes to register | Per-assembly Add{Prefix}Configuration() aggregator |
| Configuration varies by environment | EnvironmentProfile cascade: base -> env -> env+tag |
| Per-tenant configuration overrides | IConfigurationStore with tenant-scoped Get/Set |
| Secrets stored in plaintext | ISecretStore with AES-256-GCM encryption at rest |
| No audit trail for config changes | AuditLogger logs every Set/Delete with old/new value |
| Need Azure-native backend | Pragmatic.Configuration.Azure with App Configuration + Key Vault |
| Need database-backed persistence | Pragmatic.Configuration.Database with multi-dialect ADO.NET |
| Hot-reload of configuration values | WatchAsync + PragmaticConfigurationProvider bridge to IOptionsMonitor<T> |
| AOT/trimming concerns with reflection | All binding code is source-generated, zero reflection at runtime |
Quick Start
Section titled “Quick Start”1. Source Generator (Compile-time Binding)
Section titled “1. Source Generator (Compile-time Binding)”using Pragmatic.Configuration;using System.ComponentModel.DataAnnotations;
[Configuration]public partial class BookingOptions{ [Required] [MaxLength(100)] public string HotelName { get; set; } = "";
[Range(1, 100)] public int MaxGuests { get; set; } = 10;
public int CancellationWindowHours { get; set; } = 24;}The source generator produces:
// Per-type: BookingOptionsConfigurationExtensions.AddBookingOptions(services, configuration)services.AddOptions<BookingOptions>() .Bind(configuration.GetSection("Booking")) .ValidateDataAnnotations() .ValidateOnStart();
// Per-assembly: MyAppConfigurationExtensions.AddMyAppConfiguration(services, configuration)// Calls all individual Add*Options methods2. Usage in IStartupStep
Section titled “2. Usage in IStartupStep”[StartupStep]public class AppStartupStep : IStartupStep{ public void ConfigureServices( IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) { // One line registers ALL [Configuration] options in the assembly services.AddMyAppConfiguration(configuration); }}3. Runtime Store (Dynamic Configuration)
Section titled “3. Runtime Store (Dynamic Configuration)”services.AddPragmaticConfiguration(options =>{ options.EnvironmentTag = "eu-west"; options.MultiTenant.Enabled = true;});Architecture
Section titled “Architecture”Three Pillars
Section titled “Three Pillars”IConfigurationStore ISecretStore IFeatureFlagStore (key-value config) (encrypted secrets) (see Pragmatic.FeatureFlags) | | v v ConfigurationResolver Direct access (cascade resolution) | v PragmaticConfigurationProvider (bridge to IOptionsMonitor<T>)Cascade Resolution
Section titled “Cascade Resolution”ConfigurationResolver resolves values through layers, last-found wins:
1. Base value (key = "MaxRetries", tenant = null, env = null)2. Environment layer (key = "MaxRetries", tenant = null, env = "staging")3. Environment + tag (key = "MaxRetries", tenant = null, env = "staging-eu-west")4. Tenant override (key = "MaxRetries", tenant = "acme", env = null)EnvironmentProfile.From("Staging", "eu-west") builds the resolution chain automatically.
Store Abstractions
Section titled “Store Abstractions”IConfigurationStore — Backend-agnostic configuration 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 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);}Backends
Section titled “Backends”In-Memory (Default)
Section titled “In-Memory (Default)”Registered automatically by AddPragmaticConfiguration(). Useful for development and testing.
services.AddPragmaticConfiguration();// InMemoryConfigurationStore and InMemorySecretStore are registered via TryAddDatabase Backend
Section titled “Database Backend”ADO.NET pure — zero EF Core dependency. Supports PostgreSQL, SQL Server, and SQLite.
// Register your connection factoryservices.AddSingleton<IDbConnectionFactory>(() => new NpgsqlConnectionFactory("Host=localhost;Database=myapp"));
// Add database-backed storesservices.AddDatabaseConfigurationStore(options =>{ options.Provider = DatabaseProvider.PostgreSql; options.AutoCreateSchema = true; // creates tables on first use options.Environment = "staging"; options.AuditUser = "system"; options.PollingInterval = TimeSpan.FromSeconds(30);});
// Optional: encrypted secret storeservices.AddDatabaseSecretStore();// Requires: options.EncryptionKey or PRAGMATIC_SECRET_KEY env variableDatabase Schema
Section titled “Database Schema”Three tables are auto-created (idempotent):
| Table | Purpose |
|---|---|
pragmatic_config | Key-value pairs with tenant_id, environment, version tracking |
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”| 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 to handle 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_KEYenv variable
Azure Backend
Section titled “Azure Backend”Azure App Configuration for dynamic config, Azure Key Vault for secrets.
// 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 => { ... });Azure Key Conventions
Section titled “Azure 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.
Bridge & Hot-Reload
Section titled “Bridge & Hot-Reload”PragmaticConfigurationProvider bridges IConfigurationStore to Microsoft’s IConfiguration system, enabling IOptions<T> / IOptionsMonitor<T>:
builder.Configuration.AddPragmaticStore(store, environmentProfile, keyPrefix: null);How it works:
- Startup:
Load()reads all values from store (base + environment layers) - Background: Subscribes to
WatchAsync()for change notifications - On change: Re-loads all values + calls
OnReload()—IOptionsMonitor<T>fires change callbacks - Key normalization:
/replaced by:for Microsoft Configuration compatibility
// Consuming hot-reloaded valuespublic class MyService(IOptionsMonitor<BookingOptions> options){ public int MaxGuests => options.CurrentValue.MaxGuests; // Automatically picks up changes from any backend}Source Generator Details
Section titled “Source Generator Details”Section Path Convention
Section titled “Section Path Convention”The section path is inferred from the class name by removing the Options suffix:
| Class Name | Inferred Section |
|---|---|
BookingOptions | "Booking" |
PaymentOptions | "Payment" |
MyConfig | "MyConfig" |
Override with [Configuration(SectionPath = "Custom:Path")].
Attribute Reference
Section titled “Attribute Reference”[Configuration] // Infer section from class name[Configuration(SectionPath = "My:Section")] // Explicit section path[Configuration(ValidateOnStart = false)] // Skip startup validationappsettings.json
Section titled “appsettings.json”{ "Booking": { "HotelName": "Grand Hotel", "MaxGuests": 50, "CancellationWindowHours": 48 }}Consuming Options
Section titled “Consuming Options”Use standard Microsoft patterns — the generator wires everything:
// Real-time values (recommended)public class BookingService(IOptionsMonitor<BookingOptions> options){ public int MaxGuests => options.CurrentValue.MaxGuests;}
// Per-request snapshotpublic class BookingHandler(IOptionsSnapshot<BookingOptions> options) { }
// Singleton (startup-only)public class StaticService(IOptions<BookingOptions> options) { }Metadata Generation
Section titled “Metadata Generation”When Pragmatic.Composition is referenced, the generator also emits [assembly: PragmaticMetadata] with a JSON schema of all [Configuration] classes, including property types, validation attributes, and section paths.
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 |
Configuration Options Reference
Section titled “Configuration Options Reference”PragmaticConfigurationOptions
Section titled “PragmaticConfigurationOptions”| Option | Default | Description |
|---|---|---|
EnvironmentTag | null | Sub-environment tag (e.g., "eu-west") |
MultiTenant.Enabled | false | Enable tenant-scoped overrides |
MultiTenant.FallbackToBase | true | Cascade to base if no tenant value |
Cache.DefaultTtl | 30s | Configuration value cache duration |
Cache.SecretsTtl | 5m | Secret value cache duration |
Cache.FeatureFlagsTtl | 10s | Feature flag cache duration |
DatabaseConfigurationOptions
Section titled “DatabaseConfigurationOptions”| Option | Default | Description |
|---|---|---|
ConnectionString | "" | Database connection string |
Provider | PostgreSql | PostgreSql, SqlServer, or Sqlite |
EncryptionKey | null | AES-256 key for secrets (32 bytes, base64) |
AutoCreateSchema | true | Create tables on first use |
Environment | null | Environment scope for queries |
AuditUser | null | Identity for audit log entries |
PollingInterval | 30s | Change detection polling interval |
EnableChangePolling | true | Enable/disable WatchAsync polling |
AzureConfigurationOptions
Section titled “AzureConfigurationOptions”| Option | Default | Description |
|---|---|---|
AppConfigurationEndpoint | null | Azure App Configuration endpoint |
AppConfigurationConnectionString | null | Alternative: connection string auth |
KeyVaultUri | null | Azure Key Vault endpoint |
KeyPrefix | "Pragmatic" | Key namespace prefix |
SentinelKey | "Pragmatic:Sentinel" | Change notification trigger |
CacheExpiration | 30s | Config value TTL |
SecretCacheExpiration | 5m | Secret value TTL |
Design Decisions
Section titled “Design Decisions”| Decision | Rationale |
|---|---|
| ADO.NET pure, zero EF Core | Configuration storage is simple key-value — ORM adds complexity without benefit |
ISqlDialect abstraction | Each database has different upsert, timestamp, and constraint semantics |
| COALESCE sentinel for SQLite | SQLite treats NULL != NULL in unique constraints — sentinel pattern ensures correct upsert |
| AES-256-GCM for secrets | Authenticated encryption prevents tampering; random nonce prevents nonce reuse |
| Encryption key outside DB | Key stored in env variable or local config — never in the database itself |
| Polling-based WatchAsync | Database change notifications (LISTEN/NOTIFY) are provider-specific; polling is universal |
| Bridge to IConfigurationProvider | Reuses Microsoft’s existing IOptions<T> ecosystem without reinventing it |
| Cascade resolution order | Base -> env -> env+tag -> tenant mirrors real-world config hierarchy |
| In-memory defaults via TryAdd | Works out of the box; DB/Azure backends replace via standard DI |
Cross-Module Integration
Section titled “Cross-Module Integration”| With 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 using CacheCategories.Configuration for isolation |
| Pragmatic.SourceGenerator | Generates IOptions<T> binding, validation, DI registration, and metadata |
Layer Dependencies
Section titled “Layer Dependencies”Layer 0 (Foundation) Layer 1 (Capabilities)+-+ Result +-+ Configuration <-- this module+-+ Ensure +-+ Validation+-+ DependencyInjection +-+ Mapping +-+ ...Requirements
Section titled “Requirements”- .NET 10.0+
Pragmatic.Abstractions(interfaces)Pragmatic.SourceGeneratoranalyzer (for[Configuration]binding)- Database backend: ADO.NET provider (Npgsql, Microsoft.Data.SqlClient, Microsoft.Data.Sqlite)
- Azure backend: Azure SDK packages (Azure.Data.AppConfiguration, Azure.Security.KeyVault.Secrets, Azure.Identity)
License
Section titled “License”Part of the Pragmatic.Design ecosystem.