Skip to content

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.

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

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 — streams ConfigurationChange records for real-time change notification. Accepts a key pattern (e.g., "Booking:*").

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

Record emitted by IConfigurationStore.WatchAsync:

public sealed record ConfigurationChange(
string Key,
string? OldValue,
string? NewValue,
string? TenantId,
DateTimeOffset Timestamp);

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 GetAsync falls back to base values when no tenant-specific value exists.
  • SetAsync emits a ConfigurationChange to the channel (consumed by WatchAsync).
  • WatchAsync filters changes by key prefix pattern.

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

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)

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

  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).
  3. Overlay tenant-specific values last.

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

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);
  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 as a fire-and-forget task with exception 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 MyService(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.
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);
}
}

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.

ADO.NET-based, zero EF Core dependency. Supports PostgreSQL, SQL Server, and SQLite.

Terminal window
dotnet add package Pragmatic.Configuration.Database

Three tables are auto-created: pragmatic_config, pragmatic_secrets (AES-256-GCM encrypted), pragmatic_config_audit.

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

Terminal window
dotnet add package Pragmatic.Configuration.Azure

Authentication uses DefaultAzureCredential. Keys follow Azure naming conventions with configurable prefix.

See the README for full backend configuration options.

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.