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.ApplicationDatain 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 |
|---|---|---|
|
"Application-specific data that is used by the current, non-roaming user." Typically |
The default for almost everything: the SQLite database, caches, logs, downloaded files, per-machine settings. |
|
"Application-specific data for the current roaming user." Typically |
Small preferences that should follow a user between machines in a domain with roaming profiles. Never databases or large files. |
|
"Application-specific data that is used by all users." Typically |
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. |
|
The user’s documents folder. |
Files the user creates and expects to find: exports, reports, a library file the user chose to save. |
|
The user’s profile folder. The documentation says apps "should not create files or folders at this level" and should use |
Nothing. Do not write here. |
|
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 |
|
|
Cache folder that backup skips |
A subfolder you create and document |
|
Temporary files |
|
|
Key-value settings |
A JSON file, see below |
|
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.
Source: ApplicationData class (Microsoft.Windows.Storage), ApplicationData.GetForUnpackaged(String, String) method and Windows App SDK 2.x release notes (2.2.0 and 2.3.1 entries).
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\Microsoftand the Start MenuProgramsfolder are written to a "private per-user, per-app location" and merged at runtime so they appear in the realAppData. -
On a file open, the private location is checked first; if the file isn’t there, the real
AppDatais used and no virtualization happens for that file. -
Modifications to files that already exist under
AppDataare allowed to go through, for compatibility. Deleting underAppDatais 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.
-
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.
-
Do not point a second tool — a backup script, a command-line companion — at the "real"
AppDatapath and expect to see the packaged app’s new files. They sit in the redirected location. -
Everything inside the package (
C:\Program Files\WindowsApps\<package_full_name>) is read-only. Settings files shipped with the app, such asappsettings.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 |
|
Strongly typed, validated, reloadable ( |
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 |
The app reads and writes its own |
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). |
|
Visual Studio generates a wrapper class; user-scoped values are saved as XML to |
One-click in the WinForms designer, binding support for WinForms properties. |
The |
|
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 |
|---|---|
|
|
|
|
|
WPF (or WinUI 3 / WinForms) app, view models, |
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. -
UpdateAsyncloads the row and copies values onto it rather than callingUpdateon a detached entity. That makes the concurrency token meaningful: the originalVersionis read from the database in the same unit of work, and theUPDATEcarriesWHERE Id = @id AND Version = @original. -
DeleteAsyncusesExecuteDeleteAsync, a singleDELETEstatement without loading the row. -
CancellationTokenis passed to every awaited EF call, so theLoadCommandcancel 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 |
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 |
Same accumulation, tied to the view model lifetime; a failed |
Avoid, except for a short-lived editor that needs change tracking and is closed with the dialog. |
A context per operation from |
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 ( |
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,
MigrateandMigrateAsynctake a database-wide lock before applying migrations. On SQLite there is no application lock, so the provider creates a__EFMigrationsLocktable 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,
MigrateandMigrateAsyncthrow when the model has pending changes compared with the last migration (thePendingModelChangesWarning). Rundotnet ef migrations has-pending-model-changesin CI so that the failure is found there and not on a user’s PC. -
EnsureCreatedAsync()and migrations do not mix: calling it beforeMigrateAsync()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 |
|---|---|
|
Yes, directly |
|
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 |
|---|---|---|
|
Path of the database file. Aliases |
Always pass an absolute path: the working directory of a desktop app depends on how it was started (shortcut, file association, "Start in" field). |
|
|
|
|
|
The connection-string page warns that mixing shared-cache mode with write-ahead logging is discouraged. |
|
Sends |
Empty by default: no pragma is sent. |
|
Command timeout in seconds (alias |
30. Added in version 6.0. |
|
Whether connections are pooled. |
|
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:
-
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.
-
A
LoadCommandgenerated 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. -
The continuation after
awaitresumes on the UI thread (through theSynchronizationContext), which is what you want for settingBooks, 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 |
|---|---|
|
Declines sharing. "Any request to open the file (by this process or another process) will fail until the file is closed." |
|
Allows subsequent opening for reading. If not specified, a request to open for reading fails until the file is closed. |
|
Allows subsequent opening for writing. If not specified, a request to open for writing fails. |
|
Allows subsequent opening for reading or writing. |
|
Allows subsequent deleting of the file. |
|
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 |
|
Same, or |
|
User settings |
JSON file (this page); |
JSON file; |
|
Packaged virtualization |
When packaged as MSIX ( |
When packaged as MSIX ( |
When packaged as MSIX ( |
EF Core |
Same code; bind to |
Same code; bind to |
Same code; |
File pickers |
|
|
|
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.BaseDirectoryisProgram Filesfor 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. UseLocalApplicationDatafor anything larger than a few kilobytes. -
Losing settings on upgrade. The legacy
user.configfolder 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
Versionin 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
__EFMigrationsLocktable can block every later start. Clear it as documented and consider a start-up diagnostic that names the table. -
decimalin SQLite. Ordering and comparingdecimalcolumns runs on the client; either convert todoubleas in the model above or store minor units aslong. -
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 orUnauthorizedAccessExceptionis 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 |
|
.NET 6 to 8 |
Source-generated |
EF Core 9 / .NET 9 |
|
.NET 10 / EF Core 10 |
This page’s baseline. The |
Windows App SDK 2.x |
|
Source: Applying migrations, Connection strings and ApplicationData class. The .NET Framework and .NET 6 to 8 rows are summarised from the pages above.
Related Pages
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.