Elarion

Settings

Runtime-changeable, key/value settings with a swappable store, in-process change watching, and an AOT-clean typed accessor — global and per-user.

Elarion's settings subsystem gives applications runtime-changeable configuration: key/value data, hierarchical like IConfiguration (but the hierarchy is virtual, like environment variables), that can change while the app runs and be watched for changes in-process. It is split into two swappable sides so each can evolve independently — see ADR-0011.

Two backends ship: an in-process store (the zero-dependency default) and an EF Core database store (Elarion.Settings.EntityFrameworkCore). Change notification is in-process by default; add Elarion.Settings.PostgreSql (PostgreSQL LISTEN/NOTIFY) to propagate changes across all nodes. Consume settings natively (below), or as IConfiguration/IOptionsMonitor<T> via the Elarion.Settings.Configuration adapter. The EF Core store writes on your injected DbContext, so a SetAsync/RemoveAsync made inside a transaction commits or rolls back with it — see persistence & transactions.

Two sides

  • The sink side — where settings live and how changes are announced. ISettingsStore reads and writes values; ISettingsChangeSource hands out change tokens you can watch. Both are interfaces, so the backing store is swappable: the in-process default ships today; database and other backends implement the same contracts.
  • The consuming side — how your code reads settings. The native ISettingsManager is an AOT-clean typed accessor. Adapters to IConfiguration and IOptionsMonitor<T> ship in Elarion.Settings.Configuration, so existing code that already speaks those abstractions consumes settings without change — see Consuming as IConfiguration / IOptionsMonitor.

Scopes

A setting belongs to a SettingsScope. Two scopes ship:

  • SettingsScope.Global — system-wide, shared by everyone.
  • SettingsScope.User(userId) — bound to a specific user. SettingsScope.CurrentUser is a placeholder the accessor resolves against the ambient ICurrentUser at call time.

A scope is an open (Kind, Owner) value rather than a closed enum, so additional scopes (for example tenant or environment) can be added later without changing the contracts or the store schema.

User-scoped reads fail closed: outside an authenticated context (a background job, say, where there is no current user) requesting CurrentUser throws rather than silently reading global data — the same posture as current-user handler caching.

Keys

Keys are flat strings with a : separator, exactly like IConfiguration ("app:smtp:host"). The hierarchy is virtual — the store treats keys as opaque; only prefix watching and (future) adapters interpret the tree. Watch a prefix to observe a whole subtree.

Using the typed accessor

ISettingsManager is registered scoped by AddElarionSettings(). Typed access serializes through the canonical IElarionJsonSerialization options — a source-generated JsonTypeInfo<T>, no reflection — so it works under trimming/AOT (the repo disables reflection-based JSON by default). Register your settings types in a JSON context and contribute it to the canonical serializer (module contexts are contributed automatically by AddElarion; a standalone type uses ConfigureElarionJson):

// Program.cs
builder.Services.AddElarionSettings();
builder.Services.ConfigureElarionJson(o => o.TypeInfoResolvers.Add(AppSettingsJsonContext.Default));

// A source-generated context for your settings types.
[JsonSerializable(typeof(SmtpSettings))]
internal sealed partial class AppSettingsJsonContext : JsonSerializerContext;

public sealed record SmtpSettings {
    public required string Host { get; init; }
    public int Port { get; init; } = 25;
}
public sealed class SendEmailHandler(ISettingsManager settings) : IHandler<SendEmail, Result<Unit>> {
    public async ValueTask<Result<Unit>> HandleAsync(SendEmail request, CancellationToken ct) {
        // Global, typed, with a fallback when unset. Type info comes from the canonical serializer.
        var smtp = await settings.GetAsync(
            "app:smtp",
            fallback: new SmtpSettings { Host = "localhost" },
            cancellationToken: ct);

        // Per-user, raw string.
        var signature = await settings.GetStringAsync(
            "email:signature", SettingsScope.CurrentUser, ct);

        // ... send ...
        return Result.Success();
    }
}

Write with SetAsync/SetStringAsync. Concurrency is opt-in per write:

  • Unconditional (expectedVersion omitted / null) is last-write-wins. The update is keyed only on the setting's identity and increments the version in place, so two nodes writing the same key concurrently both succeed instead of one spuriously reporting a conflict.
  • Pass an expectedVersion to opt into optimistic concurrency. A mismatch (a lost race, or a create where the key already exists) returns SettingWriteResult.ConcurrencyConflict rather than throwing.

Watching for changes

ISettingsManager.Watch (and ISettingsChangeSource.Watch) return an IChangeToken that fires when a matching setting changes. Tokens are one-shot — re-watch, or use ChangeToken.OnChange, to keep observing.

ChangeToken.OnChange(
    () => settings.Watch("app:smtp"),
    () => _logger.LogInformation("SMTP settings changed; reloading."));

Using the EF Core database store

Elarion.Settings.EntityFrameworkCore persists settings to a relational database via your DbContext:

// In your DbContext's OnModelCreating:
modelBuilder.UseElarionSettings();   // maps the elarion_settings table

// In Program.cs (replaces the in-process store):
builder.Services.AddElarionSettingsEntityFrameworkCore<AppDbContext>();

Writes are change-tracker-free and immediate (they never flush the caller's unrelated tracked changes), with a version column backing optimistic concurrency. Generate and apply an EF migration for the elarion_settings table; the framework ships no migrations.

Database providers. The store is provider-neutral EF Core: table and column names are resolved from your model, and the two raw statements it issues (the create-path INSERT and the last-write-wins UPDATE … RETURNING) are built from that model rather than hard-coded for one dialect. PostgreSQL and SQLite are both exercised by the test suite; any relational provider whose SQL supports RETURNING should behave the same (SQLite has supported it since 3.35 — every bundled Microsoft.Data.Sqlite native build is far newer). On SQLite, pair the store with the default in-process change source: a SQLite database file implies a single application node, and in-process notification already reaches every watcher in that process. The multi-node change source below is deliberately PostgreSQL-only — with SQLite there is no second node to notify.

Change notification and transactions. A store write enlists in the context's ambient transaction when one is open, and the default in-process notifier is commit-gated to match: a write made outside a transaction is announced immediately (it is already durable), while a write made inside a caller-owned transaction is deferred and announced only when that transaction commits — and dropped on rollback, so a watcher never reloads a value a rollback discards. This works out of the box — AddElarionSettingsEntityFrameworkCore<TContext> auto-attaches an EF Core transaction interceptor to TContext — so single-node live reload of IConfiguration/IOptionsMonitor<T> and the scheduler applies as soon as the command that changed the setting commits (a setting changed from a command handler no longer needs a restart to take effect). The one thing the in-process notifier cannot do is cross process boundaries; the PostgreSQL change source below extends the same commit-gated delivery to every node.

Single-instance notification. With only the shipped in-process source, a settings change on one node is not observed by another node's watchers or scheduler until that node restarts. When the EF Core store is paired with the in-process source, AddElarionSettingsEntityFrameworkCore registers a startup service that logs a Warning making this limitation visible at runtime; registering the PostgreSQL source below silences it. In a deliberately single-node deployment — a SQLite-backed host, say — the warning is informational and safe to ignore: there is no other node whose watchers could go stale.

Multi-node change notification (PostgreSQL LISTEN/NOTIFY)

Elarion.Settings.PostgreSql makes change notification cross-instance over the database the settings already live in — no extra infrastructure:

// In Program.cs, next to the EF Core store (either order):
builder.Services.AddElarionSettingsEntityFrameworkCore<AppDbContext>();
builder.Services.AddElarionPostgreSqlSettingsChanges(connectionString);
// or, if the host already manages an NpgsqlDataSource:
builder.Services.AddElarionPostgreSqlSettingsChanges(dataSource);

A write on any node now fires IChangeToken watchers on every node — ISettingsManager.Watch, the settings IConfiguration provider, IOptionsMonitor<T>, and the scheduler's ${...} live rescheduling all follow. How it works:

  • A hosted listener holds one dedicated LISTEN connection per node and fires the matching watch tokens for each received notification, reconnecting with exponential backoff if the connection drops. PostgreSQL does not queue notifications for absent listeners, so after a reconnect the listener fires all watches — a spurious re-read is cheap and always converges.
  • Watch tokens fire only from the notification loop: a local write loops back through the database like a remote one, so every node — including the writer — observes changes in commit order through one path.
  • The EF Core store's writes are announced with pg_notify on the store's own connection, which PostgreSQL makes transactional: a write inside a caller-owned transaction is announced only when that transaction commits, and never on rollback. Transactional writes are therefore fully notified with this backend.
  • The channel (elarion_settings_changed by default) and the reconnect backoff are configurable via PostgreSqlSettingsChangeOptions; two applications sharing one database should use distinct channels.

The store's DbContext must target the same PostgreSQL database as the connection string / data source given to AddElarionPostgreSqlSettingsChanges. See ADR-0024 for the design and alternatives.

UseElarionSettings takes optional tableName and schema parameters to rename the table or place it in a non-default schema, plus a snakeCase toggle (default true; false switches to PascalCase names — the default table becomes ElarionSettings): modelBuilder.UseElarionSettings("app_settings", "app").

On a [GenerateDbSets] context you can skip the hand-written call: annotate the context with [GenerateElarionSettings] (optionally with SnakeCase/TableName/Schema) and the bundled generator emits the DbSet<Setting> and applies the same model configuration through the EF generator's model-config seam (ELSET001 if [GenerateDbSets] is missing).

Consuming as IConfiguration / IOptionsMonitor

If you prefer the standard .NET abstractions, Elarion.Settings.Configuration surfaces the global settings as an IConfiguration provider with live reload:

// On the host builder (WebApplicationBuilder / HostApplicationBuilder):
builder.AddElarionSettingsConfiguration();

Now builder.Configuration["app:title"] reads from settings, IOptionsMonitor<T> reloads when a setting changes, and any code already reading IConfiguration — including the scheduler's ${...} variable substitution, which re-resolves on every occurrence — picks up runtime changes automatically. Because IConfiguration is built before the DI container, the background refresher performs the initial load in its StartAsync, so it completes before later-registered hosted services (notably the scheduler) start — settings-backed ${...} placeholders resolve to their stored values, not stale defaults. The initial load is time-bounded, so a slow or unavailable store cannot hang startup; on timeout the provider starts empty and values populate on the next change. Call AddElarionSettingsConfiguration before registering any hosted service that reads settings-backed configuration at start. Only the Global scope is surfaced; per-user settings are not app-wide configuration, so read those through ISettingsManager.

Scheduler integration

Settings flow into the scheduler through variable substitution: a job's ${...} schedule variables resolve from the (config-backed) variable source, and the settings IConfiguration adapter is observable, so changing a setting reschedules affected recurring jobs live (not just on their next fire). No scheduler-specific wiring is needed.

Roadmap

The foundation, the EF Core database store, the IConfiguration/IOptionsMonitor adapter, the scheduler integration, and the cross-instance PostgreSQL LISTEN/NOTIFY change source ship today. A Redis pub/sub change source is a possible future provider over the same contracts. See ADR-0011 for the full design and phasing and ADR-0024 for the multi-node backend.

On this page