A support engineer opens our admin web UI, changes a timeout, and saves. The next time the service reads that setting, it gets the new value. Nobody restarts anything, which matters a lot when the service is running a forecourt with customers at the pumps.
That's runtime configuration in .NET done the old-fashioned way, and it still pays for itself. This post walks through how the framework I work in does it: an attribute that marks config-owning classes, reflection so the UI can list every setting, a cache with an expiry, and the one hard-coded value that stops the cache reading its own settings forever.
This is Part 3 of Legacy .NET That Still Pays. As with Part 2, the pattern belongs to the framework we build on, and the names below are neutral. I'm writing up how it works and where it bites.
The weak version: read once at start-up
Here's how most services start life:
public sealed class PaymentWorker
{
private readonly TimeSpan _paymentTimeout;
public PaymentWorker(IConfiguration config)
{
_paymentTimeout = config.GetValue("Payments:Timeout", TimeSpan.FromSeconds(30));
}
}The value is read once, into a field, when the object's built. It's simple, fast and easy to test. It also means that changing the setting needs a restart, because nothing ever reads it again.
For a web app behind a load balancer, a rolling restart is routine. For a Windows service on a forecourt, a restart drops connections to terminals and pump controllers, and has to be timed around trading. "Change a setting" shouldn't mean "schedule an outage".
The better version: settings that know where they live
The framework's version has three pieces.
Typed settings with a key and a default
Each setting is a property of a small typed wrapper, constructed with its owner, its key and its default:
[ExposesSettings]
public sealed class PaymentWorker : Worker
{
public IntSetting PaymentTimeoutSeconds { get; } =
new IntSetting(owner: this, key: "PAYMENTS::Timeout:Seconds:", defaultValue: 30);
private void StartPayment()
{
var timeout = TimeSpan.FromSeconds(PaymentTimeoutSeconds.GetValue());
// ...
}
}The worker never stores the value. It asks for it at the moment it needs it, through GetValue(), every time. That's the whole trick, and it's also the rule people break, which I'll come back to.
There are wrappers for the usual types: booleans, integers, strings and time spans. Each one parses the stored string into its type, and falls back to the default if the stored value is missing.
An attribute and some reflection
The attribute marks the class as owning settings. At runtime, reflection finds every setting property on every marked class, and merges their keys and defaults with the values stored in the database.
That's what lets the admin UI list every setting in the service, grouped by category, with its current value and its default, without anyone maintaining a separate list. Add a property to a marked class, and it shows up in the UI on the next start.
It's also what makes a "revert to default" button possible, because the UI knows each setting's default. That button has a story of its own, which I'll get to.
A cache that can be told to forget
Reading from the database on every GetValue() would be far too slow for a setting read in a hot path. So reads go through an in-memory cache, and each cached item has a lifetime.
When someone saves a setting in the UI, the save writes to the database and then force-expires that one cached item. The next GetValue() misses the cache, reads the new value and caches it again. That's what "takes effect on the next read" means: not instant, not on a timer, but on the very next read after the save.
The framework prefers expiring the one affected item over expiring everything. Clearing the whole cache on every save would work, and it would make every setting in the service pay a database read for one person's change to one timeout.
Why typed wrappers and not strings
It would be simpler to read every setting as a string and parse it where it's used. The wrappers exist so that parsing and defaults live in one place. Every caller that reads the payment timeout gets an integer, with the same default, parsed the same way. Nobody writes their own int.TryParse with their own fallback and quietly disagrees with everyone else.
The setting that has to read itself
Here's the part I like most, because it's a genuine puzzle.
Cache lifetimes are settings too. Different kinds of cached data can live for different lengths of time, and support can change them. So when the cache stores an item, it asks configuration how long that kind of item should live.
Now follow the config repository itself. Configuration is read through the cache, so the repository's data is a cached item, and to cache it, the cache has to ask configuration how long it should live. Asking configuration means reading through the cache, which means asking configuration how long to cache that, and so on, forever.
The fix is small and completely unglamorous. For that one kind of item, the config repository's own data, the cache doesn't ask configuration at all. It uses a hard-coded default lifetime and returns straight away:
private TimeSpan GetLifetime(string itemType)
{
// The config repository is itself cached; asking config for its lifetime would recurse.
if (itemType == ConfigRepositoryItemType)
return DefaultLifetime;
return _config.GetValue($"CACHE::Lifetime:{itemType}", DefaultLifetime);
}Every self-configuring system needs one value it doesn't configure. It's the same reason a bootloader isn't loaded by the operating system. The one hard-coded default is the floor the rest of the system stands on, and trying to make it configurable just moves the recursion somewhere else.
Where "the next read" isn't the next read
The design has one rule that everything else depends on: always read through GetValue(), never copy the value somewhere and use the copy.
When someone breaks that rule, the setting looks right everywhere and behaves wrong. I found a bug like that in the forecourt series, where a kiosk timeout only took effect on the third button press. The value in the database was right, and the cache was right. But one part of the service had copied the value into its own field, and only refreshed that copy in one rarely used mode.
That's the failure mode of every runtime-config system, not just this one. The framework can guarantee the next GetValue() sees the change. It can't guarantee that anyone calls it. A field initialised from a setting, a value captured in a closure, or a timer created with the old interval all keep the old value until something rebuilds them.
The framework does handle one common case well. Background tasks re-read their interval setting on every cycle, instead of once when they start. So changing how often a worker polls takes effect after the current wait, without restarting the worker.
Changes that don't go through the UI
Force-expiry only happens on the UI's save path. If a value changes some other way, a support script, a database migration or someone editing a row directly, nothing expires the cache. The service carries on with the cached value until its lifetime runs out.
That's the trade-off in choosing a cache lifetime. A long one means fewer database reads and a longer wait for any change that bypasses the UI. A short one means changes land sooner and every setting costs more reads. Knowing which path your changes take is half of picking the number.
The other half is how much a stale value would actually hurt. A cached display label can be stale for hours; a cached payment timeout probably shouldn't be.
Validate before you save
Runtime changes need a guard at the door, because there's no restart and no deployment pipeline between a typo and production. The framework runs validators on each setting before a save reaches the database: is this a valid integer, a valid boolean, a valid time of day.
If a stored value can't be parsed, the typed wrapper quietly falls back to its default. That's safe, but it also hides the mistake. Someone types a bad interval, saves it, the service uses the default, and nobody finds out until the behaviour doesn't change the way they expected.
Validation on save turns that silent fallback into an error the person can see and fix straight away. I'll come back to one of those validators in Part 6.
How modern .NET compares
Modern .NET has most of this built in. IOptionsMonitor<T> gives you the current value of a settings object and a change notification, and IOptionsSnapshot<T> gives you a fresh value per request. With file-based configuration and reload on change, editing appsettings.json flows through without a restart.
For settings stored in a database, you write a custom configuration provider and signal its reload token when a save happens. That gets you close to what the framework does, with the ecosystem's types instead of home-grown ones.
What the modern options don't give you for free is the admin UI. The attribute-and-reflection approach means every setting is discoverable, with its default, without a separate registry. You could build that on top of the options pattern, but you'd be building it.
Where it falls over
Nothing stops someone copying the value. The design only works if everyone reads through GetValue(), and the third-press bug shows what happens when one caller doesn't. An analyser could flag setting values assigned to fields. We don't have one.
Defaults only exist for marked classes. A class that owns settings but is missing the attribute still works, but its keys have no default as far as the UI is concerned. When I fixed a revert-to-default bug in the admin UI, I audited the settings with no default: 21 keys with 5 different causes, and a missing attribute was one of them. The feature is only as complete as the attributes are.
The recursion guard is a value nobody can change without a release. That's the point of it, and it's also a limit worth knowing about when someone asks why one particular cache lifetime ignores the settings page.
And every read pays for a cache lookup. That's cheap, far cheaper than a database read, but it isn't free, and a setting read in a very tight loop is still worth reading once per iteration of the outer loop rather than per item.
Read it where you use it
My position: if a service is expensive to restart, its settings should be read where they're used, through something that knows how to expire a stale value, not copied into fields at start-up. The mechanism can be an options monitor or a home-grown wrapper. The rule is the same either way.
If you're adding runtime configuration to a service, pick your mechanism, then make one rule and review for it: never store a setting's value, always ask for it. Expire only what changed. Validate on save, so a bad value is an error and not a silent default. And find the one value your configuration system needs before it can read anything else, and hard-code it on purpose.
Next in the series: keeping two async steps in a fixed order without blocking on .Result. The rest of my writing is on the blog.