Configuration Stores
Pragmatic.Configuration provides a pluggable store architecture for runtime configuration and secrets. The store layer is separate from the compile-time [Configuration] binding — it handles dynamic values, cascade resolution, hot-reload, and tenant overrides.
Store Interfaces
Section titled “Store Interfaces”All interfaces are defined in Pragmatic.Abstractions so any module can depend on them without pulling in the full runtime.
IConfigurationStore
Section titled “IConfigurationStore”Backend-agnostic key-value store with tenant isolation and change notification:
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);}Key methods:
GetAsync— reads a single value, optionally scoped to a tenant.GetSectionAsync— reads all values under a prefix (e.g.,"Booking:"returns all Booking keys).SetAsync/DeleteAsync— mutate values, optionally tenant-scoped.WatchAsync— streamsConfigurationChangerecords for real-time change notification. Accepts a key pattern (e.g.,"Booking:*").
ISecretStore
Section titled “ISecretStore”Read-only store for secrets. Secrets are set out-of-band (vault, CI/CD, user-secrets):
public interface ISecretStore{ Task<string?> GetSecretAsync(string key, CancellationToken ct = default); Task<string?> GetSecretAsync(string key, string tenantId, CancellationToken ct = default);}Implementations manage encryption at rest. The InMemorySecretStore stores secrets in plain text (development only); production implementations (Database, Azure Key Vault) encrypt at rest.
Configuration Caching
Section titled “Configuration Caching”Configuration values read from remote backends are cached via ICacheStack (from Pragmatic.Abstractions) using the CacheCategories.Configuration category. This provides key-prefix isolation and per-category TTL configuration.
Default: an internal InMemoryConfigurationCacheStack (ConcurrentDictionary with TTL). When Pragmatic.Caching is registered, it automatically picks up the HybridCacheStack backend with L1 memory + L2 distributed caching.
ConfigurationChange
Section titled “ConfigurationChange”Record emitted by IConfigurationStore.WatchAsync:
public sealed record ConfigurationChange( string Key, string? OldValue, string? NewValue, string? TenantId, DateTimeOffset Timestamp);Built-In Stores
Section titled “Built-In Stores”InMemoryConfigurationStore
Section titled “InMemoryConfigurationStore”Default store for development and testing. Uses ConcurrentDictionary for thread-safe access and Channel<ConfigurationChange> for change notification.
Features:
- Tenant isolation via separate
ConcurrentDictionary<string, ConcurrentDictionary<string, string>>. - Tenant-scoped
GetAsyncfalls back to base values when no tenant-specific value exists. SetAsyncemits aConfigurationChangeto the channel (consumed byWatchAsync).WatchAsyncfilters changes by key prefix pattern.
InMemorySecretStore
Section titled “InMemorySecretStore”Default secret store for development and testing. Stores secrets in plain text. Supports tenant-scoped secrets with fallback to base.
Additional method for programmatic setup:
var secretStore = (InMemorySecretStore)store;secretStore.SetSecret("ApiKey", "my-secret-key");secretStore.SetSecret("ApiKey", "tenant-specific-key", tenantId: "acme");Cascade Resolution
Section titled “Cascade Resolution”ConfigurationResolver resolves values through environment and tenant layers. It is registered as scoped because ITenantContext is scoped.
Constructor:
public ConfigurationResolver( IConfigurationStore store, EnvironmentProfile environment, ITenantContext? tenantContext = null)Resolution Order
Section titled “Resolution Order”Values are resolved in this order (last found wins):
1. Base value (key = "MaxRetries")2. Environment layer (most specific to least, via ResolutionChain)3. Tenant override (if tenant context is resolved and multi-tenant is enabled)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).
- Overlay tenant-specific values last.
EnvironmentProfile
Section titled “EnvironmentProfile”Wraps the current environment with Pragmatic conventions:
var profile = EnvironmentProfile.From("Staging", "eu-west");// profile.Name = "Staging"// profile.Tag = "eu-west"// profile.ResolutionChain = ["base", "staging", "staging-eu-west"]The resolution chain determines environment overlay order:
"base"is always first.- Environment name is added (lowercased) unless Production.
- Environment + tag is added if a tag is specified.
Production has a shorter chain: ["base"] (no environment overlay).
Helper properties: IsDevelopment, IsStaging, IsProduction, IsTesting, IsEnvironment(name).
Bridge to IOptionsMonitor
Section titled “Bridge to IOptionsMonitor”PragmaticConfigurationProvider bridges IConfigurationStore into Microsoft’s IConfiguration system, enabling 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 as a fire-and-forget task with exception 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 MyService(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.
Runtime Reconfiguration
Section titled “Runtime Reconfiguration”Writing Configuration Values
Section titled “Writing Configuration Values”public class ConfigService(IConfigurationStore store){ public async Task UpdateMaxGuests(int newValue, CancellationToken ct) { await store.SetAsync("Booking:MaxGuests", newValue.ToString(), ct: ct); // If PragmaticConfigurationProvider is registered, IOptionsMonitor // will pick up the change automatically via hot-reload. }
public async Task SetTenantOverride(string tenantId, int maxGuests, CancellationToken ct) { await store.SetAsync("Booking:MaxGuests", maxGuests.ToString(), tenantId, ct); }}Multi-Tenant Configuration
Section titled “Multi-Tenant Configuration”Enable in AddPragmaticConfiguration:
services.AddPragmaticConfiguration(options =>{ options.MultiTenant.Enabled = true; options.MultiTenant.FallbackToBase = true;});When enabled, ConfigurationResolver reads ITenantContext from DI to resolve tenant-specific overrides. If the tenant has no override and FallbackToBase is true, the base value is returned.
Backend Packages
Section titled “Backend Packages”Database Backend
Section titled “Database Backend”ADO.NET-based, zero EF Core dependency. Supports PostgreSQL, SQL Server, and SQLite.
dotnet add package Pragmatic.Configuration.DatabaseThree tables are auto-created: pragmatic_config, pragmatic_secrets (AES-256-GCM encrypted), pragmatic_config_audit.
Azure Backend
Section titled “Azure Backend”Azure App Configuration for dynamic config, Azure Key Vault for secrets.
dotnet add package Pragmatic.Configuration.AzureAuthentication uses DefaultAzureCredential. Keys follow Azure naming conventions with configurable prefix.
See the README for full backend configuration options.
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 override}
services.AddSingleton<IConfigurationStore, RedisConfigurationStore>();services.AddPragmaticConfiguration(); // TryAdd won't replace your storeThe in-memory defaults use TryAdd, so any store registered before AddPragmaticConfiguration() takes precedence.