Skip to content

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).

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.

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 assembly
services.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;
});
Terminal window
# 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.Azure

For source generator support:

<ProjectReference Include="..\Pragmatic.SourceGenerator\src\Pragmatic.SourceGenerator\Pragmatic.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
ProblemSolution
Boilerplate IOptions<T> binding per class[Configuration] attribute + source generator
Validation scattered or missingDataAnnotation attributes with .ValidateDataAnnotations().ValidateOnStart()
Many option classes to registerPer-assembly Add{Prefix}Configuration() aggregator
Configuration varies by environmentEnvironmentProfile cascade: base -> env -> env+tag
Per-tenant configuration overridesIConfigurationStore with tenant-scoped Get/Set
Secrets stored in plaintextISecretStore with AES-256-GCM encryption at rest
No audit trail for config changesAuditLogger logs every Set/Delete with old/new value
Need Azure-native backendPragmatic.Configuration.Azure with App Configuration + Key Vault
Need database-backed persistencePragmatic.Configuration.Database with multi-dialect ADO.NET
Hot-reload of configuration valuesWatchAsync + PragmaticConfigurationProvider bridge to IOptionsMonitor<T>
AOT/trimming concerns with reflectionAll binding code is source-generated, zero reflection at runtime

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 methods
[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);
}
}
services.AddPragmaticConfiguration(options =>
{
options.EnvironmentTag = "eu-west";
options.MultiTenant.Enabled = true;
});

IConfigurationStore ISecretStore IFeatureFlagStore
(key-value config) (encrypted secrets) (see Pragmatic.FeatureFlags)
| |
v v
ConfigurationResolver Direct access
(cascade resolution)
|
v
PragmaticConfigurationProvider
(bridge to IOptionsMonitor<T>)

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.

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

Registered automatically by AddPragmaticConfiguration(). Useful for development and testing.

services.AddPragmaticConfiguration();
// InMemoryConfigurationStore and InMemorySecretStore are registered via TryAdd

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

// Register your connection factory
services.AddSingleton<IDbConnectionFactory>(() =>
new NpgsqlConnectionFactory("Host=localhost;Database=myapp"));
// Add database-backed stores
services.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 store
services.AddDatabaseSecretStore();
// Requires: options.EncryptionKey or PRAGMATIC_SECRET_KEY env variable

Three tables are auto-created (idempotent):

TablePurpose
pragmatic_configKey-value pairs with tenant_id, environment, version tracking
pragmatic_secretsEncrypted values (AES-256-GCM) with tenant isolation
pragmatic_config_auditFull audit trail: entity, action, old/new value, changed_by, timestamp
ProviderUpsertTimestampsEncryption Column
PostgreSQLON CONFLICT DO UPDATETIMESTAMPTZBYTEA
SQL ServerMERGEDATETIMEOFFSETVARBINARY(MAX)
SQLiteON CONFLICT + COALESCE sentinelTEXTBLOB

SQLite uses COALESCE(tenant_id, '') in unique indexes to handle 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 env variable

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.


PragmaticConfigurationProvider bridges IConfigurationStore to Microsoft’s IConfiguration system, enabling IOptions<T> / IOptionsMonitor<T>:

builder.Configuration.AddPragmaticStore(store, environmentProfile, keyPrefix: null);

How it works:

  1. Startup: Load() reads all values from store (base + environment layers)
  2. Background: Subscribes to WatchAsync() for change notifications
  3. On change: Re-loads all values + calls OnReload()IOptionsMonitor<T> fires change callbacks
  4. Key normalization: / replaced by : for Microsoft Configuration compatibility
// Consuming hot-reloaded values
public class MyService(IOptionsMonitor<BookingOptions> options)
{
public int MaxGuests => options.CurrentValue.MaxGuests;
// Automatically picks up changes from any backend
}

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

Class NameInferred Section
BookingOptions"Booking"
PaymentOptions"Payment"
MyConfig"MyConfig"

Override with [Configuration(SectionPath = "Custom:Path")].

[Configuration] // Infer section from class name
[Configuration(SectionPath = "My:Section")] // Explicit section path
[Configuration(ValidateOnStart = false)] // Skip startup validation
{
"Booking": {
"HotelName": "Grand Hotel",
"MaxGuests": 50,
"CancellationWindowHours": 48
}
}

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 snapshot
public class BookingHandler(IOptionsSnapshot<BookingOptions> options) { }
// Singleton (startup-only)
public class StaticService(IOptions<BookingOptions> options) { }

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.


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

OptionDefaultDescription
EnvironmentTagnullSub-environment tag (e.g., "eu-west")
MultiTenant.EnabledfalseEnable tenant-scoped overrides
MultiTenant.FallbackToBasetrueCascade to base if no tenant value
Cache.DefaultTtl30sConfiguration value cache duration
Cache.SecretsTtl5mSecret value cache duration
Cache.FeatureFlagsTtl10sFeature flag cache duration
OptionDefaultDescription
ConnectionString""Database connection string
ProviderPostgreSqlPostgreSql, SqlServer, or Sqlite
EncryptionKeynullAES-256 key for secrets (32 bytes, base64)
AutoCreateSchematrueCreate tables on first use
EnvironmentnullEnvironment scope for queries
AuditUsernullIdentity for audit log entries
PollingInterval30sChange detection polling interval
EnableChangePollingtrueEnable/disable WatchAsync polling
OptionDefaultDescription
AppConfigurationEndpointnullAzure App Configuration endpoint
AppConfigurationConnectionStringnullAlternative: connection string auth
KeyVaultUrinullAzure Key Vault endpoint
KeyPrefix"Pragmatic"Key namespace prefix
SentinelKey"Pragmatic:Sentinel"Change notification trigger
CacheExpiration30sConfig value TTL
SecretCacheExpiration5mSecret value TTL

DecisionRationale
ADO.NET pure, zero EF CoreConfiguration storage is simple key-value — ORM adds complexity without benefit
ISqlDialect abstractionEach database has different upsert, timestamp, and constraint semantics
COALESCE sentinel for SQLiteSQLite treats NULL != NULL in unique constraints — sentinel pattern ensures correct upsert
AES-256-GCM for secretsAuthenticated encryption prevents tampering; random nonce prevents nonce reuse
Encryption key outside DBKey stored in env variable or local config — never in the database itself
Polling-based WatchAsyncDatabase change notifications (LISTEN/NOTIFY) are provider-specific; polling is universal
Bridge to IConfigurationProviderReuses Microsoft’s existing IOptions<T> ecosystem without reinventing it
Cascade resolution orderBase -> env -> env+tag -> tenant mirrors real-world config hierarchy
In-memory defaults via TryAddWorks out of the box; DB/Azure backends replace via standard DI

With 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 using CacheCategories.Configuration for isolation
Pragmatic.SourceGeneratorGenerates IOptions<T> binding, validation, DI registration, and metadata
Layer 0 (Foundation) Layer 1 (Capabilities)
+-+ Result +-+ Configuration <-- this module
+-+ Ensure +-+ Validation
+-+ DependencyInjection +-+ Mapping
+-+ ...
  • .NET 10.0+
  • Pragmatic.Abstractions (interfaces)
  • Pragmatic.SourceGenerator analyzer (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)

Part of the Pragmatic.Design ecosystem.