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!