Local Data, Settings & Files

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Every desktop app eventually has to remember something: the books the user entered, the window position, the last folder they exported to, a cache that makes the next start faster. Unlike a web app, a desktop app has no server to hand that state to, so the questions are local ones. Which folder may the app write to? Does that folder survive an uninstall, a reinstall, a move to another PC? Which format fits the data — a settings file, a small database, a document the user can open elsewhere? And how does the write happen without freezing the window or corrupting the file when the process is killed halfway? This page answers those questions for the Bookshelf Desktop app and shows the concrete implementation of IBookRepository on EF Core and SQLite.

The page was written against these versions, current in October 2026:

  • .NET 10 LTS, latest patch 10.0.13, with C# 14. .NET 11 is at release candidate 2 (general availability is expected 10 to 13 November 2026). The file-system, configuration and JSON APIs used here are unchanged in .NET 11 previews as far as the release notes consulted show.

  • EF Core 10 (Microsoft.EntityFrameworkCore.Sqlite), the release that ships with .NET 10. The EF Core pages cited state the behaviour from EF Core 9 onwards (migration locking); check the package version your project resolves.

  • Windows App SDK 2.5.1 for Microsoft.Windows.Storage.ApplicationData in WinUI 3 apps.

  • CommunityToolkit.Mvvm 8.4.2 for the view models that call the repository, and Visual Studio 2026 18.11.1.

Where Desktop Apps Keep Their Data

A Windows desktop app has no private sandbox by default. An unpackaged WPF, WinForms or WinUI 3 app runs with the rights of the signed-in user and can write anywhere that user can. That freedom is the problem: the right place is a convention, and the conventions are expressed as known folders. In .NET they are the members of the Environment.SpecialFolder enumeration, read with Environment.GetFolderPath.

SpecialFolder member Documented meaning Use it for

LocalApplicationData

"Application-specific data that is used by the current, non-roaming user." Typically %LOCALAPPDATA%, that is C:\Users\<name>\AppData\Local.

The default for almost everything: the SQLite database, caches, logs, downloaded files, per-machine settings.

ApplicationData

"Application-specific data for the current roaming user." Typically %APPDATA%, C:\Users\<name>\AppData\Roaming.

Small preferences that should follow a user between machines in a domain with roaming profiles. Never databases or large files.

CommonApplicationData

"Application-specific data that is used by all users." Typically C:\ProgramData.

Data shared between users of one PC, or between the app and a service. A standard user may not be able to modify files another user created there unless the installer sets permissions.

MyDocuments (same as Personal)

The user’s documents folder.

Files the user creates and expects to find: exports, reports, a library file the user chose to save.

UserProfile

The user’s profile folder. The documentation says apps "should not create files or folders at this level" and should use ApplicationData instead.

Nothing. Do not write here.

ProgramFiles

The program files directory.

The install location of an unpackaged app. It is read-only for standard users, so never store data there.

Two properties of GetFolderPath matter in practice. The documentation says the locations "can have different values on different operating systems, the user can change some of the locations, and the locations are localized". So code must never concatenate C:\Users\ and a user name; and the first run of the app should create its own subfolder (Directory.CreateDirectory is idempotent). It also says MyComputer always yields the empty string, an example of why the result of GetFolderPath should be checked when a folder is optional.

Source: Environment.SpecialFolder enum (the descriptions quoted are from the Fields table; the typical paths are the usual Windows locations, not stated on that page).

A small folder service

The Bookshelf Desktop app resolves every path from one place, so tests can substitute a temporary folder and the rest of the code never touches Environment directly.

using System;
using System.IO;

namespace Bookshelf.Core;

/// <summary>Resolves the folders the application is allowed to write to.</summary>
public interface IAppPaths
{
    /// <summary>Per-user, per-machine data: database, caches, logs.</summary>
    string DataDirectory { get; }

    /// <summary>User settings file.</summary>
    string SettingsFile { get; }

    /// <summary>Default folder suggested for exports.</summary>
    string ExportsDirectory { get; }
}

public sealed class AppPaths : IAppPaths
{
    private const string AppFolderName = "Bookshelf";

    public AppPaths()
    {
        var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
        if (string.IsNullOrEmpty(local))
        {
            throw new InvalidOperationException("The local application data folder is not available.");
        }

        DataDirectory = Path.Combine(local, AppFolderName);
        SettingsFile = Path.Combine(DataDirectory, "settings.json");
        ExportsDirectory = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), AppFolderName);

        Directory.CreateDirectory(DataDirectory);
    }

    public string DataDirectory { get; }
    public string SettingsFile { get; }
    public string ExportsDirectory { get; }
}

Source: Environment.GetFolderPath method. The interface and class are Bookshelf code; the same pattern appears in the EF Core getting-started sample, which builds its SQLite path from LocalApplicationData.

The Windows App SDK ApplicationData class

WinUI 3 and other Windows App SDK apps also have Microsoft.Windows.Storage.ApplicationData, a class that "provides access to the application data store". Its members mirror the UWP storage model: LocalPath (the local data store, "backed up to the cloud" according to the reference), LocalCachePath ("not included in backup and restore operations"), TemporaryPath, SharedLocalPath, MachinePath, LocalSettings and the StorageFolder equivalents. It is obtained with ApplicationData.GetDefault() for the current user; for a process without package identity, GetForUnpackaged(string publisher, string product) "retrieves the ApplicationData object for an unpackaged app". The reference page does not list a roaming store or RoamingSettings for this class.

Need .NET (all stacks) Windows App SDK (any stack that can use it)

Local data folder

Environment.GetFolderPath(SpecialFolder.LocalApplicationData) plus your own subfolder

ApplicationData.GetDefault().LocalPath (packaged); ApplicationData.GetForUnpackaged(publisher, product) for an unpackaged app

Cache folder that backup skips

A subfolder you create and document

LocalCachePath

Temporary files

Path.GetTempPath()

TemporaryPath

Key-value settings

A JSON file, see below

LocalSettings container

Use the .NET folders unless the app is packaged and you want the operating system to own the location. The two cover the same need, but only the .NET API works identically in WPF, WinForms and WinUI 3, in packaged and unpackaged form, which is why the Bookshelf examples use it. GetForUnpackaged arrived in Windows App SDK 2.2.0, and 2.3.1 fixed its LocalSettings opening a roaming registry path, so an unpackaged app that uses it should reference 2.3.1 or later.

Packaged Apps: AppData Virtualization

An app packaged as MSIX still sees the normal folders — but Windows quietly redirects some of its writes. Packaged desktop apps (uap10:RuntimeBehavior="packagedClassicApp", the type WinUI 3 and Desktop Bridge apps declare) are virtualized by default, whether they run at full trust (uap10:TrustLevel="mediumIL") or in an AppContainer: the operating system tracks their AppData and HKCU writes so it can remove them on uninstall. Apps packaged with external location (win32App) are not virtualized and see the real file system and registry. On Windows 10 version 1903 and later, for a virtualized app:

  • New files and folders created under AppData\Local, AppData\Local\Microsoft, AppData\Roaming, AppData\Roaming\Microsoft and the Start Menu Programs folder are written to a "private per-user, per-app location" and merged at runtime so they appear in the real AppData.

  • On a file open, the private location is checked first; if the file isn’t there, the real AppData is used and no virtualization happens for that file.

  • Modifications to files that already exist under AppData are allowed to go through, for compatibility. Deleting under AppData is allowed if the user has permission.

  • When the package is uninstalled, the redirected writes are removed with it.

Before Windows 10 version 1903, all writes to AppData, including updates and deletes, were copy-on-write to the private location.

For Bookshelf this means: the SQLite database and settings file written by a packaged app live in the user’s LocalApplicationData as far as the code is concerned, but an uninstall removes them. That is usually the desired behaviour for caches and often undesired for a library the user spent a year building. Three consequences follow.

  1. Tell users where their data lives and offer an export (see the file section below); a database that disappears on uninstall is a support ticket.

  2. Do not point a second tool — a backup script, a command-line companion — at the "real" AppData path and expect to see the packaged app’s new files. They sit in the redirected location.

  3. Everything inside the package (C:\Program Files\WindowsApps\<package_full_name>) is read-only. Settings files shipped with the app, such as appsettings.json, can be read but never updated in place.

To keep chosen data outside the redirection (so it survives uninstall and other tools can see it), declare the unvirtualizedResources restricted capability and either turn write virtualization off with desktop6:FileSystemWriteVirtualization and desktop6:RegistryWriteVirtualization, or, on Windows 11, exclude specific AppData folders and HKCU keys with the virtualization: elements.

Source: Understanding how packaged desktop apps run on Windows ("AppData operations on Windows 10, version 1903 and later", "Common file system operations", "Uninstallation") and Flexible virtualization ("Default MSIX behavior").

User Settings

Settings are small, user-editable values: theme, last-used filter, window size, default export folder. Three designs are in common use. This page recommends the second for new code.

Approach How it works Strengths Limits

Options pattern over appsettings.json

Host.CreateApplicationBuilder loads the file from the content root into IConfiguration; IOptions<T>, IOptionsSnapshot<T> and IOptionsMonitor<T> read it.

Strongly typed, validated, reloadable (IOptionsMonitor<T>), already part of the Generic Host setup.

Read-only by design. The file sits in the install folder, which is read-only for standard users (and inside the package for MSIX). It carries application configuration, not user preferences.

A user settings JSON file in LocalApplicationData

The app reads and writes its own settings.json with System.Text.Json, and layers it over defaults.

Works in every stack, packaged or not; human-readable; trivial to back up, reset and migrate; no designer coupling.

You write the load, save and migration code (shown below).

Settings.settings / ApplicationSettingsBase (legacy)

Visual Studio generates a wrapper class; user-scoped values are saved as XML to user.config; application-scoped values are read from app.exe.config.

One-click in the WinForms designer, binding support for WinForms properties.

The user.config folder name is derived from the company, a hash of the assembly name and path, and the assembly version, so a new version can start with empty settings. No encryption. See the pitfalls below.

ApplicationData.LocalSettings (Windows App SDK)

Key-value container in the app data store.

Native to packaged WinUI 3 apps.

Only for apps that can use the Windows App SDK storage API; values are simple types.

Options pattern for configuration, a JSON file for preferences

The Generic Host page of this section shows appsettings.json and IOptions<T> for configuration such as the database file name. The options documentation states what that route can and cannot do: IOptions<TOptions> does not support reading configuration data after the app has started; IOptionsSnapshot<TOptions> is scoped; IOptionsMonitor<TOptions> is a singleton with change notifications for file-based providers. Nothing in the options pattern writes values back. That is why the preferences the user changes need their own store.

The example below is a complete settings service: defaults come from the record, the file overrides them, saving is atomic, and a corrupt file never prevents the app from starting.

using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;

namespace Bookshelf.Core;

/// <summary>Preferences the user can change. Add members; never rename without a migration.</summary>
public sealed record UserSettings
{
    public int Version { get; init; } = 2;
    public string Theme { get; init; } = "System";
    public string LastExportDirectory { get; init; } = "";
    public bool ShowOnlyFavorites { get; init; }
    public double WindowWidth { get; init; } = 1100;
    public double WindowHeight { get; init; } = 720;
}

[JsonSourceGenerationOptions(WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip,
    AllowTrailingCommas = true)]
[JsonSerializable(typeof(UserSettings))]
internal sealed partial class SettingsJsonContext : JsonSerializerContext;

public interface ISettingsService
{
    UserSettings Current { get; }
    Task LoadAsync(CancellationToken cancellationToken = default);
    Task UpdateAsync(Func<UserSettings, UserSettings> change, CancellationToken cancellationToken = default);
}

public sealed class JsonSettingsService(IAppPaths paths) : ISettingsService
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public UserSettings Current { get; private set; } = new();

    public async Task LoadAsync(CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            if (!File.Exists(paths.SettingsFile))
            {
                return;
            }

            await using var stream = new FileStream(
                paths.SettingsFile, FileMode.Open, FileAccess.Read, FileShare.Read,
                bufferSize: 4096, useAsync: true);
            var loaded = await JsonSerializer.DeserializeAsync(
                stream, SettingsJsonContext.Default.UserSettings, cancellationToken);
            Current = loaded ?? new UserSettings();
        }
        catch (JsonException)
        {
            // A hand-edited or truncated file must not stop the app: keep the defaults.
            Current = new UserSettings();
        }
        catch (IOException)
        {
            Current = new UserSettings();
        }
        finally
        {
            _gate.Release();
        }
    }

    public async Task UpdateAsync(Func<UserSettings, UserSettings> change, CancellationToken cancellationToken = default)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            var updated = change(Current);
            await AtomicFile.WriteAsync(
                paths.SettingsFile,
                (stream, ct) => JsonSerializer.SerializeAsync(
                    stream, updated, SettingsJsonContext.Default.UserSettings, ct),
                cancellationToken);
            Current = updated;
        }
        finally
        {
            _gate.Release();
        }
    }
}

Source: System.Text.Json overview (reflection versus source generation, thread safety of locked JsonSerializerOptions) and Options pattern. The settings record, the service and the UpdateAsync shape are Bookshelf code; the page documents the serializer and options APIs, not a settings-file design.

AtomicFile.WriteAsync is defined in the file section below. A view model uses the service like any other dependency:

using System.Threading.Tasks;
using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace Bookshelf.Desktop.ViewModels;

public sealed partial class SettingsViewModel(ISettingsService settings) : ObservableObject
{
    [ObservableProperty]
    public partial string Theme { get; set; } = settings.Current.Theme;

    [ObservableProperty]
    public partial bool ShowOnlyFavorites { get; set; } = settings.Current.ShowOnlyFavorites;

    [RelayCommand]
    private Task SaveAsync() =>
        settings.UpdateAsync(s => s with { Theme = Theme, ShowOnlyFavorites = ShowOnlyFavorites });
}

Source: ObservableProperty attribute and RelayCommand attribute. SettingsViewModel is a Bookshelf addition.

The legacy Settings.settings route

The Windows Forms documentation describes application settings as persisting data as XML to configuration files: application-scoped settings (the applicationSettings element, generally in app.exe.config) are mostly read-only, user-scoped settings (the userSettings element) are written to user.config, which the architecture creates the first time the application saves settings for that user. The default provider, LocalFileSettingsProvider, stores user-scoped settings under LocalApplicationData, in a subfolder derived from the company name, a hash of the assembly’s friendly name and strong name (or folder path), and the assembly version. The documentation draws the consequence itself: "If any of the preceding assembly details change, the prior user-scoped settings are lost because a new subfolder name is generated", and for settings that must survive upgrades "create a custom settings provider". The same page states that there is no built-in encryption and warns against storing database passwords in clear text.

The page is titled for Windows Forms and the designer integration is a WinForms feature; the generated Settings class and ApplicationSettingsBase live in System.Configuration and are usable from WPF as well, but the official WPF documentation does not describe them, so this page does not claim support beyond what the WinForms page says. Existing apps can keep them; new apps are better served by a JSON file.

Secrets are not settings

Neither a settings file nor user.config is the place for a password, an API token or a connection string with a credential. Both are plain text readable by anyone who can read the profile. Store secrets with the Windows Credential Locker or DPAPI (ProtectedData), or better, do not keep them at all and authenticate to a service with a token flow. Bookshelf’s SQLite connection string contains only a path, so nothing here is secret.

EF Core with SQLite in a Desktop App

For structured data a settings file is the wrong tool and the answer is a database. SQLite is the natural choice on the desktop: a single file, no server, no installer, transactional, with a first-party EF Core provider. The EF Core documentation lists the provider package as Microsoft.EntityFrameworkCore.Sqlite and the engine as "SQLite (3.46.1 onwards)". The getting-started tutorial creates its database in LocalApplicationData, exactly where the previous section sent us.

The Bookshelf layering from the Dependency Injection page stays the same, and this section fills in the details:

Project Contents

Bookshelf.Core

Book, IBookRepository, IAppPaths, ISettingsService. No reference to EF Core.

Bookshelf.Data

BookEntity, BookshelfDbContext, SqliteBookRepository, migrations, DatabaseInitializer. References Microsoft.EntityFrameworkCore.Sqlite.

Bookshelf.Desktop

WPF (or WinUI 3 / WinForms) app, view models, IDialogService; composition root registers the data services.

dotnet add src/Bookshelf.Data package Microsoft.EntityFrameworkCore.Sqlite
dotnet add src/Bookshelf.Data package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-ef

Source: Getting Started - EF Core (the package, Design package and dotnet-ef install steps are from the "Install Entity Framework Core" and "Create the database" sections).

The model

Book stays the immutable record defined in Bookshelf.Core: public sealed record Book(int Id, string Title, string Author, int Year = 0, decimal Price = 0m, string? Isbn = null). EF Core maps classes with settable properties, so the data project keeps a separate persistence type and converts at the boundary. That also keeps tracked entities, lazy loading and EF namespaces out of the view models.

using System;
using Bookshelf.Core;
using Microsoft.EntityFrameworkCore;

namespace Bookshelf.Data;

public sealed class BookEntity
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string Author { get; set; } = "";
    public int Year { get; set; }
    public decimal Price { get; set; }
    public string? Isbn { get; set; }

    /// <summary>Application-managed concurrency token (SQLite has no native one).</summary>
    public Guid Version { get; set; } = Guid.NewGuid();

    public Book ToBook() => new(Id, Title, Author, Year, Price, Isbn);

    public static BookEntity FromBook(Book book) => new()
    {
        Id = book.Id,
        Title = book.Title,
        Author = book.Author,
        Year = book.Year,
        Price = book.Price,
        Isbn = book.Isbn,
    };

    public void CopyFrom(Book book)
    {
        Title = book.Title;
        Author = book.Author;
        Year = book.Year;
        Price = book.Price;
        Isbn = book.Isbn;
        Version = Guid.NewGuid();
    }
}

public sealed class BookshelfDbContext(DbContextOptions<BookshelfDbContext> options) : DbContext(options)
{
    public DbSet<BookEntity> Books => Set<BookEntity>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<BookEntity>(entity =>
        {
            entity.ToTable("Books");
            entity.HasKey(b => b.Id);
            entity.Property(b => b.Title).IsRequired().HasMaxLength(300);
            entity.Property(b => b.Author).IsRequired().HasMaxLength(200);
            entity.Property(b => b.Isbn).HasMaxLength(20);

            // SQLite stores decimal as text; ordering or comparing it runs on the client.
            // Keep the C# type and store a double instead (see the pitfalls table).
            entity.Property(b => b.Price).HasConversion<double>();

            entity.Property(b => b.Version).IsConcurrencyToken();
            entity.HasIndex(b => b.Title);
            entity.HasIndex(b => b.Isbn).IsUnique();
        });
    }
}

Source: SQLite provider limitations (the decimal conversion is the example given on that page) and Handling concurrency conflicts (the application-managed Guid token with IsConcurrencyToken). The entity-to-record mapping and index choices are Bookshelf design.

The repository

IBookRepository has the canonical shape from the MVVM pages. Each method opens its own context from the factory, runs one unit of work and disposes it. The repository returns Book records and never tracked entities.

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Bookshelf.Core;

public interface IBookRepository
{
    Task<IReadOnlyList<Book>> GetAllAsync(CancellationToken cancellationToken);
    Task<Book?> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task<Book> AddAsync(Book book, CancellationToken cancellationToken);
    Task UpdateAsync(Book book, CancellationToken cancellationToken);
    Task DeleteAsync(int id, CancellationToken cancellationToken);
}
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Bookshelf.Core;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Bookshelf.Data;

public sealed class SqliteBookRepository(
    IDbContextFactory<BookshelfDbContext> factory,
    ILogger<SqliteBookRepository> logger) : IBookRepository
{
    public async Task<IReadOnlyList<Book>> GetAllAsync(CancellationToken cancellationToken)
    {
        await using var db = await factory.CreateDbContextAsync(cancellationToken);
        var rows = await db.Books
            .AsNoTracking()
            .OrderBy(b => b.Title)
            .ToListAsync(cancellationToken);
        return rows.Select(r => r.ToBook()).ToList();
    }

    public async Task<Book?> GetByIdAsync(int id, CancellationToken cancellationToken)
    {
        await using var db = await factory.CreateDbContextAsync(cancellationToken);
        var row = await db.Books
            .AsNoTracking()
            .FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
        return row?.ToBook();
    }

    public async Task<Book> AddAsync(Book book, CancellationToken cancellationToken)
    {
        await using var db = await factory.CreateDbContextAsync(cancellationToken);
        var entity = BookEntity.FromBook(book);
        entity.Id = 0; // the database assigns the key
        db.Books.Add(entity);
        await db.SaveChangesAsync(cancellationToken);
        logger.LogInformation("Added book {BookId}", entity.Id);
        return entity.ToBook();
    }

    public async Task UpdateAsync(Book book, CancellationToken cancellationToken)
    {
        await using var db = await factory.CreateDbContextAsync(cancellationToken);
        var entity = await db.Books.FirstOrDefaultAsync(b => b.Id == book.Id, cancellationToken)
            ?? throw new KeyNotFoundException($"Book {book.Id} no longer exists.");
        entity.CopyFrom(book);
        await db.SaveChangesAsync(cancellationToken);
    }

    public async Task DeleteAsync(int id, CancellationToken cancellationToken)
    {
        await using var db = await factory.CreateDbContextAsync(cancellationToken);
        await db.Books.Where(b => b.Id == id).ExecuteDeleteAsync(cancellationToken);
    }
}

Source: DbContext lifetime, configuration, and initialization ("The DbContext lifetime", "Use a DbContext factory"). The repository body is Bookshelf code, extending the version on the Dependency Injection page with the concurrency token. ExecuteDeleteAsync is the set-based delete API of EF Core 7 and later; this page did not re-fetch its reference.

A few details in that code are deliberate:

  • AsNoTracking() on read paths. The result becomes a record that is then thrown away, so change tracking would be pure overhead.

  • UpdateAsync loads the row and copies values onto it rather than calling Update on a detached entity. That makes the concurrency token meaningful: the original Version is read from the database in the same unit of work, and the UPDATE carries WHERE Id = @id AND Version = @original.

  • DeleteAsync uses ExecuteDeleteAsync, a single DELETE statement without loading the row.

  • CancellationToken is passed to every awaited EF call, so the LoadCommand cancel button of the view model (see the Commands page) actually stops the query.

DbContext lifetime in a desktop app

The EF Core documentation states that a DbContext "is designed to be used for a single unit-of-work", that "the lifetime of a DbContext instance is usually very short", that it must be disposed, and that it is "not thread-safe". An ASP.NET Core request is one unit of work and AddDbContext ties the context to it. A desktop window has no such scope, and the documentation offers AddDbContextFactory for application types that "do not create a service scope that aligns with the desired DbContext lifetime". The contexts it creates "are not managed by the application’s service provider and therefore must be disposed by the application", hence await using in every repository method.

Pattern What goes wrong Verdict for desktop

One DbContext for the whole app (AddDbContext plus a singleton repository)

Tracked entities accumulate for the whole session; two overlapping operations (load plus save) throw "A second operation started on this context before a previous operation completed".

Avoid.

One DbContext per view model

Same accumulation, tied to the view model lifetime; a failed SaveChanges leaves a poisoned change tracker; the context is shared by every command of the view model, including concurrent ones.

Avoid, except for a short-lived editor that needs change tracking and is closed with the dialog.

A context per operation from IDbContextFactory<T>

Nothing, apart from the cost of creating a context, which the documentation treats as cheap.

Recommended default, shown in this page.

A context per window or dialog scope (IServiceScopeFactory)

Works when the lifetime matches one dialog’s unit of work, but needs disciplined disposal.

Acceptable for forms with explicit save/cancel.

Applying migrations at startup

The database must exist, with the right schema, before the first query. EF Core migrations are created at design time and applied either by tooling or by the app itself. For a desktop app the app itself is the only option, because there is no deployment pipeline touching a user’s bookshelf.db.

Create the migration in the data project (use the app as startup project so that the connection string can be resolved; or add an IDesignTimeDbContextFactory<BookshelfDbContext> in Bookshelf.Data):

dotnet ef migrations add InitialCreate --project src/Bookshelf.Data --startup-project src/Bookshelf.Desktop
dotnet ef migrations add AddBookVersion --project src/Bookshelf.Data --startup-project src/Bookshelf.Desktop

Source: Getting Started - EF Core (the migrations add command) and Applying migrations. The --project and --startup-project options are standard dotnet ef options not shown on those two pages; the design-time factory option is described in the EF Core Design-time DbContext Creation page.

Then apply them in a hosted service, before any window opens (the Dependency Injection page of this section shows where it is registered):

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Bookshelf.Data;

public sealed partial class DatabaseInitializer(
    IDbContextFactory<BookshelfDbContext> factory,
    ILogger<DatabaseInitializer> logger) : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        await using var db = await factory.CreateDbContextAsync(cancellationToken);
        try
        {
            await db.Database.MigrateAsync(cancellationToken);
            LogReady(db.Database.GetDbConnection().DataSource);
        }
        catch (Exception ex)
        {
            LogFailed(ex);
            throw; // let the app show a clear start-up error instead of a half-working UI
        }
    }

    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    [LoggerMessage(Level = LogLevel.Information, Message = "Database ready at {Path}")]
    private partial void LogReady(string path);

    [LoggerMessage(Level = LogLevel.Critical, Message = "Could not migrate the database")]
    private partial void LogFailed(Exception exception);
}

Source: Applying migrations ("Apply migrations at runtime", "Migration locking"). The hosted-service wrapper and logging are Bookshelf code.

What the EF Core documentation says about runtime migration is worth reading before shipping it:

  • It lists "Runtime migration" as the strategy for "applications that accept startup migration tradeoffs", and describes MigrateAsync() as the call. A desktop app that owns its database is exactly that case; the documented warnings (elevated schema permissions, no inspection of the SQL, difficulty rolling back) concern shared server databases.

  • From EF Core 9, Migrate and MigrateAsync take a database-wide lock before applying migrations. On SQLite there is no application lock, so the provider creates a __EFMigrationsLock table and inserts a row. If the process is killed during a migration the row can stay behind, and every later migration waits for it. The documented fix is to drop that table or delete its rows. Detect it: a start-up that hangs for ever after a crash is the symptom.

  • From EF Core 9, Migrate and MigrateAsync throw when the model has pending changes compared with the last migration (the PendingModelChangesWarning). Run dotnet ef migrations has-pending-model-changes in CI so that the failure is found there and not on a user’s PC.

  • EnsureCreatedAsync() and migrations do not mix: calling it before MigrateAsync() makes the migration fail. Use one or the other. For a database that will ever change, use migrations from day one.

  • Migrations cannot be wrapped in an explicit transaction when using MigrateAsync.

SQLite migration limits

SQLite has a smaller ALTER TABLE than server databases. The provider documentation lists which operations work directly and which need a "rebuild": create a new table, copy the data, drop the old table, rename the new one. A rebuild is attempted automatically only for artifacts that are part of the EF model; otherwise a NotSupportedException is thrown.

Operation Supported on SQLite

AddColumn, CreateTable, CreateIndex, DropIndex, DropTable, RenameColumn, RenameTable, Insert, Update, Delete

Yes, directly

AlterColumn, DropColumn, AddForeignKey, DropForeignKey, AddPrimaryKey, AddUniqueConstraint, RenameIndex, check constraints

Yes, by rebuilding the table

Schemas, sequences, database-generated concurrency tokens

Not supported (modelling limitation)

The same page notes that idempotent migration scripts are not supported on SQLite, because SQLite has no procedural language. For a desktop app this is irrelevant (the app migrates itself), but it rules out generating one script for all versions.

Connection string, location and write-ahead logging

The path comes from the folder service, never from a literal. Microsoft.Data.Sqlite documents the keywords:

Keyword Meaning Default and desktop notes

Data Source

Path of the database file. Aliases DataSource and Filename. Relative paths are relative to the current working directory.

Always pass an absolute path: the working directory of a desktop app depends on how it was started (shortcut, file association, "Start in" field).

Mode

ReadWriteCreate, ReadWrite, ReadOnly, Memory.

ReadWriteCreate by default. Use ReadOnly for a reference database shipped with the app.

Cache

Default, Private, Shared.

The connection-string page warns that mixing shared-cache mode with write-ahead logging is discouraged.

Foreign Keys

Sends PRAGMA foreign_keys on open when True or False.

Empty by default: no pragma is sent.

Default Timeout

Command timeout in seconds (alias Command Timeout).

30. Added in version 6.0.

Pooling

Whether connections are pooled.

True by default (added in 6.0). Pooled connections keep the file open, which matters for deleting or moving the database, see the pitfalls.

The same documentation states that "SQLite doesn’t support asynchronous I/O. Async ADO.NET methods will execute synchronously in Microsoft.Data.Sqlite", and recommends write-ahead logging for performance and concurrency. It also notes that write-ahead logging is "enabled by default on databases created using Entity Framework Core". Two consequences for the desktop: first, await db.Books.ToListAsync() on SQLite does the file read on the calling thread of the awaiter’s continuation context, so awaiting it directly from a UI-thread event handler can block the UI for a large query (next section). Second, write-ahead logging creates bookshelf.db-wal and bookshelf.db-shm next to the database, so a backup that copies only bookshelf.db while the app runs can miss committed data.

Source: Connection strings - Microsoft.Data.Sqlite and Async limitations - Microsoft.Data.Sqlite. The -wal and -shm side files, and the consequence for copying, are described in the SQLite write-ahead logging documentation.

Registration, in the composition root:

using Bookshelf.Core;
using Bookshelf.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Data.Sqlite;

namespace Bookshelf.Data;

public static class DataServiceCollectionExtensions
{
    public static IServiceCollection AddBookshelfData(this IServiceCollection services)
    {
        services.AddSingleton<IAppPaths, AppPaths>();
        services.AddSingleton<ISettingsService, JsonSettingsService>();

        services.AddDbContextFactory<BookshelfDbContext>((provider, options) =>
        {
            var paths = provider.GetRequiredService<IAppPaths>();
            var connectionString = new SqliteConnectionStringBuilder
            {
                DataSource = System.IO.Path.Combine(paths.DataDirectory, "bookshelf.db"),
                Mode = SqliteOpenMode.ReadWriteCreate,
                ForeignKeys = true,
            }.ToString();
            options.UseSqlite(connectionString);
        });

        services.AddSingleton<IBookRepository, SqliteBookRepository>();
        services.AddHostedService<DatabaseInitializer>();
        return services;
    }
}

Source: Connection strings - Microsoft.Data.Sqlite ("Connection string builder") and DbContext configuration (AddDbContextFactory). AddHostedService only takes effect under the Generic Host, which starts every IHostedService when the host starts. An app that builds a plain ServiceCollection (as the WinUI 3 page does) must call StartAsync on the registered hosted services itself, or the database is never migrated. The overload that takes IServiceProvider is a standard EF Core overload that those pages do not show; the builder avoids string concatenation of paths.

Async queries off the UI thread

The repository methods are async, but "async" and "off the UI thread" are different things. Three facts decide whether the window stays responsive:

  1. Awaiting from the UI thread is fine when the work yields. A truly asynchronous call frees the thread while the work runs. The SQLite documentation says its async methods run synchronously, so the first part of the query — including the file read — can run on the thread that called it.

  2. A LoadCommand generated by [RelayCommand] runs its async method on the thread that invoked it, which is the UI thread for a button click. So the repository call starts on the UI thread.

  3. The continuation after await resumes on the UI thread (through the SynchronizationContext), which is what you want for setting Books, but not what you want for the mapping of thousands of rows.

For a library of a few hundred books none of that is measurable. For larger tables the straightforward fix is to move the whole data call to the thread pool at the call site, and to keep the UI update on the UI thread:

using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace Bookshelf.Desktop.ViewModels;

public sealed partial class BooksViewModel(IBookRepository repository, IDialogService dialogs) : ObservableObject
{
    public ObservableCollection<BookViewModel> Books { get; } = [];

    [ObservableProperty]
    public partial string StatusMessage { get; set; } = "";

    [RelayCommand(IncludeCancelCommand = true)]
    private async Task LoadAsync(CancellationToken cancellationToken)
    {
        StatusMessage = "Loading...";

        // The data call and the mapping run on a thread-pool thread...
        var books = await Task.Run(
            async () => (await repository.GetAllAsync(cancellationToken))
                .Select(b => new BookViewModel(b))
                .ToList(),
            cancellationToken);

        // ...and this continuation is back on the UI thread.
        Books.Clear();
        foreach (var book in books)
        {
            Books.Add(book);
        }

        StatusMessage = $"{Books.Count} books";
    }
}

Source: Async limitations - Microsoft.Data.Sqlite and RelayCommand attribute (IncludeCancelCommand). The Task.Run wrapper is a Bookshelf choice; it is not a documented requirement. The Threading, Dispatcher & Async page of this section covers the dispatcher rules and IProgress reporting.

A ObservableCollection<T> bound to a list must be changed on the UI thread in WPF; building the list on the pool thread and adding on the UI thread, as above, respects that. Adding items one by one raises one change notification per item; the Lists, Grids & Trees page discusses batching and virtualization for big collections.

Optimistic concurrency

A desktop app is usually a single process, but not always: the user can open two instances, a sync service can write to the same file, or a UpdateAsync can race with a background import. EF Core implements optimistic concurrency through a concurrency token. The documentation explains that SQLite has no native auto-updating token, so the application manages one, here the Guid Version of BookEntity, regenerated by CopyFrom. When the token in the database differs from the original value, SaveChanges throws DbUpdateConcurrencyException.

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace Bookshelf.Data;

public static class ConcurrencyExtensions
{
    /// <summary>Saves, and if another writer got there first, keeps the database values (client loses).</summary>
    public static async Task<bool> SaveKeepingDatabaseValuesAsync(
        this DbContext db, CancellationToken cancellationToken)
    {
        try
        {
            await db.SaveChangesAsync(cancellationToken);
            return true;
        }
        catch (DbUpdateConcurrencyException ex)
        {
            foreach (var entry in ex.Entries)
            {
                var databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken);
                if (databaseValues is null)
                {
                    entry.State = EntityState.Detached; // the row was deleted by somebody else
                }
                else
                {
                    entry.OriginalValues.SetValues(databaseValues);
                    entry.CurrentValues.SetValues(databaseValues);
                }
            }

            return false;
        }
    }
}

Source: Handling concurrency conflicts ("Application-managed concurrency tokens", "Resolving concurrency conflicts" with current, original and database values). The merge policy is a deliberate simplification; the documentation shows a loop that lets the application choose each value.

The repository would return the result to the view model, which asks the user (through IDialogService.ConfirmAsync) whether to reload the changed book or overwrite it. Do not silently retry on a desktop: the user has the old values on screen.

Files: Reading, Writing, Locking

Some data belongs in files the user can see: exports, imports, a backup, reports, attachments. The settings file above is also a file, and it has to be written safely. .NET gives a layered API: File and Directory helpers, FileStream for control, and FileShare and File.Replace for coordination with other processes.

File sharing and locking

Windows enforces sharing at open time. A FileStream constructor takes a FileAccess (what this handle will do) and a FileShare (what others may do while it is open). FileShare is a flags enumeration.

FileShare value Meaning

None

Declines sharing. "Any request to open the file (by this process or another process) will fail until the file is closed."

Read

Allows subsequent opening for reading. If not specified, a request to open for reading fails until the file is closed.

Write

Allows subsequent opening for writing. If not specified, a request to open for writing fails.

ReadWrite

Allows subsequent opening for reading or writing.

Delete

Allows subsequent deleting of the file.

Inheritable

Makes the handle inheritable by child processes; the documentation notes this is "not directly supported by Win32".

The documentation adds that "even if this flag is specified, additional permissions might still be needed". A failing open throws IOException. The .NET I/O error page shows how to recognise a sharing violation: the Win32 code is the low 16 bits of HResult, and error 32 is ERROR_SHARING_VIOLATION ("the file or directory is in use"). Handle IOException last in a catch chain, because it is the base class of the other I/O exceptions.

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace Bookshelf.Core;

public static class FileOpening
{
    /// <summary>Opens a file for reading, retrying briefly while another process (an editor, an antivirus scan) holds it.</summary>
    public static async Task<FileStream> OpenReadWithRetryAsync(
        string path, int attempts = 5, TimeSpan? delay = null, CancellationToken cancellationToken = default)
    {
        var wait = delay ?? TimeSpan.FromMilliseconds(100);
        for (var attempt = 1; ; attempt++)
        {
            try
            {
                return new FileStream(
                    path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete,
                    bufferSize: 4096, useAsync: true);
            }
            catch (IOException e) when (IsSharingViolation(e) && attempt < attempts)
            {
                await Task.Delay(wait, cancellationToken);
            }
        }
    }

    private static bool IsSharingViolation(IOException e) => (e.HResult & 0x0000FFFF) == 32;
}

Source: FileShare enum and Handling I/O errors in .NET (the When ((e.HResult & 0x0000FFFF) == 32) filter). The retry loop is Bookshelf code.

Atomic writes

Writing a file in place has a window in which the file is empty or half written: a crash, a power loss or a killed process during that window destroys the previous good copy. The safe pattern is write-then-replace: write the new content to a temporary file in the same folder, flush it to disk, then swap it for the target. File.Replace does the swap: it "replaces the contents of a specified file with the contents of another file, deleting the original file and creating a backup of the replaced file". The source and destination must be on the same volume (an exception is raised otherwise), passing null for the backup name skips the backup, and an existing backup file is overwritten.

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace Bookshelf.Core;

public static class AtomicFile
{
    /// <summary>
    /// Writes <paramref name="path"/> without ever leaving it half written: the content goes to a temporary
    /// file next to it first, then File.Replace swaps it in and keeps the previous version as ".bak".
    /// </summary>
    public static async Task WriteAsync(
        string path,
        Func<Stream, CancellationToken, Task> write,
        CancellationToken cancellationToken = default)
    {
        var directory = Path.GetDirectoryName(path)
            ?? throw new ArgumentException("The path has no directory.", nameof(path));
        Directory.CreateDirectory(directory);

        var temp = Path.Combine(directory, Path.GetFileName(path) + "." + Guid.NewGuid().ToString("N") + ".tmp");
        try
        {
            await using (var stream = new FileStream(
                temp, FileMode.CreateNew, FileAccess.Write, FileShare.None,
                bufferSize: 4096, useAsync: true))
            {
                await write(stream, cancellationToken);
                await stream.FlushAsync(cancellationToken);
                stream.Flush(flushToDisk: true);
            }

            if (File.Exists(path))
            {
                File.Replace(temp, path, path + ".bak");
            }
            else
            {
                File.Move(temp, path);
            }
        }
        finally
        {
            if (File.Exists(temp))
            {
                File.Delete(temp);
            }
        }
    }
}

Source: File.Replace method (same-volume rule, null backup, overwritten backup). FileStream.Flush(bool flushToDisk) is documented on FileStream.Flush. The helper is Bookshelf code. File.Replace is atomic only to the extent the file system guarantees it; the documentation does not use the word "atomic", so this page calls the pattern "safe" rather than promising a transactional guarantee on every volume type.

The helper is used by the settings service above, and by the export:

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Bookshelf.Core;

namespace Bookshelf.Desktop;

public static class BookExporter
{
    public static Task ExportCsvAsync(
        IReadOnlyCollection<Book> books, string path, CancellationToken cancellationToken) =>
        AtomicFile.WriteAsync(path, async (stream, ct) =>
        {
            await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
            await writer.WriteLineAsync("Id,Title,Author,Year,Price,Isbn".AsMemory(), ct);
            foreach (var b in books.OrderBy(b => b.Title))
            {
                var line = string.Join(',', b.Id, Quote(b.Title), Quote(b.Author), b.Year,
                    b.Price.ToString(System.Globalization.CultureInfo.InvariantCulture), b.Isbn ?? "");
                await writer.WriteLineAsync(line.AsMemory(), ct);
            }
        }, cancellationToken);

    private static string Quote(string value) => "\"" + value.Replace("\"", "\"\"") + "\"";
}

Source: Write text to a file. The CSV layout, quoting and ExportCsvAsync are Bookshelf code; for real-world CSV (embedded line breaks, other cultures) use a library.

Where the file location comes from a user choice, the pickers differ by stack (WPF SaveFileDialog, WinForms SaveFileDialog, WinUI 3 FileSavePicker initialised with the window handle). Pickers sit behind IFileDialogService, as the Windows Dialogs & Navigation page describes; the code above only needs the path.

Backing up the database

A SQLite database is a file, but a live database is not safe to copy blindly. With write-ahead logging, committed data may still be in the -wal file. Three routes work. The simplest for a desktop app is to ask SQLite to produce a consistent copy (the VACUUM INTO statement, or the SQLite online-backup API exposed by SqliteConnection.BackupDatabase), or to copy only when no connection is open and the pool has been cleared. Neither API is documented in the pages cited in this page, so treat the next snippet as a starting point to verify against the Microsoft.Data.Sqlite reference:

using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;

namespace Bookshelf.Data;

public static class DatabaseBackup
{
    public static async Task BackupAsync(string databasePath, string backupPath, CancellationToken cancellationToken)
    {
        await using var source = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly");
        await source.OpenAsync(cancellationToken);

        await using var destination = new SqliteConnection($"Data Source={backupPath}");
        await destination.OpenAsync(cancellationToken);

        source.BackupDatabase(destination); // synchronous: call it from a background thread
    }
}

Source: SqliteConnection.BackupDatabase method. BackupDatabase(SqliteConnection destination) is synchronous, which is why the comment asks for a background thread.

WPF, WinUI 3 and Windows Forms Compared

Storage is mostly independent of the UI framework, because it is plain .NET. The differences are packaging, designers and the Windows-only APIs.

Concern WPF WinUI 3 / Windows App SDK Windows Forms

Data folder

LocalApplicationData subfolder

Same, or ApplicationData.GetDefault().LocalPath when packaged

LocalApplicationData subfolder

User settings

JSON file (this page); Settings.settings possible but not documented for WPF

JSON file; ApplicationData.LocalSettings for packaged apps

Settings.settings is first-class: designer, user.config, binding to control properties

Packaged virtualization

When packaged as MSIX (packagedClassicApp); not with external location

When packaged as MSIX (packagedClassicApp); not with external location

When packaged as MSIX (packagedClassicApp); not with external location

EF Core

Same code; bind to ObservableCollection<T> on the UI thread

Same code; bind to ObservableCollection<T> on the UI thread

Same code; BindingList<T> or ObservableCollection<T> with a BindingSource

File pickers

OpenFileDialog, SaveFileDialog (Microsoft.Win32)

FileOpenPicker, FileSavePicker initialised with the window handle

OpenFileDialog, SaveFileDialog

Trimming and Native AOT

Not supported (see the Windows App Platforms page)

Supported for some scenarios; EF Core and reflection-based serializers need care

Not supported

Source: Application Settings overview, ApplicationData class and the Windows App Platforms page of this section. The picker rows and the Native AOT row summarise the platform pages of this section.

Pitfalls

  • Writing next to the executable. AppContext.BaseDirectory is Program Files for an installed app and the read-only package folder for MSIX. Write only to the known folders above.

  • Relative paths. The working directory of a desktop app depends on how it was started. Use absolute paths from IAppPaths.

  • Roaming for big data. Putting the database in ApplicationData (roaming) copies it at logon and logoff in a roaming-profile domain. Use LocalApplicationData for anything larger than a few kilobytes.

  • Losing settings on upgrade. The legacy user.config folder name includes the assembly version, so a new version may start with empty settings unless the app calls the upgrade routine or uses a custom provider. A JSON file in a stable folder avoids the problem.

  • Unversioned settings and schema. Keep Version in the settings file and migrate it; keep migrations for the database from the first release. Renaming a settings property silently resets it.

  • One long-lived DbContext. Leads to stale data, leaks and "a second operation started on this context". Use the factory.

  • Not awaiting EF calls. The EF Core documentation says to "always await EF Core asynchronous methods immediately".

  • Database.EnsureCreated() plus migrations. They conflict; use migrations only.

  • Abandoned migration lock. After a crash during a migration, the __EFMigrationsLock table can block every later start. Clear it as documented and consider a start-up diagnostic that names the table.

  • decimal in SQLite. Ordering and comparing decimal columns runs on the client; either convert to double as in the model above or store minor units as long.

  • Pooled connections keep the file open. To delete or move the database (restore from backup, reset to factory state) close the DbContext, then clear the SQLite connection pools before touching the file, otherwise the delete fails with a sharing violation. SqliteConnection.ClearAllPools() (Microsoft.Data.Sqlite 6.0 and later) empties the pools; see SqliteConnection.ClearAllPools.

  • Copying a live database. With write-ahead logging, copying only the main file can miss committed data.

  • Storing secrets in settings or appsettings.json. Both are plain text.

  • Swallowing IOException. A sharing violation is retryable; a missing directory or UnauthorizedAccessException is not. Distinguish them, and always tell the user which path failed.

  • Assuming the "real" AppData of an MSIX-packaged app. Another tool will not see the packaged app’s new files where it expects them; and an uninstall deletes them.

What Changed Across .NET Versions

Version Relevant change

.NET Framework 4.8

Settings.settings and ApplicationSettingsBase were the standard preferences store; EF 6 or EF Core 3.1 for data; System.Data.SQLite or Microsoft.Data.Sqlite for SQLite.

.NET 6 to 8

Source-generated System.Text.Json (.NET 6 and later) makes a trim-friendly settings file practical; the Pooling keyword was added to Microsoft.Data.Sqlite in version 6.0.

EF Core 9 / .NET 9

Migrate and MigrateAsync take a database-wide lock (on SQLite, the __EFMigrationsLock table) and throw when the model has pending changes.

.NET 10 / EF Core 10

This page’s baseline. The Vfs connection-string keyword appears in Microsoft.Data.Sqlite version 10.0.

Windows App SDK 2.x

Microsoft.Windows.Storage.ApplicationData is documented from 1.6; ApplicationData.GetForUnpackaged(publisher, product) for unpackaged apps arrived in 2.2.0, with a LocalSettings registry-path fix in 2.3.1.

Source: Applying migrations, Connection strings and ApplicationData class. The .NET Framework and .NET 6 to 8 rows are summarised from the pages above.

This section’s other pages build on this one. The Dependency Injection & the Generic Host page registers the services and shows appsettings.json and the options pattern; Threading, Dispatcher & Async covers IProgress, cancellation and dispatcher rules in depth; Testing View Models & UI replaces IBookRepository and IAppPaths with fakes, and uses an in-memory or temporary-file SQLite database for repository tests; Windows Platform Integration covers package identity and pickers; the packaging and distribution pages explain what an MSIX install and uninstall do to these folders. Outside this section, the C# pages of this site cover the language features used here — records, async/await, LINQ, nullable reference types, exceptions and IDisposable — and the database pages of the site explain SQL, schema evolution and query diagnosis in general terms, which apply to SQLite as well. See also Dependency Injection & Generic Host, Threading, Dispatcher & Async, Testing View Models & UI and MVVM Toolkit.