ktsu.AppDataStorage: Zero-Friction Config Persistence in .NET

Persisting application configuration is one of those tasks that every .NET project ends up doing, every project gets slightly wrong, and every project regrets having rolled its own. The first version is a one-line File.WriteAllText against a JSON-serialized settings object. The second version handles the case where the file doesn't exist yet. The third version handles the case where the file is corrupt. The fourth version debounces writes because the settings object was being saved on every property change. The fifth version atomic-writes via a temporary file because the third version's "file is corrupt" handler kept hitting cases where a write was interrupted mid-flight. Each version is reasonable. Added up across projects, the wasted effort is real.

ktsu.AppDataStorage is the small library I built so I would never have to write any of those versions again. It does the boring, safe persistence work (backups, debouncing, atomic writes, thread-safety, corrupt-file recovery) behind a one-line API that gets out of the way.

The Smallest Useful Example

using ktsu.AppDataStorage;

public class MySettings : AppData<MySettings>
{
    public string Theme { get; set; } = "light";
    public int FontSize { get; set; } = 14;
    public bool AutoSave { get; set; } = true;
}

// Load existing data or create a new instance
var settings = MySettings.LoadOrCreate();
Console.WriteLine(settings.Theme);    // "light"

That's the whole API for the common case. Inherit from AppData<T>. Call LoadOrCreate(). Done.

The file lives in the user's application data folder, named after the type, serialized as JSON. The library handles the directory creation. The library handles the case where the file already exists. The library handles the case where the file exists but is corrupt (it falls back to the most recent backup).

For the every-access case, the singleton form is even shorter:

// Access the singleton from anywhere
var settings = MySettings.Get();
settings.FontSize = 16;
settings.QueueSave();

Get() is lazy-initialized. The first call does the LoadOrCreate, and subsequent calls return the cached instance. QueueSave() debounces, so calling it ten times in two seconds produces exactly one disk write three seconds later, not ten.

The Safety Mechanisms

The reason the library exists, more than the API ergonomics, is the set of things it does behind the API.

Atomic writes. Every save goes to a temporary file first, then atomically replaces the original. A power loss or a crash mid-save can't corrupt the settings file, because the original is either still intact or the replacement has completed. The temp-file approach is well-understood and exactly what most people would write if they sat down to write this code themselves.

Automatic backups. Before overwriting an existing file, the library creates a backup. If the new write succeeds but turns out to have been wrong (a serialization bug that lost data, a schema change that wiped fields), the backup is there. Timestamped collision handling means backups don't get lost when several saves happen close together.

Corrupt-file recovery. On load, if the main file is missing or fails to deserialize, the library falls back to the most recent backup automatically. The application starts with the last known-good state instead of crashing or starting with defaults.

Debounced saves. QueueSave() registers an intent to save, and SaveIfRequired() (called from a tick or timer) actually flushes the queued save after a 3-second window. Settings UI tends to fire change events on every keystroke or slider movement, and writing to disk on every event is wasteful and feels sluggish. Debouncing turns 100 keystrokes into one disk write.

Dispose-on-exit. The library registers for process exit and flushes queued saves before the application terminates. Closing the application doesn't lose the last batch of settings changes just because the debounce window hadn't fired yet.

Thread-safe operations. All file operations are synchronized. On .NET 9+, the library uses the new Lock type, and on earlier versions, plain object locks. The same instance can be read and written from multiple threads without races.

Each of these is the kind of thing that gets bolted onto hand-rolled config code after hitting the bug it prevents. The library has them out of the box.

What's in and What's Out

The library is opinionated about being small. A few things it deliberately doesn't do, with the reasoning:

It doesn't do schema migration. If the settings type changes shape and the existing on-disk file no longer matches, the library will fail to deserialize and fall back to the backup. It will not run a migration step automatically. Schema migration is project-specific in ways the library can't sensibly generalize, and getting it wrong silently is worse than failing loudly. The migration belongs in application code.

It doesn't do remote sync. The data lives in the local application data folder. Syncing across devices is a separate concern with its own design decisions (which device wins, when to sync, what to do on conflict) that don't belong in a library this small.

It doesn't do encrypted storage. For persisting secrets, this isn't the library. The data is JSON on disk, readable by anyone with access to the user's profile folder. For application settings, that's fine. For credentials or tokens, OS-level secure storage is the right tool.

It doesn't do partial saves. Writes are whole-file. There's no "save just this property" affordance. Partial saves are difficult to get right with consistent backup semantics, and the whole-file model is fast enough for the size of data a settings file actually contains.

The flip side of being opinionated about smallness is that the library is easy to read end-to-end and easy to reason about. The code that does the saving is short. The behavior is predictable. There aren't many surprises left to discover the third year of using it.

File System Abstraction

For testing, the library uses System.IO.Abstractions for all file operations. In practice that means a mock file system can be swapped in for unit tests, and settings-handling code becomes testable without touching the disk.

// In tests, inject a mock file system
var mockFileSystem = new MockFileSystem();
MySettings.FileSystem = mockFileSystem;

// Now LoadOrCreate operates against the mock, not real disk
var settings = MySettings.LoadOrCreate();

This is easy to forget about until the third test that needs to manipulate config state, and at that point it's exactly the thing that should have been there from the start. Having it built in means the testing path is clear from day one.

Custom Storage Locations

The default behavior (write to the standard application data folder, named after the type) is right for most cases. When it isn't, there are overloads:

// Custom subdirectory
var settings = MySettings.LoadOrCreate(subdirectory: "MyCompany/MyApp");

// Custom filename
var settings = MySettings.LoadOrCreate(filename: "user-preferences.json");

// Both
var settings = MySettings.LoadOrCreate(
    subdirectory: "MyCompany/MyApp",
    filename: "user-preferences.json");

This is useful when an application has multiple settings types that should live together, or when the file needs to match a specific name for compatibility with another tool.

Where It Doesn't Fit

A few honest cases where this library isn't the right choice.

Tiny applications with one or two config values. A single appsettings.json and a one-line read is fine. The library's machinery is overkill for ten lines of config.

Server applications with externalized config. When config lives in environment variables, a config service, or a sidecar, the library isn't doing anything that's needed. It's designed for desktop applications where the user's settings live alongside the user.

Performance-critical hot paths. The library is fast enough for settings, meaning saves measured in seconds and reads measured in milliseconds. Persisting state at frame rate calls for a different kind of solution.

Where It Fits

The library fits a desktop or workstation .NET application best: one or more class-shaped settings types, where saves happen on user actions and reads happen on startup and occasionally while running. Tools, utilities, editors, custom workflow apps, anything that needs to remember what the user was doing last time without the user thinking about it.

The library is on NuGet:

dotnet add package ktsu.AppDataStorage

The one-line API is what makes the difference. The first time the thought is "I need to persist a config object across application runs" and the answer is LoadOrCreate() and QueueSave() rather than "let me roll the persistence layer for this project too," the library has paid for itself.