Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bcdfee6
Materialise a fresh settings model per read instead of caching one
sadeqabuhattem Oct 3, 2026
a4d5b85
Add ISettingProjector<T> and tag the generated endpoints
sadeqabuhattem Oct 3, 2026
4b4cc73
Drop the EF Core marker interface and infer the provider
sadeqabuhattem Oct 3, 2026
5e9ccf7
Record the API-gap fixes in the changelog
sadeqabuhattem Oct 3, 2026
623c48a
Drop the column alignment from the PaymentSettings doc sample
sadeqabuhattem Oct 4, 2026
8edd75a
Add the sample-app API gap review
sadeqabuhattem Oct 4, 2026
b1c99da
Replace the audit trail with a write-observer hook
sadeqabuhattem Oct 4, 2026
a10b1c5
Write only the named property in SetAsync(selector, value)
sadeqabuhattem Oct 4, 2026
44c5ffb
Propagate settings writes between application instances
sadeqabuhattem Oct 4, 2026
3481ab5
Add group-list and schema endpoints, and a constraints port
sadeqabuhattem Oct 4, 2026
835e2f9
State ISettingProjector<T>'s failure semantics
sadeqabuhattem Oct 4, 2026
6fd231a
Assert the Dapper Postgres bootstrap no longer creates SettingAudits
sadeqabuhattem Oct 4, 2026
4294ede
Stop the Unreleased changelog contradicting itself on DbContext
sadeqabuhattem Oct 5, 2026
955a394
Remove the API gap review now that its items are done
sadeqabuhattem Oct 5, 2026
58f5dcb
Merge every applicable schema contributor, not just the first
sadeqabuhattem Oct 6, 2026
3ce9ec2
Add UseSchemaContributor, the last port without a builder method
sadeqabuhattem Oct 6, 2026
e7675ae
Reject two projectors for one settings group at Build()
sadeqabuhattem Oct 7, 2026
1904708
Let a projector declare its response type, and publish it
sadeqabuhattem Oct 7, 2026
ca054cd
Cover ReplaceProjector keeping the declared response type in step
sadeqabuhattem Oct 7, 2026
c2c1c84
Rename ApplySettingsConfiguration to ApplySettings
sadeqabuhattem Oct 7, 2026
aed285f
Name the context overload once, under the name it ships with
sadeqabuhattem Oct 7, 2026
e8ef8d8
Drop the obsolete alias for the context overload
sadeqabuhattem Oct 7, 2026
21fceb9
Cover FluentSettingValidator and the unexercised schema rules
sadeqabuhattem Oct 10, 2026
f8f7d48
Close the README gaps this branch opened, and cut preview.2
sadeqabuhattem Oct 10, 2026
4e07e2c
Say one settings table, because there is one
sadeqabuhattem Oct 10, 2026
1b4dd65
Make the coverage gate able to fail
sadeqabuhattem Oct 10, 2026
adaf205
Stop a write replacing an undecryptable secret with its default
sadeqabuhattem Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,85 @@ jobs:
name: unit-test-results
path: ./test-results

- name: Coverage gate
# Cobertura is in the list so the threshold step below has a single merged report to
# read. The unit tests run on both TFMs, so the artifact holds two coverage files
# describing the same source: summing them would double-count, and taking either one
# alone misses what only the other compiles. ReportGenerator's merge is the honest number
# (81.55% line, against 81.95% for net10.0 by itself).
- name: Generate coverage report
uses: danielpalme/ReportGenerator-GitHub-Action@5
with:
reports: './test-results/**/coverage.cobertura.xml'
targetdir: './coverage-html'
reporttypes: 'HtmlInline;Badges;MarkdownSummaryGithub'
minimumCoverage: ${{ env.COVERAGE_THRESHOLD }}
reporttypes: 'HtmlInline;Badges;MarkdownSummaryGithub;Cobertura'
tag: ${{ github.run_number }}

- name: Publish coverage summary
if: always()
run: |
[ -f "./coverage-html/SummaryGithub.md" ] && cat ./coverage-html/SummaryGithub.md >> $GITHUB_STEP_SUMMARY || true

# Uploaded before the gate runs, and on failure too: a build that fails on coverage is
# exactly when someone wants the line-by-line report.
- uses: actions/upload-artifact@v4
if: always()
with: { name: coverage-html, path: ./coverage-html/ }

# The actual gate.
#
# This job used to pass `minimumCoverage` to the action above, which has no such input —
# GitHub logged "Unexpected input(s) 'minimumCoverage'" and ignored it, so the job
# generated a report and exited 0 whatever the coverage was. A required check named
# "Coverage Gate" that cannot fail is worse than no check: the ruleset demands it and
# everyone reads the green tick as proof.
#
# Line coverage only. Branch coverage is several points lower (75.73% merged) and gating
# on it today would fail the build over code nobody is touching; it is printed here so the
# number stays visible, and raising it is a deliberate follow-up rather than an ambush.
- name: Enforce coverage threshold
run: |
set -euo pipefail

REPORT=./coverage-html/Cobertura.xml
if [[ ! -f "${REPORT}" ]]; then
echo "::error::No merged Cobertura report at ${REPORT} — coverage cannot be verified"
exit 1
fi

python3 - "${REPORT}" "${COVERAGE_THRESHOLD}" <<'PY'
import os
import sys
import xml.etree.ElementTree as ET

report, threshold = sys.argv[1], float(sys.argv[2])
root = ET.parse(report).getroot()

# Abort on a missing or malformed rate rather than treating it as 0 or as "fine". Passing
# silently is the precise failure this step exists to replace, so it must not be reachable.
try:
line = float(root.get("line-rate")) * 100
branch = float(root.get("branch-rate")) * 100
except (TypeError, ValueError) as exc:
print(f"::error::Cannot read line-rate/branch-rate from {report}: {exc}")
sys.exit(1)

print(f"Line coverage: {line:.2f}% (threshold {threshold:.0f}%)")
print(f"Branch coverage: {branch:.2f}% (reported, not gated)")

summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as handle:
handle.write("\n| Metric | Coverage | Threshold |\n|---|---|---|\n")
handle.write(f"| Line | {line:.2f}% | {threshold:.0f}% |\n")
handle.write(f"| Branch | {branch:.2f}% | not gated |\n")

if line < threshold:
print(f"::error::Line coverage {line:.2f}% is below the {threshold:.0f}% threshold")
sys.exit(1)

print(f"Line coverage {line:.2f}% meets the {threshold:.0f}% threshold")
PY

# ───────────────────────────────────────────────────────────────────────
provider-tests:
name: Provider Tests
Expand Down
237 changes: 237 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

31 changes: 16 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ builder.Services.AddSettings()

var app = builder.Build();

// Create the database and the settings tables on first run.
// Create the database and the settings table on first run.
// In a real app, use EF Core migrations instead.
using (var scope = app.Services.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.EnsureCreatedAsync();
}

app.MapSettingsEndpoints(); // adds GET/POST /api/settings/mail-server
app.MapSettingsEndpoints(); // GET/POST /api/settings/mail-server, plus the group list and schema

app.MapGet("/", async (ISettingManager settings) =>
{
Expand All @@ -86,15 +86,12 @@ public class MailSettings
public int Port { get; set; } = 587;
}

// Your EF Core context: add the two settings tables to it.
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options), ISettingDbContext
// Your EF Core context: add the settings table to it. One line, no interface
// and no DbSet properties — the engine is read off the context.
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Setting> Settings => Set<Setting>();
public DbSet<SettingAuditEntry> SettingAudits => Set<SettingAuditEntry>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplySettingsConfiguration(DatabaseProvider.Sqlite);
=> modelBuilder.ApplySettings(this);
}
```

Expand Down Expand Up @@ -153,7 +150,9 @@ public class EmailService(ISettingManager settings)
```

Reads are cached (10 minutes by default), so reading settings on every request is cheap. A write
clears the cache on that server straight away. If you run several servers, see [Caching](https://github.com/dotnetboost/settings/blob/main/docs/caching.md).
clears the cache on the instance that made it. If you run more than one instance, add
`.SynchronizeThroughDistributedCache()` so the others pick the change up too — see
[Caching](https://github.com/dotnetboost/settings/blob/main/docs/caching.md#running-on-more-than-one-instance).

---

Expand All @@ -168,7 +167,7 @@ already have:
| Dapper / plain ADO.NET | `DotNetBoost.Settings.Dapper` | `.UseDapper(sp => new SqlConnection(cs), migrateSchema: true)` |
| MongoDB | `DotNetBoost.Settings.MongoDb` | `.UseMongoDb("mongodb://localhost:27017", "my_app_db")` |

EF Core needs two extra `DbSet`s on your context. See the
EF Core needs one line in `OnModelCreating` — no interface and no `DbSet` properties. See the
[storage providers guide](https://github.com/dotnetboost/settings/blob/main/docs/storage-providers.md)
for the full setup of each provider.

Expand All @@ -183,22 +182,24 @@ builder.Services.AddSettings()
.UseEntityFrameworkCore<AppDbContext>()
.UseAesEncryption(key) // encrypt [Sensitive] properties
.UseFluentValidation(typeof(Program).Assembly) // reject invalid values
.UseAuditStore<EfCoreAuditStore>() // keep a change history (EF Core)
.OnChanged<MailSettings, MailSettingsChanged>() // react when a value changes
.UseWriteObserver<MyWriteObserver>() // record or forward every write
.Build();
```

| I want to… | Guide |
|---|---|
| Use SQL Server, PostgreSQL or MongoDB instead of SQLite | [Storage providers](https://github.com/dotnetboost/settings/blob/main/docs/storage-providers.md) |
| Expose and secure the REST API | [REST API](https://github.com/dotnetboost/settings/blob/main/docs/rest-api.md) |
| Change what `GET` returns without changing what is stored | [Projectors](https://github.com/dotnetboost/settings/blob/main/docs/rest-api.md#reshaping-what-get-returns) |
| Reject invalid values (`[Range]`, FluentValidation) | [Validation](https://github.com/dotnetboost/settings/blob/main/docs/validation.md) |
| Encrypt passwords and API keys in the database | [Encryption](https://github.com/dotnetboost/settings/blob/main/docs/encryption.md) |
| Rename a settings class safely, set defaults | [Defining settings](https://github.com/dotnetboost/settings/blob/main/docs/defining-settings.md) |
| Run on several servers (Redis cache) | [Caching](https://github.com/dotnetboost/settings/blob/main/docs/caching.md) |
| Run on several servers and keep their caches in step | [Caching](https://github.com/dotnetboost/settings/blob/main/docs/caching.md#running-on-more-than-one-instance) |
| Run code when a setting changes | [Change notifications](https://github.com/dotnetboost/settings/blob/main/docs/change-notifications.md) |
| Stop two people overwriting each other's edits | [Reading & writing](https://github.com/dotnetboost/settings/blob/main/docs/reading-and-writing.md#concurrent-writes) |
| See who changed what, and when | [Audit trail](https://github.com/dotnetboost/settings/blob/main/docs/audit-trail.md) |
| Record or forward every write (and who made it) | [Write notifications](https://github.com/dotnetboost/settings/blob/main/docs/write-notifications.md) |
| Generate a form from the class (list groups, read the schema) | [Discovery endpoints](https://github.com/dotnetboost/settings/blob/main/docs/discovery-endpoints.md) |
| Give admins a web UI to edit settings | [Dashboard](https://github.com/dotnetboost/settings/blob/main/docs/dashboard.md) |
| See every builder option | [Configuration reference](https://github.com/dotnetboost/settings/blob/main/docs/configuration-reference.md) |
| Run the full demo stack (API, dashboard, PostgreSQL, Redis) | [.NET Aspire](https://github.com/dotnetboost/settings/blob/main/docs/aspire.md) |
Expand All @@ -223,7 +224,7 @@ See [Running everything with .NET Aspire](https://github.com/dotnetboost/setting

| Package | What it's for |
|---|---|
| `DotNetBoost.Settings.Core` | Required. The engine: `ISettingManager`, caching, encryption, audit, notifications |
| `DotNetBoost.Settings.Core` | Required. The engine: `ISettingManager`, caching, encryption, notifications |
| `DotNetBoost.Settings.EntityFrameworkCore` | Store settings through EF Core (SQL Server, PostgreSQL, SQLite) |
| `DotNetBoost.Settings.Dapper` | Store settings through Dapper (SQL Server, PostgreSQL, SQLite) |
| `DotNetBoost.Settings.MongoDb` | Store settings in MongoDB |
Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ New here? Start with the [5-minute quick start](../README.md#get-started-in-5-mi
- [Encrypting sensitive values](encryption.md): `[Sensitive]`, AES-256-GCM, key rotation
- [Validation](validation.md): Data Annotations and FluentValidation
- [Change notifications](change-notifications.md): run code when a setting changes
- [Audit trail](audit-trail.md): who changed what, and when
- [Write notifications](write-notifications.md): observe every write — what changed, by whom
- [Caching](caching.md): cache duration, Redis for multi-server deployments
- [REST API endpoints](rest-api.md): generated endpoints and **how to secure them**
- [Discovery endpoints](discovery-endpoints.md): list the groups, describe one's properties and constraints

**Tooling**
- [Dashboard (SPA client)](dashboard.md): a web UI for editing settings
Expand Down
12 changes: 7 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
SettingManager
┌───────────┬────────┼────────┬─────────────┐
│ │ │ │ │
ISettingCache ISettingStore ISettingEncryptor ISettingAuditStore
(IMemoryCache (EF Core / (AES-256-GCM (EfCoreAuditStore
or Redis) Dapper / or custom) or custom)
ISettingCache ISettingStore ISettingEncryptor ISettingValidator
(IMemoryCache (EF Core / (AES-256-GCM (DataAnnotations
or Redis) Dapper / or custom) or FluentValidation)
MongoDB)
│
ISettingChangedHandler<T>
(your app's runtime reactions)
┌───────────┴────────────┐
ISettingChangedHandler<T> ISettingWriteObserver
(typed, one group, (untyped, every group: the
in-process reactions) property diff, actor, correlation id)
```

## Repository layout
Expand Down
4 changes: 2 additions & 2 deletions docs/aspire.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ What comes up:
| Resource | What it is |
|---|---|
| `postgres` / `settingsdb` | PostgreSQL 18 container, named volume, persistent across runs |
| `pgweb` | Browser SQL client for inspecting `Settings` and `SettingAudits` |
| `pgweb` | Browser SQL client for inspecting the `Settings` table |
| `cache` | Redis container backing `RedisSettingCache` |
| `redisinsight` | Browser UI for watching the cache fill and expire |
| `api` | `samples/SampleApp` — the REST endpoints and the Scalar reference at `/scalar` |
Expand Down Expand Up @@ -61,7 +61,7 @@ written out in full and commented, so switching is four edits and no new code:

The available blocks are PostgreSQL (EF Core, active), SQL Server (EF Core), SQLite (EF Core, no
container), MongoDB, and Dapper on PostgreSQL. For the EF Core ones, also flip the
`DatabaseProvider` passed to `ApplySettingsConfiguration` in `samples/SampleApp/AppDbContext.cs` —
`DatabaseProvider` passed to `ApplySettings` in `samples/SampleApp/AppDbContext.cs` —
it decides the column type used for `Value` and how `RowVersion` is mapped.

## The Redis cache
Expand Down
24 changes: 0 additions & 24 deletions docs/audit-trail.md

This file was deleted.

59 changes: 58 additions & 1 deletion docs/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

Reads go through `ISettingCache` (default: `IMemoryCache`, 10-minute absolute expiration). A per-group `SemaphoreSlim` prevents cache stampedes under concurrent load.

**What is cached is the stored rows, not the settings object.** Every `GetAsync` materialises a
fresh instance from them, so two callers never share one: assigning to a property of what you
read changes nothing for the next reader, and nothing for `GET /api/settings/{route}`. The cache
is there to skip the round trip to the store; the mapping is reflection over a handful of
properties and is cheap to repeat.

Two consequences worth knowing:

- A `[Sensitive]` value is decrypted per read rather than once per cache entry. For a
distributed cache this is the better trade anyway — what is written to Redis is the stored
ciphertext, not the decrypted model.
- A custom `ISettingCache` is handed `Setting[]`. Implementations that serialise, like the Redis
one below, need nothing special for this; the type is public and plain.

```csharp
builder.Services.AddSettings()
.UseEntityFrameworkCore<AppDbContext>()
Expand All @@ -10,7 +24,50 @@ builder.Services.AddSettings()
.Build();
```

> **Multi-node note:** the default cache is per-instance in-memory. In a multi-node deployment, use `UseCustomCache<T>()` with a distributed cache (Redis, etc.) so a write on one node invalidates the cache on all nodes.
## Running on more than one instance

A write evicts the cache entry on the instance that made it. Nothing else happens by itself, so
every other instance goes on serving the old values until its own entry expires — up to
`CacheDuration`, ten minutes by default. "Change it, reload, done" stops being true at two
replicas.

One line fixes it, using whatever `IDistributedCache` the application already has:

```csharp
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");

builder.Services.AddSettings()
.UseEntityFrameworkCore<AppDbContext>()
.SynchronizeThroughDistributedCache()
.Build();
```

Every write publishes a fresh token for that group, and a cache hit compares its token against
the published one at most once per **`ChangeCheckInterval`, 5 seconds by default**.

**That interval is the staleness bound, and it is not zero.** An instance can serve a value
another instance has already replaced for up to five seconds. What the signal buys is that the
bound is five seconds rather than ten minutes — not that propagation is instant. Pass a
different interval if you want a different trade: `SynchronizeThroughDistributedCache(checkInterval: TimeSpan.FromSeconds(1))`
costs more round trips to Redis for a tighter bound.

The token is per group, so a write to one group does not make other groups reload. An
unreachable signalling store is logged and treated as "unchanged": a signal is an optimisation
over the cache duration, so a Redis outage should cost a slower reload, not an error. And with
no signal registered the check never fires, so a single-instance application behaves exactly as
it did before and needs no configuration.

Register your own transport — a bus, a database table, a pub/sub topic — with
`UseChangeSignal<T>()` over `ISettingChangeSignal`.

> **This is about cache coherence, and nothing else.** `ISettingChangedHandler<T>` and
> `ISettingWriteObserver` stay in-process by design: the signal makes other instances notice a
> new *value*, it does not run their handlers. If you need every node to react, publish from an
> observer onto something every node subscribes to.

### Or share one cache

The alternative is to give every instance the same cache, so there is only one entry to evict:

```csharp
public class RedisSettingCache(IConnectionMultiplexer redis) : ISettingCache
Expand Down
17 changes: 17 additions & 0 deletions docs/change-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,20 @@ builder.Services.AddSettings()

Multiple handlers per settings type are supported and run in registration order. A handler that throws is logged and does not roll back the write or block other handlers.

## Which of the two hooks you want

The library notifies you of a write in two shapes. They serve different consumers, and picking
the wrong one is mostly a matter of fighting the data you are handed.

| | `ISettingChangedHandler<T>` | [`ISettingWriteObserver`](write-notifications.md) |
|---|---|---|
| Scope | One settings group, typed | Every group, untyped |
| Payload | The `previous` and `current` models | Per-property diff, plus actor and correlation id |
| Good for | Reacting to a new value: *if `MailSettings.Host` changed, reconnect* | Recording or forwarding what happened: auditing, webhooks, indexing |
| Reports `ClearAsync` | No | Yes, as `SettingWriteKind.Cleared` |

Both are **in-process only**, and both have the same failure policy: **an exception is logged and
swallowed**, so neither can fail a write that has already committed, and one failing handler
never skips the ones after it.

Registering both is normal — they are not alternatives.
Loading
Loading