Dependency Injection & the Generic Host

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.

Dependency injection (DI) is how a Windows desktop application stops creating its own collaborators with new and asks for them instead. A view model that needs a repository declares it in its constructor; something else decides which repository to give it, how long it lives, and when to dispose it. That something else is a service container, and in modern .NET the container is created, configured and run by the Generic Host, the same hosting model that worker services and ASP.NET Core use. WPF, WinUI 3 and Windows Forms have no host of their own, so a desktop app adds one by hand: a few lines in the application start-up and exit code. This page was written against .NET 10 LTS (latest patch 10.0.13) with C# 14, Visual Studio 2026 18.11.1, Windows App SDK 2.5.1 and CommunityToolkit.Mvvm 8.4.2 on Windows 10 and Windows 11. .NET 11 is at release candidate 2 (11.0.0-rc.2) and general availability is expected at .NET Conf, 10 to 13 November 2026; none of the hosting or DI pages consulted lists a change for it that affects desktop apps. WPF is the stack in focus; WinUI 3 and Windows Forms get shorter comparison snippets further down.

The running scenario is Bookshelf Desktop. The page wires up its composition root: the Bookshelf.Core interfaces (IBookRepository, IDialogService, INavigationService), the Bookshelf.Data implementation (SqliteBookRepository over EF Core and SQLite), the Bookshelf.Desktop services (WpfDialogService), the view models (BooksViewModel, BookEditorViewModel) and the windows. How a window is opened and how a dialog returns a result is covered in Windows, Dialogs & Navigation; this page shows only the parts that depend on the container. The sibling pages The MVVM Toolkit (CommunityToolkit.Mvvm), Messaging Between View Models, Navigation, Dialogs & Services in MVVM and Testing View Models & UI build on it.

Why a desktop app wants a container

The reasons are the same as for any other .NET workload, and the official overview lists them for a hard-coded dependency: replacing it forces edits in every consumer, its own dependencies must be configured by the consumer, and it cannot be replaced by a test double. The three ingredients of the fix are small:

  • an abstraction (an interface or base class) that consumers depend on, for example IBookRepository;

  • a registration of the implementation in an IServiceCollection, usually at start-up;

  • constructor injection: the container builds the consumer and passes the dependencies in.

This is the dependency inversion principle in practice: the high-level view model depends on an abstraction owned by the core library, and the low-level SQLite code depends on the same abstraction, so the dependency arrow points toward the core. For a layered solution such as Bookshelf (Bookshelf.Core, Bookshelf.Data, Bookshelf.Desktop, Bookshelf.Tests) the container is the only place that knows every project; see Project Structure & Build for the project references.

Piece Package Role

IServiceCollection

Microsoft.Extensions.DependencyInjection.Abstractions (referenced by the hosting package)

The list of registrations. AddSingleton, AddScoped, AddTransient and AddKeyedSingleton add to it.

IServiceProvider

Microsoft.Extensions.DependencyInjection

The built container. GetService returns null when a service is missing, GetRequiredService throws.

IServiceScopeFactory and IServiceScope

Microsoft.Extensions.DependencyInjection.Abstractions

Creates a child lifetime boundary. Scoped services live and are disposed with it.

ActivatorUtilities

Microsoft.Extensions.DependencyInjection.Abstractions

Creates an object that is not registered, taking constructor arguments from the container and from explicit values.

IHost and HostApplicationBuilder

Microsoft.Extensions.Hosting

Bundles the container with configuration, logging, hosted services and an application lifetime.

The official overview also warns that desktop apps "control their own lifetime", so WPF and Windows Forms must integrate the host lifetime with the application’s lifetime events. The rest of this page is that integration.

What the Generic Host adds

You can use new ServiceCollection().BuildServiceProvider() without a host; the MVVM Toolkit documentation and the WinUI tutorial do exactly that. The host adds the infrastructure most applications end up writing anyway. The Generic Host page lists what Host.CreateApplicationBuilder configures by default:

Area Default behaviour

Content root

The path returned by Directory.GetCurrentDirectory(). This is the folder where appsettings.json is looked up.

Host configuration

Environment variables prefixed with DOTNET_ (this is where DOTNET_ENVIRONMENT comes from) and command-line arguments.

App configuration

appsettings.json, then appsettings.{Environment}.json, then Secret Manager (only in the Development environment), then environment variables, then command-line arguments. Later sources override earlier ones.

Logging providers

Console, Debug, EventSource and EventLog (EventLog only on Windows).

DI validation

Scope validation and dependency validation (ValidateOnBuild) are switched on when the environment is Development.

Services registered by Build()

IHostApplicationLifetime, IHostLifetime and IHostEnvironment.

Two builder styles exist. Host.CreateApplicationBuilder (IHostApplicationBuilder, introduced in .NET 6) is a linear, property-based style (builder.Services, builder.Configuration, builder.Logging) and is the style the current templates and the WPF how-to use. Host.CreateDefaultBuilder (IHostBuilder) is the older callback style (ConfigureServices, ConfigureAppConfiguration); both provide the same defaults, and the documentation recommends the first for new projects. Older Bookshelf snippets and many blog posts use CreateDefaultBuilder; the two are interchangeable for everything on this page.

Which environment name is "current"? It comes from the DOTNET_ENVIRONMENT variable, which Visual Studio sets from a launch profile (the environmentVariables entry of Properties/launchSettings.json). A deployed desktop app has no such variable, so it runs as Production, the DI validation above is off, and the user secrets are not loaded. That asymmetry is the reason the testing section below validates the container explicitly.

Generic Host in a WPF app

The official how-to (Use the .NET Generic Host in a WPF app, dated March 2026) has four steps: remove StartupUri, build and start the host in the Startup handler, stop and dispose it in the Exit handler, and register the main window so that it can take services in its constructor. The pieces below are that sample adapted to Bookshelf.

Project file and package

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

  <ItemGroup>
    <None Update="appsettings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="appsettings.Development.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.*" />
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
    <ProjectReference Include="..\Bookshelf.Core\Bookshelf.Core.csproj" />
    <ProjectReference Include="..\Bookshelf.Data\Bookshelf.Data.csproj" />
  </ItemGroup>

</Project>

Source: Use the .NET Generic Host in a WPF app (the Microsoft.Extensions.Hosting reference, the appsettings.json copy rule and the floating 10.0.* version are from the how-to; the other references are Bookshelf’s)

A floating version such as 10.0.* is what the how-to shows. A shipped application is better served by an exact version in Directory.Packages.props (central package management), so that every build of the release uses the same Microsoft.Extensions.* assemblies.

App.xaml without StartupUri

<Application x:Class="Bookshelf.Desktop.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             Startup="Application_Startup"
             Exit="Application_Exit">
    <Application.Resources>
    </Application.Resources>
</Application>

StartupUri makes WPF construct the window itself, by calling its parameterless constructor, as part of Application.Run. That bypasses the container, so a window with constructor parameters would fail. With the attribute removed, nothing is shown until your Startup code resolves a window from the container and calls Show(). The full Application model (Startup, Exit, ShutdownMode, SessionEnding) is described in WPF Architecture & Application Model.

The composition root

using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Bookshelf.Desktop;

public partial class App : Application
{
    private IHost? _host;

    private async void Application_Startup(object sender, StartupEventArgs e)
    {
        try
        {
            var settings = new HostApplicationBuilderSettings
            {
                Args = e.Args,
                ContentRootPath = AppContext.BaseDirectory,
            };

            var builder = Host.CreateApplicationBuilder(settings);
            builder.Services.AddBookshelfDesktop(builder.Configuration);

            _host = builder.Build();
            await _host.StartAsync();

            var mainWindow = _host.Services.GetRequiredService<MainWindow>();
            mainWindow.Show();
        }
        catch (Exception ex)
        {
            // No window exists yet, so the process would otherwise stay alive with nothing on screen.
            MessageBox.Show(ex.Message, "Bookshelf could not start", MessageBoxButton.OK, MessageBoxImage.Error);
            Shutdown(exitCode: 1);
        }
    }

    private async void Application_Exit(object sender, ExitEventArgs e)
    {
        if (_host is null)
        {
            return;
        }

        using (_host)
        {
            await _host.StopAsync();
        }
    }
}

Source: Use the .NET Generic Host in a WPF app; HostApplicationBuilderSettings.ContentRootPath follows .NET Generic Host. The try/catch, the ContentRootPath choice and the Shutdown call are Bookshelf additions, not part of the official sample.

What each part does and why it differs from the official sample:

  • ContentRootPath = AppContext.BaseDirectory. The Generic Host page states that the default content root is the current directory. A console tool is usually started from the folder it is in; a desktop application is started from a shortcut, the Start menu or a file association, and its current directory can be anywhere. Pointing the content root at the folder of the executable keeps appsettings.json found. The how-to does not discuss this.

  • await _host.StartAsync() before Show(). StartAsync starts every registered IHostedService and runs ValidateOnStart checks, so a database migration or an invalid setting fails before the user sees a window. The cost is that anything slow in a hosted StartAsync delays the first window; move slow work into a BackgroundService (below).

  • async void handlers. Event handlers are the one place async void is acceptable, but an exception that escapes one is raised on the UI thread’s dispatcher and ends up in DispatcherUnhandledException. The try/catch makes the failure visible and stops the process; with no window shown and no Shutdown call the app would stay alive in Task Manager with nothing to click.

  • Stopping in Exit. The sample awaits StopAsync inside an async void handler. Application.Run returns when the handler’s first await returns control to WPF, so the process can end while StopAsync is still running if a hosted service takes a while to stop. The Windows Forms how-to blocks instead (StopAsync().GetAwaiter().GetResult()). Blocking on the UI thread risks a deadlock if a hosted service needs the dispatcher to finish, so a WPF app that has long-running stop work should keep StopAsync short, use the CancellationToken it receives, and never post to the dispatcher from StopAsync. This paragraph is the page author’s reasoning from the documented behaviour; the official pages do not discuss it.

OnStartup and OnExit instead of events

The same logic can live in overrides of Application.OnStartup and Application.OnExit instead of event handlers; the Bookshelf data-binding example uses OnStartup. The base methods raise the Startup and Exit events, so an override that does not call base.OnStartup(e) or base.OnExit(e) silently disables any handler wired in XAML.

Startup and Exit events (official how-to) OnStartup and OnExit overrides

Wiring

Attributes in App.xaml, handlers in App.xaml.cs.

Methods in App.xaml.cs only. No attributes needed.

Base call

Not applicable.

base.OnStartup(e) raises Startup; skipping it means handlers wired elsewhere never run.

Exit code

ExitEventArgs.ApplicationExitCode can be set in Exit.

Same value, set on the ExitEventArgs before calling or instead of the base method.

Tests and tooling

Handler names are visible in XAML.

Easier to navigate to; no string-named handlers.

Startup and shutdown sequence

sequenceDiagram participant OS as Windows participant App as App (Application) participant B as HostApplicationBuilder participant H as IHost participant S as Hosted services participant W as MainWindow OS->>App: start process and Application.Run App->>App: Startup event raised App->>B: Host.CreateApplicationBuilder(settings) App->>B: register options, logging, data, view models, windows B-->>App: Build returns IHost App->>H: await StartAsync H->>S: StartAsync for each hosted service S-->>H: started (DatabaseInitializer has migrated) App->>H: Services.GetRequiredService of MainWindow H-->>App: MainWindow with BooksViewModel injected App->>W: Show Note over W,S: the user works while BackgroundService loops run on the thread pool W-->>App: last window closed (ShutdownMode OnLastWindowClose) App->>App: Exit event raised App->>H: await StopAsync and Dispose H->>S: StopAsync with a cancellation token

The host also exposes IHostApplicationLifetime (events ApplicationStarted, ApplicationStopping, ApplicationStopped, and StopApplication()), but in a desktop app the window normally ends the application, not the host. The lifecycle order the Generic Host page documents for start is StartingAsync, StartAsync, StartedAsync, then ApplicationStarted, and for stop ApplicationStopping, StoppingAsync, StopAsync, StoppedAsync, then ApplicationStopped (the ...Async pairs come from IHostedLifecycleService, an optional extension of IHostedService).

Registering windows, view models and services

The registrations live in two extension methods. AddBookshelfCore holds everything that all three UI stacks share (options, the data layer, the messenger and the hosted services); AddBookshelfDesktop calls it and adds the WPF-specific services, windows and view models. The same methods build the production container and a test container, and the WinUI and Windows Forms variants further down call AddBookshelfCore with their own UI registrations.

using Bookshelf.Core;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

// In the shared Bookshelf.Data project, which the WPF, WinUI 3 and Windows Forms apps all reference.
namespace Bookshelf.Data;

public static class BookshelfCoreServiceCollectionExtensions
{
    public static IServiceCollection AddBookshelfCore(
        this IServiceCollection services, IConfiguration configuration)
    {
        // Options: bound to the "Bookshelf" section, validated when the host starts.
        services.AddOptions<BookshelfOptions>()
            .Bind(configuration.GetSection(BookshelfOptions.SectionName))
            .ValidateDataAnnotations()
            .ValidateOnStart();

        // Data: IDbContextFactory, SqliteBookRepository and DatabaseInitializer (see "EF Core without a request scope").
        services.AddBookshelfData();

        // Cross-cutting services shared by every UI stack.
        services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);

        // Background work: the sync service resolves the scoped client inside a scope per iteration.
        services.AddScoped<ICatalogSyncClient, CatalogSyncClient>();
        services.AddHostedService<LibrarySyncService>();

        return services;
    }
}
using Bookshelf.Core;
using Bookshelf.Data;
using Bookshelf.Desktop.ViewModels;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Bookshelf.Desktop;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddBookshelfDesktop(
        this IServiceCollection services, IConfiguration configuration)
    {
        services.AddBookshelfCore(configuration);

        // WPF implementations of the Bookshelf.Core contracts (user patterns, see "Navigation, Dialogs & Services in MVVM").
        services.AddSingleton<IDialogService, WpfDialogService>();
        services.AddSingleton<WindowScopeManager>();

        // View models and windows.
        services.AddSingleton<BooksViewModel>();
        services.AddSingleton<MainWindow>();
        services.AddTransient<BookEditorWindow>();

        return services;
    }
}

Source: Dependency injection overview, Options pattern and Options pattern guidance for .NET library authors (Add{Service} naming, and a namespace of your own rather than Microsoft.Extensions.DependencyInjection). The two-method layout, WindowScopeManager and IDialogService are Bookshelf patterns; neither WPF nor the toolkit defines them. AddBookshelfData is the data-layer registration shown on Local Data, Settings & Files.

ICatalogSyncClient is a small Bookshelf contract in Bookshelf.Core; CatalogSyncClient stands for the app’s own implementation (an HTTP client for a remote catalogue, for example), which this page does not show:

namespace Bookshelf.Core;

/// <summary>Synchronises the local catalogue with a remote source.</summary>
public interface ICatalogSyncClient
{
    /// <summary>Runs one synchronisation pass and returns the number of books that changed.</summary>
    Task<int> SyncAsync(CancellationToken cancellationToken);
}

Source: Dependency injection overview ("Scope scenarios")

Choosing a lifetime for each type

A web request gives ASP.NET Core a natural scope. A desktop app has none. Nothing creates a scope for you, so the official wording for scoped services ("created once per client request") does not apply, and the three lifetimes need a desktop reading:

Lifetime What the container does Desktop reading

Singleton

Creates the instance on first request (or uses the instance you supply) and returns it forever. Disposes it when the container is disposed. Must be thread-safe.

One per process. Right for stateless services, caches, the repository, the messenger, and the main view model of a single-window app. Wrong for anything that holds per-document state.

Scoped

One instance per IServiceScope, disposed with the scope. Resolving it from the root provider silently makes it a singleton.

One per thing the user opens: a document window, a wizard, a modal dialog with its own unit of work. You must create the scope yourself.

Transient

A new instance on every resolution. The container keeps a reference to disposable transients until the scope or container is disposed.

Right for light, stateful objects: windows that can be opened many times, editor view models, pages. Dangerous for disposable transients resolved from the root (they accumulate until the process ends).

Rules that follow from the official guidelines (and what they mean in WPF):

  • A longer-lived service must not depend on a shorter-lived one. A singleton BooksViewModel that took a scoped or transient BookEditorViewModel in its constructor would keep the first instance alive and reuse it for every edit. This is a captive dependency. In the Development environment the default provider throws an InvalidOperationException of the form "Cannot consume scoped service 'Bar' from singleton 'Foo'"; a deployed app runs as Production by default and gets no error.

  • Scoped means "inside a scope". The Dependency injection overview and the lifetimes page both say a scoped service must be resolved from an explicit scope created with IServiceScopeFactory.CreateScope(). Resolving it from host.Services (the root provider) gives a singleton in disguise, and scope validation rejects it in Development.

  • Do not dispose what the container created. Windows and view models that implement IDisposable are disposed by the container at the end of their scope, or when the host is disposed. Calling Dispose yourself on a service you resolved is a documented anti-pattern.

  • Instances you supply are yours. AddSingleton(new ExampleService()) hands the container an object it did not create, and the container does not dispose it.

  • Disposable transients: use a scope or a factory. The guidelines advise not to register IDisposable types as transient and resolve them from the root, because the container holds them for the life of the container. The recommended remedies are a factory (for a short, controlled lifetime) or a scoped registration with an explicit scope (for a shared lifetime).

flowchart TD Q1{"Does the instance hold state that belongs to one open window or one unit of work?"} Q1 -- no --> Q2{"Is it thread-safe and cheap to keep forever?"} Q1 -- yes --> Q3{"Is it shared by several services of that window?"} Q2 -- yes --> SING["Singleton: repository, messenger, dialog service, caches"] Q2 -- no --> TRAN["Transient: new each time, not disposable from the root"] Q3 -- yes --> SCOP["Scoped: create one scope per window and resolve its parts inside it"] Q3 -- no --> TRAN

For Bookshelf Desktop the table below records the decisions:

Type Lifetime Reason

IOptions<BookshelfOptions>

Singleton (framework)

IOptions<T> is registered as a singleton and can be injected anywhere; it does not see file changes after start.

IDbContextFactory<BookshelfDbContext>

Singleton (default of AddDbContextFactory, see the EF Core section)

Creates contexts; holds no per-unit-of-work state.

BookshelfDbContext

Not registered

Created per unit of work from the factory and disposed by the caller.

IBookRepository (SqliteBookRepository)

Singleton

Stateless apart from the factory; thread-safe because each call uses its own context.

IMessenger

Singleton

The toolkit’s WeakReferenceMessenger.Default registered as the application messenger.

IDialogService (WpfDialogService)

Singleton

Needs the service provider to create windows and Application.Current to find an owner.

BooksViewModel

Singleton

The main screen is a single instance that lives as long as the process.

BookEditorViewModel

Not registered

Takes a Book as constructor data, so it is built per edit; see below.

MainWindow

Singleton

Shown once; resolved from the root provider.

BookEditorWindow

Transient

Opened as often as the user clicks Edit; resolved inside a window scope.

DatabaseInitializer, LibrarySyncService

Singleton (hosted services)

AddHostedService registers them as singletons that the host starts and stops.

Windows, constructor injection and DataContext

The view model reaches the window by constructor injection, and the window assigns its own DataContext. This keeps the composition root free of view code and makes the dependency visible in the window’s signature.

using System.Windows;
using Bookshelf.Desktop.ViewModels;
using Microsoft.Extensions.Logging;

namespace Bookshelf.Desktop;

public partial class MainWindow : Window
{
    public MainWindow(BooksViewModel viewModel, ILogger<MainWindow> logger)
    {
        InitializeComponent();
        DataContext = viewModel;

        Loaded += async (_, _) =>
        {
            logger.LogInformation("Main window loaded; loading the library.");
            await viewModel.LoadCommand.ExecuteAsync(null);
        };
    }
}

Source: Use the .NET Generic Host in a WPF app ("Inject services into a window"); the toolkit command call follows AsyncRelayCommand

Where a view model needs runtime data that the container cannot know (the Book being edited), do not register the type and resolve it with GetRequiredService. Create it with ActivatorUtilities.CreateInstance<T>(provider, arguments), which fills constructor parameters from the explicit arguments first and the container second, or inject a small factory. The overview documents the first approach (ActivatorUtilities creates "objects that aren’t registered in the container"). The dialog service in Windows, Dialogs & Navigation uses it for the same reason.

A scope per window (user pattern)

A modal editor is a unit of work: it should be able to share a few scoped services with whatever else it shows, and everything transient it created should be released when it closes. A scope per window gives both. There is no official WPF helper for this; the code below is a Bookshelf pattern built on the documented IServiceScopeFactory.

using System.Windows;
using Microsoft.Extensions.DependencyInjection;

namespace Bookshelf.Desktop;

/// <summary>Creates windows inside their own DI scope and disposes the scope when the window closes.</summary>
public sealed class WindowScopeManager(IServiceScopeFactory scopeFactory)
{
    public TWindow Create<TWindow>() where TWindow : Window
    {
        var scope = scopeFactory.CreateAsyncScope();
        try
        {
            var window = scope.ServiceProvider.GetRequiredService<TWindow>();
            window.Closed += async (_, _) => await scope.DisposeAsync();
            return window;
        }
        catch
        {
            scope.Dispose();
            throw;
        }
    }
}

Source: Dependency injection overview ("Scope scenarios") and DI guidelines ("IDisposable guidance"). WindowScopeManager is a user pattern: there is no official WPF API for scoped windows.

Things to know about this pattern:

  • Everything resolved through scope.ServiceProvider (the window, its transient view model, scoped services) is disposed when the scope is disposed. That is the supported way to dispose a disposable transient before the application ends.

  • Scopes are not hierarchical and have no special connection to each other (the guidelines say so). Two windows with two scopes get two different instances of a scoped service.

  • Do not create a scope per button click on the main window unless the work really has that lifetime. Each scope holds its disposable services until it is disposed.

  • A scoped service must never be injected into a singleton, such as BooksViewModel. The singleton would keep the first window’s instance.

The service locator trap

The guidelines list the service locator as an anti-pattern: calling GetService to obtain a dependency "when you can use DI instead". Injecting IServiceProvider into a view model and resolving things from it hides the real dependencies and makes unit tests harder. In desktop apps the legitimate uses of the provider or scope factory are narrow: the composition root, a window or navigation factory that has to create views, and hosted services that need scoped work (documented below).

Configuration and the options pattern

Host.CreateApplicationBuilder loads appsettings.json from the content root if the file is there (the how-to’s CopyToOutputDirectory rule puts it there). The options pattern turns a section of configuration into a strongly typed class and gives you validation.

{
  "Bookshelf": {
    "DatabaseFileName": "bookshelf.db",
    "PageSize": 50,
    "SyncInterval": "00:15:00"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  }
}

Source: Options pattern in .NET (a section bound to an options class) and Logging in .NET (the Logging section)

using System.ComponentModel.DataAnnotations;

namespace Bookshelf.Core;

public sealed class BookshelfOptions
{
    public const string SectionName = "Bookshelf";

    [Required]
    [RegularExpression(@"^[\w\-. ]+\.db$")]
    public string DatabaseFileName { get; set; } = "bookshelf.db";

    [Range(1, 500)]
    public int PageSize { get; set; } = 50;

    [Range(typeof(TimeSpan), "00:01:00", "24:00:00")]
    public TimeSpan SyncInterval { get; set; } = TimeSpan.FromMinutes(15);

    /// <summary>Per-user data folder; a deployed app cannot write next to its executable.</summary>
    public string ResolveDatabasePath() =>
        Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "Bookshelf",
            DatabaseFileName);
}

Source: Options pattern in .NET (binding, ValidateDataAnnotations, ValidateOnStart) and Logging in .NET (the Logging section). The data-folder choice is a Bookshelf decision.

The options class must be non-abstract with a public parameterless constructor and public read-write properties to bind (fields are not bound). The registration in AddBookshelfCore binds the section and calls ValidateOnStart, so an invalid value (a PageSize of 0, a file name without .db) stops StartAsync with an OptionsValidationException before the first window shows. ValidateDataAnnotations lives in the Microsoft.Extensions.Options.DataAnnotations NuGet package, which the options page names explicitly; add it to the project if the build cannot find the method.

Choosing IOptions, IOptionsSnapshot or IOptionsMonitor

Interface Registered as Sees changes to the file after start Use in Bookshelf

IOptions<T>

Singleton

No. Also no named options.

Values that are read once: file name, page size. Can be injected into any lifetime.

IOptionsSnapshot<T>

Scoped

Yes, recomputed per scope. Cannot be injected into a singleton.

Rarely useful: desktop apps seldom have a request-like scope. Valid inside a window scope.

IOptionsMonitor<T>

Singleton

Yes, through CurrentValue and OnChange.

Settings that a user can change while the app runs and that a singleton or hosted service reads.

Change notifications work only for file-system-based configuration providers (JSON, INI, XML, key-per-file, user secrets). Environment variables are read once. Containers and network shares may not deliver file-change events at all; setting DOTNET_USE_POLLING_FILE_WATCHER to 1 or true makes the host poll (every four seconds, not configurable).

What belongs in appsettings.json

  • Defaults and environment-specific settings (page size, feature flags, log levels, sync interval). Ship them next to the executable and treat them as read-only.

  • Not per-user settings. appsettings.json lives in the install folder, which is read-only for a normal user (Program Files, or the MSIX package folder). The options documentation covers reading configuration; it does not describe a pattern for writing user preferences back. Store those in a per-user location, for example under Environment.SpecialFolder.LocalApplicationData, with a small settings service. How packaged and unpackaged apps differ is covered in Local Data, Settings & Files.

  • Not secrets. Anything inside the file is readable by every user of the machine. Secret Manager (dotnet user-secrets) is loaded only in the Development environment and is not a deployment mechanism. Use Windows Credential Manager, DPAPI or a sign-in flow for tokens; the section on security in Security & App Isolation goes deeper.

Logging

The host registers ILogger<T> and ILoggerFactory. Anything built by the container can ask for ILogger<MyType>, and the category is the full type name. The defaults print to the Console provider (invisible in a WinExe), the Debug provider (shows in the Visual Studio Output window), EventSource, and the Windows Event Log.

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);
        await db.Database.MigrateAsync(cancellationToken);
        LogDatabaseReady(db.Database.GetDbConnection().DataSource);
    }

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

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

Source: Logging in .NET (message templates, LoggerMessage source generation) and .NET Generic Host (IHostedService)

The logging page recommends compile-time source generation ([LoggerMessage]) over the LogInformation extension methods for better performance and stronger typing, and recommends message templates with named placeholders (not string interpolation, which analyzer CA2254 flags). Placeholders are positional: the names are only labels.

Configure levels in the Logging section of appsettings.json (shown above) or in code through builder.Logging (ClearProviders, AddFilter, SetMinimumLevel, AddSimpleConsole). Levels run Trace (0), Debug, Information, Warning, Error, Critical and None (6). If nothing is configured the default is Information. A provider section such as Logging:Debug:LogLevel overrides Logging:LogLevel, and a more specific category beats a general one.

Practical points for desktop apps:

  • The host does not write log files. The built-in providers are Console, Debug, EventSource and EventLog (plus Azure providers meant for services). A desktop app that wants a rolling log file needs a third-party provider (Serilog, NLog and others add ILoggingBuilder extensions); those are not Microsoft libraries and are not covered by the official docs cited here.

  • Log unhandled exceptions. Inject ILogger into a small handler and subscribe to Application.DispatcherUnhandledException once the host is built, so the stack trace reaches the same sinks as every other message. See Performance & Diagnostics for crash reporting.

  • Logging must be fast and synchronous. The logging documentation says there are no asynchronous logger methods and that a slow store should be fed through an in-memory queue by a background worker.

  • Sensitive data. Trace messages "might contain sensitive app data" and are off by default; keep them off in production. Never log book-purchase tokens or credentials in message templates, because template arguments are passed to structured providers as fields.

  • Change levels without a rebuild. The File Configuration Provider reloads configuration by default, and the logging page notes that this takes immediate effect on logging configuration. Because a desktop app has no admin console, the usual route is an appsettings.Development.json or a command-line argument.

Hosted services for background work

IHostedService has two lifecycle methods, StartAsync(CancellationToken) and StopAsync(CancellationToken). The host calls them when it starts and stops. BackgroundService is an abstract class that implements the interface and asks you to override ExecuteAsync(CancellationToken), which runs for the lifetime of the host and receives a token that fires on shutdown. A hosted service can use logging, configuration and DI like any other service.

The Bookshelf sync service refreshes the local catalogue every SyncInterval. It needs a scoped service (ICatalogSyncClient, the Bookshelf contract registered as scoped in AddBookshelfCore), so it follows the documented rule for scoped work in hosted services: do not inject the scoped service; inject IServiceScopeFactory, create a scope per iteration and resolve inside it.

using Bookshelf.Core;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Bookshelf.Data;

public sealed record CatalogSyncedMessage(int BooksChanged);

public sealed class LibrarySyncService(
    IOptions<BookshelfOptions> options,
    IServiceScopeFactory scopeFactory,
    IMessenger messenger,
    ILogger<LibrarySyncService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(options.Value.SyncInterval);

        try
        {
            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                try
                {
                    await using var scope = scopeFactory.CreateAsyncScope();
                    var client = scope.ServiceProvider.GetRequiredService<ICatalogSyncClient>();
                    int changed = await client.SyncAsync(stoppingToken);
                    messenger.Send(new CatalogSyncedMessage(changed));
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    // One failed sync must not stop the host: log it and wait for the next tick.
                    logger.LogError(ex, "Catalogue sync failed; retrying at the next interval.");
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Normal shutdown.
        }
    }
}

Source: Worker Services in .NET (BackgroundService, IHostedService) and Dependency injection overview ("Scope scenarios"). The PeriodicTimer loop and the message are Bookshelf choices.

Notes:

  • Timing. StartAsync of each hosted service is awaited by host.StartAsync(). For a BackgroundService the host starts ExecuteAsync without waiting for it to complete, which is why it is the right place for long-running work.

  • Threads. ExecuteAsync is not on the UI thread. A change to an ObservableCollection bound to a WPF ItemsControl must be marshalled to the dispatcher; sending a message that the view model handles on the UI thread is one way (see Messaging Between View Models and Threading, Dispatcher & Async). The messenger delivers the message on the thread that called Send, so the recipient is responsible for switching to the dispatcher.

  • Overridden lifecycle methods. If you override StartAsync or StopAsync on a BackgroundService, call and await the base method, or the service will not start or stop correctly.

  • Failure behaviour. The HostOptions.BackgroundServiceExceptionBehavior property decides what the host does when a BackgroundService throws an unhandled exception. The default is StopHost: since .NET 6 the exception is logged and the host stops (Ignore restores the earlier behaviour). A stopped host in a WPF app does not close the window: the UI keeps running while every hosted service has stopped. Catch exceptions inside the loop, as above, instead of relying on the host.

  • Cancellation and timeouts. StopAsync receives a token that is cancelled when the host’s shutdown timeout expires. HostOptions.ShutdownTimeout defaults to 30 seconds. A desktop app should keep stopping quick (a few seconds) because the user has already closed the window.

HostOptions at a glance

Property Meaning

StartupTimeout

Default timeout for StartAsync. The default is Timeout.InfiniteTimeSpan (no timeout).

ShutdownTimeout

Default timeout for StopAsync. The default is 30 seconds.

ServicesStartConcurrently and ServicesStopConcurrently

Whether the host starts or stops hosted services concurrently or sequentially. Both default to false (sequential); added in .NET 8.

BackgroundServiceExceptionBehavior

What the host does when a BackgroundService throws. The default is StopHost.

Source: HostOptions class (the property pages state the false defaults) and Exception handling in hosting (.NET 6 breaking change); the 30-second and infinite timeout defaults are the initializers in HostOptions.cs.

All five are on the HostOptions class (Microsoft.Extensions.Hosting.HostOptions). Configure them like any other options: builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(5));.

Short-lived work that is not a hosted service

A command that runs because the user clicked a button belongs in a view model, not in a hosted service. The host is the right home for work that lives with the application (a periodic sync, a file watcher, a database migration at start). A hosted service is also the right place to start the first load so that Application_Startup stays quick: register a service that fills a cache, and let the window show a loading state in the meantime.

EF Core without a request scope

Bookshelf stores books in SQLite through EF Core. The EF Core documentation explains the key constraints:

  • a DbContext is designed for one unit of work and its lifetime "is usually very short";

  • it is not thread-safe: do not share an instance between threads, and await every async call before the next use;

  • it must be disposed after use.

ASP.NET Core’s AddDbContext registers the context with a scoped lifetime, which maps to one request. A desktop app has no request scope: AddDbContext plus constructor injection into a singleton repository would keep one context for the whole session, accumulate every tracked entity, and break as soon as two operations overlap. The EF Core page describes AddDbContextFactory for "application types …​ that use dependency injection but do not create a service scope that aligns with the desired DbContext lifetime" (it names Blazor, but the same logic applies to WPF), and states that contexts created by the factory "are not managed by the application’s service provider and therefore must be disposed by the application".

using Bookshelf.Core;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

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; }

    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 sealed class BookshelfDbContext(DbContextOptions<BookshelfDbContext> options) : DbContext(options)
{
    public DbSet<BookEntity> Books => Set<BookEntity>();
}

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);
        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);
        db.Books.Update(BookEntity.FromBook(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 ("Use a DbContext factory", "Avoiding DbContext threading issues"). The entity, context and repository bodies are Bookshelf code: the EF Core page shows AddDbContextFactory and CreateDbContext but no repository.

Why this works with the lifetimes above:

  • The factory is cheap and stateless, so the repository can be a singleton. AddDbContextFactory registers the factory (and the options) as a singleton by default; its lifetime parameter changes that. For convenience it also registers the context type itself as scoped, which a desktop app should not inject into singletons.

  • Every method creates its own BookshelfDbContext with await using, so two overlapping operations (a LoadCommand and the sync service) use two contexts and never trigger the "A second operation started on this context" error.

  • The repository returns immutable Book records, not tracked entities, which keeps EF Core types out of the view models and out of Bookshelf.Core.

  • DatabaseInitializer (a hosted service) applies the migrations before any window opens. Starting from a hosted service avoids calling Database.Migrate() from a constructor.

Do not register the context with AddDbContext in a WPF app unless you also create a scope per unit of work. The connection string here is a local SQLite file and contains no credential; a database that needs one belongs behind an authenticated service, not in appsettings.json.

The MVVM Toolkit alternative: Ioc.Default

The MVVM Toolkit does not ship a container. Its Ioc page says so: the toolkit "doesn’t provide built-in APIs" for dependency injection because Microsoft.Extensions.DependencyInjection already exists. What it does provide is the CommunityToolkit.Mvvm.DependencyInjection.Ioc class, a thread-safe holder for one IServiceProvider:

Member Description

Ioc.Default

The default Ioc instance, which implements IServiceProvider.

ConfigureServices(IServiceProvider)

Initialises the shared provider. The class documentation says to call it at start-up and to pass any provider that offers DI, for example Microsoft.Extensions.DependencyInjection.

GetService<T>()

Tries to resolve an instance of T; returns null if the service is missing.

GetRequiredService<T>()

Resolves T and throws if it is missing.

GetService(Type)

The IServiceProvider implementation.

The Ioc conceptual page (last updated in 2022 according to its metadata, with a UWP example) walks through the same idea with a hand-written App.Services property and App.Current.Services.GetService<T>(), and the WinUI tutorial on Microsoft Learn (step 3, "Add dependency injection") uses the same shape. Neither shows Ioc.Default; the class documentation does.

The two can be combined: let the host build the provider, then publish it to Ioc.Default for the few places that cannot use constructor injection.

using CommunityToolkit.Mvvm.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;

// In Application_Startup, after _host = builder.Build();
Ioc.Default.ConfigureServices(_host.Services);

// In a UserControl that WPF creates from XAML and therefore constructs with a parameterless constructor:
public partial class BookCoverControl : UserControl
{
    public BookCoverControl()
    {
        InitializeComponent();
        DataContext = Ioc.Default.GetRequiredService<BookCoverViewModel>();
    }
}

Source: Ioc class and Ioc (MVVM Toolkit). Combining Ioc.Default with a host-built provider is a Bookshelf pattern; the official pages show it with a provider built directly from a ServiceCollection.

When to use which approach:

  • Constructor injection everywhere you control construction (windows resolved from the host, view models, services). This is the approach the guidelines recommend.

  • Ioc.Default only at the edges where WPF or XAML, not you, creates the object: a UserControl placed in XAML, a value converter or markup extension instantiated from a resource dictionary, a design-time helper. Each such use is a service-locator call and should be treated as technical debt to be replaced by a view-model-first pattern (an implicit DataTemplate that maps a view model type to a view, so the view inherits its DataContext).

  • Not in view models or services. A view model that calls Ioc.Default.GetRequiredService<IBookRepository>() is harder to test than one that takes IBookRepository in its constructor, and the test must configure global state.

An official note: the Ioc documentation does not state what ConfigureServices does when it is called twice. It is commonly said to throw an InvalidOperationException; the page cited here does not confirm it, so tests that configure the static instance should do so once in a fixture.

The same setup in WinUI 3

WinUI 3 apps start from a different Application class (Microsoft.UI.Xaml.Application) and activate through OnLaunched. No Microsoft Learn page was found that shows the Generic Host in a WinUI 3 app; the WinUI MVVM Toolkit tutorial builds a plain ServiceCollection in the App constructor. The snippet below combines that tutorial’s shape with the host from the WPF how-to. It is a pattern derived from the official pieces, not an official sample.

using Bookshelf.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.UI.Xaml;

namespace Bookshelf.WinUI;

public partial class App : Application
{
    private readonly IHost _host;

    public App()
    {
        InitializeComponent();

        var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
        {
            ContentRootPath = AppContext.BaseDirectory,
        });
        builder.Services.AddBookshelfCore(builder.Configuration); // options, data, hosted services
        builder.Services.AddSingleton<MainWindow>();
        _host = builder.Build();
    }

    protected override async void OnLaunched(LaunchActivatedEventArgs args)
    {
        await _host.StartAsync();

        var window = _host.Services.GetRequiredService<MainWindow>();
        window.Closed += async (_, _) =>
        {
            await _host.StopAsync();
            _host.Dispose();
        };
        window.Activate();
    }
}

Source: Data binding with WinUI and MVVM Toolkit, step 3: add dependency injection for the App and OnLaunched structure; the host calls follow Use the .NET Generic Host in a WPF app. The combination is a pattern, not an official WinUI sample.

Differences to keep in mind:

  • The WinUI tutorial registers its view models as transient and its file service as a singleton, so that each page gets a fresh view model. That is the standard Frame/page pattern; the pages are created by Frame.Navigate(typeof(Page)), which uses the parameterless constructor, so the tutorial resolves the view model from App.Current.Services inside the page. The cost is again a service-locator call per page.

  • Unpackaged apps can write under Environment.SpecialFolder.LocalApplicationData. Packaged apps (MSIX) use ApplicationData.Current.LocalFolder, which the tutorial uses for its file service. Make the data path an option or an injected service so that the same registrations run in both.

  • Window.Closed is the usual place to stop the host. WinUI’s Application does not offer an Exit event like WPF’s; confirm the exact shutdown hook for your Windows App SDK version in the lifecycle documentation before relying on Closed for apps with several windows.

  • The MVVM Toolkit is identical on both stacks. The x:Bind binding style, which requires a typed property on the page, usually pairs with a view model resolved in the page constructor, as in the tutorial.

The same setup in Windows Forms

Windows Forms has an official how-to as well (Use the .NET Generic Host in a Windows Forms app, March 2026). Because Main owns the message loop, the host is configured directly in Program.cs, and the form is passed to Application.Run:

using Bookshelf.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Bookshelf.WinForms;

static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();

        var builder = Host.CreateApplicationBuilder();
        builder.Services.AddBookshelfCore(builder.Configuration);
        builder.Services.AddTransient<MainForm>();

        IHost host = builder.Build();
        host.Start();

        MainForm mainForm = host.Services.GetRequiredService<MainForm>();
        Application.Run(mainForm);

        host.StopAsync().GetAwaiter().GetResult();
        host.Dispose();
    }
}

Source: Use the .NET Generic Host in a Windows Forms app. AddBookshelfCore is the shared Bookshelf registration method; the official sample registers its own services inline.

Notes: the official sample registers the form as transient and calls the synchronous host.Start(). For Visual Basic with the Application Framework it uses the Startup and Shutdown events in ApplicationEvents.vb and an OnCreateMainForm override; removing the startup form setting avoids a designer-generated override that bypasses DI. Constructor injection into forms has a designer implication: Visual Studio’s designer needs to be able to create a form it is displaying, and a form whose only constructor has parameters is a frequent source of designer errors. Many teams keep a parameterless constructor for the designer next to the injected one; the how-to does not mention this, so check your designer version.

WPF, WinUI 3 and Windows Forms compared

Aspect WPF WinUI 3 Windows Forms

Where the host is built

Startup handler or OnStartup override

App constructor (build) and OnLaunched (start)

Program.Main

Where it is started

await host.StartAsync() in Startup

await host.StartAsync() in OnLaunched

host.Start() before Application.Run

Where it is stopped

Exit handler or OnExit override

Window.Closed (pattern, not an official sample)

After Application.Run returns, in Main

Window creation

GetRequiredService<MainWindow>() then Show(). Remove StartupUri.

GetRequiredService<MainWindow>() then Activate().

GetRequiredService<MainForm>() then Application.Run(form).

Pages and views

Created by XAML or by your factory; implicit DataTemplate keeps views parameterless.

Frame.Navigate uses parameterless pages, so pages use a locator or a navigation factory.

Forms and user controls are created by you or the designer.

Official host sample

Yes (how-to above).

Not found; the tutorial builds a ServiceCollection.

Yes (how-to above).

Content root and appsettings.json

Set ContentRootPath explicitly.

Same; MSIX installs are read-only.

Same.

UI thread

Dispatcher (Application.Current.Dispatcher)

DispatcherQueue

SynchronizationContext and Control.Invoke

Ioc.Default use

Works (no stack-specific requirement).

Works.

Works.

What changed over time

Release Change relevant to desktop DI and hosting

.NET Framework 4.6.2 and later

The HostOptions API page lists .NET Framework 4.6.2 to 4.8.1 among the supported frameworks, so the hosting packages can be referenced from a .NET Framework desktop app. The WPF and Windows Forms how-tos target modern .NET only.

.NET Core 3.0 to .NET 5

The Generic Host and Host.CreateDefaultBuilder become the standard hosting model for non-web apps. WPF and Windows Forms run on .NET Core 3.0 and later.

.NET 6

Host.CreateApplicationBuilder / HostApplicationBuilder introduced (per the Generic Host page). ConsoleLifetime handles POSIX signals; this has no practical effect on a WinExe application that has no console.

.NET 8

Keyed services (AddKeyedSingleton, [FromKeyedServices]), IHostedLifecycleService, and HostOptions.ServicesStartConcurrently / ServicesStopConcurrently.

.NET 10

GetKeyedService() (singular) with KeyedService.AnyKey now throws InvalidOperationException, per the DI overview. The WPF how-to uses Microsoft.Extensions.Hosting 10.0.*.

.NET 11 (RC2)

No change for desktop hosting is listed on the consulted pages. The HostOptions API reference already shows an 11.0 build.

Testing the composition root

Production runs as Production, where scope and dependency validation are off. A composition mistake (a missing registration, a scoped service injected into a singleton) then shows up when the user first opens the affected window. Make a test do the validation instead, using the same extension method as the app:

using Bookshelf.Core;
using Bookshelf.Desktop;
using Bookshelf.Desktop.ViewModels;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Bookshelf.Tests;

public sealed class CompositionTests
{
    [Fact]
    public void Container_builds_and_resolves_the_main_view_model()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Bookshelf:DatabaseFileName"] = "test.db",
            })
            .Build();

        var services = new ServiceCollection();
        services.AddLogging();
        services.AddBookshelfDesktop(configuration);

        using var provider = services.BuildServiceProvider(
            new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true });

        Assert.NotNull(provider.GetRequiredService<BooksViewModel>());

        // Resolved at run time by LibrarySyncService, so ValidateOnBuild cannot see it: resolve it here, in a scope.
        using var scope = provider.CreateScope();
        Assert.NotNull(scope.ServiceProvider.GetRequiredService<ICatalogSyncClient>());
    }
}

Source: Dependency injection overview ("Scope validation") and .NET Generic Host (ServiceProviderOptions.ValidateOnBuild). Resolving WPF windows in a test needs an STA thread, so the test above stops at the view model; see Testing View Models & UI.

The guidelines add: pass validateScopes: true to BuildServiceProvider to turn a captive dependency into an InvalidOperationException ("Cannot consume scoped service …​"). ValidateOnBuild additionally checks at build time that every registered service can be constructed. Both options are on the ServiceProviderOptions class that the Generic Host page links. Neither can see a service that is only requested at run time through GetRequiredService, such as the ICatalogSyncClient that LibrarySyncService resolves inside its own scope: if that registration were missing, the container would still build, and the failure would appear on the first sync. That is why the test resolves it explicitly.

Performance and security

  • Start-up cost. StartAsync and the first GetRequiredService<MainWindow>() run before the window appears. The official pages give no numbers; measure with a stopwatch around Application_Startup and move anything slow (migration of a large database, a network call) out of IHostedService.StartAsync into a BackgroundService.

  • Container cost. Singleton and scoped resolution is cheap, and resolving the services of a window is a one-time cost per window. Avoid resolving from the container in a tight loop or inside a data template.

  • Memory. A singleton keeps its whole object graph alive for the life of the process (the guidelines list this under the drawbacks of singletons). A disposable transient resolved from the root provider is never released before shutdown; the guidelines show 1,000 such objects rooted until the provider is disposed.

  • Native AOT and trimming. The container discovers constructors at run time. The section index notes that trimming and Native AOT are not supported for WPF and Windows Forms, so this does not matter for those stacks.

  • Security. Keep secrets out of appsettings.json and out of source control; log with templates, not by concatenating user input; do not turn on Trace or EnableSensitiveDataLogging in a release build, because both can write book data and connection details to a log that other processes on the machine can read.

Pitfalls

  • Leaving StartupUri in App.xaml. WPF creates the window itself with the parameterless constructor, the constructor with parameters is never called, and you get an exception or a window without a view model.

  • A failure in Application_Startup with no Shutdown. The process keeps running invisibly. Wrap startup in try and catch, show the error and call Shutdown.

  • appsettings.json not found. The file is not copied (CopyToOutputDirectory missing) or the current directory is not the executable folder. Set ContentRootPath and the copy rule.

  • Different environments in Visual Studio and in production. Scope validation, Secret Manager and appsettings.Development.json apply only in Development. Validate the container in a test.

  • A scoped service resolved from the root provider. It becomes a singleton; the DbContext registered with AddDbContext is the classic case.

  • A singleton view model that holds a scoped or transient dependency. The first instance is kept for the lifetime of the process (captive dependency).

  • Disposable transient view models or windows resolved from the root. The container holds them until shutdown. Resolve them in a window scope, or make them non-disposable and unsubscribe from events explicitly.

  • Calling Dispose on a resolved service. The container owns it.

  • Registering a pre-built instance and expecting disposal. The container does not dispose objects it did not create.

  • A hosted service that throws. The host may stop while the UI keeps running. Catch inside ExecuteAsync.

  • A long StopAsync or a hosted service that posts to the dispatcher during shutdown. The window is gone; the work hangs until the shutdown timeout or the process ends.

  • async void outside event handlers. Only Startup, Exit and the other event handlers can use it; command bodies should be async Task (see The MVVM Toolkit (CommunityToolkit.Mvvm)).

  • BuildServiceProvider inside a registration. The guidelines advise against building a second provider to resolve something while registering; use the overload that receives IServiceProvider, or read IConfiguration directly as AddBookshelfDesktop does.

  • Async factories. async / await service resolution is not supported; calling .Result on a task inside a factory deadlocks. Keep factories fast and synchronous and load data after resolving.

  • Static access to the provider. App.Current.Services and Ioc.Default make DI an alternative that you then bypass. Use them at the edges only.

  • Using the same container instance in tests. Ioc.Default is global; configure it once.

What the official documentation does not provide

  • There is no Microsoft Learn article that covers MVVM in WPF together with DI, so the registration choices on this page for windows, view models and scopes combine the WPF how-to, the Learn DI pages and the toolkit pages.

  • The WPF how-to is intentionally small. It does not mention ContentRootPath, the cost of async void, scopes, options validation, the lifetime of a DbContext, or what happens when a hosted service fails. Those points on this page come from the pages for the individual features, not from the WPF article.

  • There is no official ViewModel locator, window factory, dialog service or navigation service, and the toolkit has none (a ViewModelLocator existed in MvvmLight). WindowScopeManager, IDialogService and INavigationService on this page are user patterns.

  • The toolkit’s Ioc conceptual page has not been rewritten for the host and shows a UWP example. The class documentation that describes Ioc.Default is the API reference.

  • No official WinUI 3 Generic Host sample was found; the WinUI MVVM Toolkit tutorial uses a ServiceCollection.

  • The default values of HostOptions.StartupTimeout, ShutdownTimeout and BackgroundServiceExceptionBehavior are not stated on the API page for the class; check the property pages for your exact .NET version.