Today, let’s talk about settings.
Every application has them. And every time, we write the same code to load, save, and watch for changes. This gets boring quickly.
So I built a small toolkit inside Nochein.Toolkit. The main idea is simple: create one way to store settings and reuse it in every project. No more copying code around. And yes, it can watch the settings file and reload when needed (not yet implemented).
The heart of this system is an interface called ISettings. It represents one section of your settings.
// This interface defines a single settings section
public interface ISettings
{
// The unique name of this section (like "common" or "editor")
public string Key { get; }
// Get a stream of changes. Every time something changes,
// you get a fresh JsonObject with all values from this section
public IObservable<JsonObject> Observe();
// Take a JsonObject and apply its values to this settings section
public void Load(JsonObject jsonObject);
}
You can see that ISettings does not force you to use any specific way to store values. You could use plain C# objects if you like. But I really like the observable pattern. It lets parts of your app react to changes automatically. So let me introduce IObservableValue<T>.
// A value you can watch for changes
public interface IObservableValue<T> : IObservable<T>
{
// The current value. You can read it any time.
public T Value { get; }
}
This interface extends the standard IObservable<T> by adding a Value property. Now you can both watch changes and grab the current value right away.
I made two versions of this interface. First is ObservableValue<T>. You can both read and write its value.
// A value that can be changed from outside
public sealed class ObservableValue<T> : IObservableValue<T>, IDisposable
{
// BehaviorSubject holds the current value and sends it to new subscribers
private readonly BehaviorSubject<T> _subject;
// Start with an initial value
public ObservableValue(T value)
{
_subject = new BehaviorSubject<T>(value);
}
// Get or set the current value
public T Value
{
get => _subject.Value;
set => _subject.OnNext(value); // Notify all subscribers
}
// Subscribe to changes. We add DistinctUntilChanged so you only get
// notifications when the value actually changes, not on every same value.
public IDisposable Subscribe(IObserver<T> observer) => _subject
.AsObservable()
.DistinctUntilChanged(EqualityComparer<T>.Default)
.Subscribe(observer);
// Clean up the subject when we are done
public void Dispose() => _subject.Dispose();
}
The second version is ObservableReadOnlyValue<T>. It only lets you read the value, not change it. This is perfect for sharing values across your app without letting others modify them.
// A value that can only be read, not changed
public sealed class ObservableReadOnlyValue<T> : IObservableValue<T>
{
private readonly BehaviorSubject<T> _subject;
// Take a subject from somewhere else
public ObservableReadOnlyValue(BehaviorSubject<T> subject)
{
ArgumentNullException.ThrowIfNull(subject);
_subject = subject;
}
// Only get, no set
public T Value => _subject.Value;
// Same subscription logic
public IDisposable Subscribe(IObserver<T> observer) => _subject
.AsObservable()
.DistinctUntilChanged()
.Subscribe(observer);
}
Now let me show you what a real settings section looks like. Here is CommonSettings.
public class CommonSettings : ISettings, IDisposable
{
private readonly IObservable<JsonObject> _observable;
public CommonSettings()
{
// Create an observable value with a default
WindowTheme = new ObservableValue<WindowThemeSetting>(WindowThemeSetting.System);
// Build an observable that converts our settings into a JsonObject
// Every time WindowTheme changes, we produce a new JsonObject
_observable = WindowTheme
.Select(windowTheme => new JsonObject
{
["windowTheme"] = windowTheme switch
{
WindowThemeSetting.System => "System",
WindowThemeSetting.Light => "Light",
WindowThemeSetting.Dark => "Dark",
_ => "System"
}
})
.Publish().RefCount(); // Share the stream between all subscribers
}
// This section is called "common"
public string Key => "common";
// Expose the theme value so others can watch or change it
public ObservableValue<WindowThemeSetting> WindowTheme { get; }
// Load values from a JsonObject (called when reading from file)
public void Load(JsonObject jsonObject)
{
ArgumentNullException.ThrowIfNull(jsonObject);
// Try to find the "windowTheme" field
if (jsonObject.TryGetPropertyValue("windowTheme", out var themeNode) && themeNode is not null)
{
// Convert from string back to our enum
WindowTheme.Value = themeNode.GetValue<string>() switch
{
"System" => WindowThemeSetting.System,
"Light" => WindowThemeSetting.Light,
"Dark" => WindowThemeSetting.Dark,
_ => WindowThemeSetting.System
};
}
}
// Return the observable that produces JsonObjects for saving
public IObservable<JsonObject> Observe() => _observable;
// Clean up
public void Dispose()
{
WindowTheme.Dispose();
}
}
You register your settings with a simple builder.
private static void ConfigureSettings(ISettingsBuilder builder)
{
// Just add your settings class. The toolkit handles the rest.
builder.Add<CommonSettings>();
}
Then you can inject it anywhere. For example, in a view model.
public sealed class SettingsViewModel : ViewModelBase
{
// The toolkit gives you the exact instance you registered
public SettingsViewModel(
CommonSettings commonSettings,
IApplicationContext applicationContext,
IWindow window) : base(window)
{
// Now you can read commonSettings.WindowTheme.Value
// or subscribe to commonSettings.WindowTheme to watch for changes
}
}
All the hard work happens inside the toolkit. Let me explain the internal pieces.
First is SettingsBuilder. It collects all your settings and prepares them for dependency injection.
internal sealed class SettingsBuilder : ISettingsBuilder
{
private readonly List<ServiceDescriptor> _descriptors = [];
public void Add<T>() where T : class, ISettings
{
// Register the settings as itself (for direct injection)
_descriptors.Add(ServiceDescriptor.Singleton<T, T>());
// Also register it as ISettings (for collection injection)
_descriptors.Add(ServiceDescriptor.Singleton<ISettings>(
static services => services.GetRequiredService<T>()));
}
// Give back all descriptors so the DI container can use them
public ReadOnlyCollection<ServiceDescriptor> Descriptors { get; }
}
Next is SettingsStorage. This class handles reading and writing the actual file. It uses a lock to prevent two operations at the same time. It also writes to a temporary file first and then moves it. This way, if something crashes, you do not lose your whole settings file.
internal sealed class SettingsStorage : ISettingsStorage, IDisposable
{
private readonly SemaphoreSlim _fileLock = new(1, 1); // Only one operation at a time
private readonly ILogger _logger;
public SettingsStorage(IApplicationPaths applicationPaths, ILogger logger)
{
// The file lives in the user data folder
FileName = Path.Combine(applicationPaths.UserData, "application.config");
}
public string FileName { get; }
// Load the entire JSON file as a JsonObject
public async Task<JsonObject?> LoadAsync(CancellationToken cancellationToken)
{
await _fileLock.WaitAsync(cancellationToken);
try
{
if (!File.Exists(FileName))
return null; // No file yet, use defaults
var json = await File.ReadAllTextAsync(FileName, cancellationToken);
var rootNode = JsonNode.Parse(json);
return rootNode as JsonObject;
}
catch (JsonException)
{
return null; // Bad JSON, use defaults
}
finally
{
_fileLock.Release();
}
}
// Save the whole JsonObject to file
public async Task SaveAsync(JsonObject rootObject, CancellationToken cancellationToken)
{
await _fileLock.WaitAsync(cancellationToken);
try
{
// Write to a temp file first
var tempFile = Path.GetTempFileName();
var json = rootObject.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(tempFile, json, cancellationToken);
// Then move it to the real location (atomic operation)
File.Move(tempFile, FileName, overwrite: true);
}
finally
{
_fileLock.Release();
}
}
public void Dispose() => _fileLock.Dispose();
}
Finally, we have the main class: SettingsService. This class rules them all. It collects all settings sections, loads them from storage, watches for changes, and saves them back automatically.
internal sealed class SettingsService : IDisposable
{
private readonly Dictionary<string, ISettings> _settingsDictionary;
private readonly ISettingsStorage _storage;
private IDisposable? _changedSubscription;
private volatile bool _loading; // Prevents saving while loading
public SettingsService(
IEnumerable<ISettings> settingsCollection, // All registered settings
ISettingsStorage storage,
ILogger logger)
{
// Build a dictionary for quick lookup by key
_settingsDictionary = settingsCollection.ToDictionary(s => s.Key);
_storage = storage;
// Watch all settings sections together
// When any section changes, wait a bit (throttle), then save
_changedSubscription = settingsCollection
.Select(settings => settings.Observe()
.Select(node => new SettingsData(settings.Key, node)))
.CombineLatest() // Combine all streams
.Skip(1) // Skip the initial empty state
.Where(_ => !_loading) // Do not save while loading
.Throttle(TimeSpan.FromMilliseconds(100)) // Wait for quiet period
.Select(data => Observable.FromAsync(ct => SaveAsync(data, ct)))
.Switch() // Cancel previous save if a new change comes
.Subscribe();
}
// Load all settings from the storage file
public async Task Load(CancellationToken cancellationToken)
{
_loading = true;
try
{
var rootNode = await _storage.LoadAsync(cancellationToken);
if (rootNode is null) return;
// Go through each section in the file
foreach (var property in rootNode)
{
var key = property.Key;
var node = property.Value as JsonObject;
if (node is not null && _settingsDictionary.TryGetValue(key, out var settings))
{
settings.Load(node); // Tell the section to update itself
}
}
}
finally
{
_loading = false;
}
}
// Save all changed settings to storage
private async Task SaveAsync(IList<SettingsData> data, CancellationToken cancellationToken)
{
var rootObject = new JsonObject();
foreach (var item in data)
{
rootObject[item.Key] = item.Node?.DeepClone();
}
await _storage.SaveAsync(rootObject, cancellationToken);
}
public void Dispose() => _changedSubscription?.Dispose();
private readonly record struct SettingsData(string Key, JsonNode Node);
}
The host builder creates the SettingsService right after the ServiceProvider is ready. This means settings are loaded almost first thing when your app starts. By the time your view models ask for settings, everything is already there.
You can find all the source code in the Nochein toolkit repository.
Happy coding!