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.
ISettingsStorereads and writes values;ISettingsChangeSourcehands 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
ISettingsManageris an AOT-clean typed accessor. Adapters toIConfigurationandIOptionsMonitor<T>ship inElarion.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.CurrentUseris a placeholder the accessor resolves against the ambientICurrentUserat 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 (
expectedVersionomitted /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
expectedVersionto opt into optimistic concurrency. A mismatch (a lost race, or a create where the key already exists) returnsSettingWriteResult.ConcurrencyConflictrather 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
LISTENconnection 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_notifyon 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_changedby default) and the reconnect backoff are configurable viaPostgreSqlSettingsChangeOptions; 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.
Audit trail
Declarative, transport-neutral audit records — who performed which action on which resource, with field-level change capture, success records committed atomically with the business transaction.
Persistence & transactions
How Elarion's EF Core stores and the event buses participate in the caller's database transaction — what commits and rolls back together, and what is delivered after commit.