WPF Architecture & Application Model

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.

Windows Presentation Foundation (WPF) is the retained-mode, vector-based UI framework for Windows desktop apps on .NET. Almost everything else in this section (layout, data binding, styles, templates, commands) is built on a handful of architectural decisions: a layered assembly stack, a single class hierarchy that adds one capability per level, two trees that describe the same UI in different ways, and an Application object that owns the process lifetime. This page explains those foundations. It 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; the .NET 11 WPF release notes list bug fixes only at the time of writing, so nothing on this page changes for it. WPF is the stack in focus; WinUI 3 and Windows Forms are compared in a table further down.

The running scenario is Bookshelf Desktop, with a BooksViewModel, a BookDetailsViewModel, an IBookRepository and an IDialogService in the Bookshelf.Desktop namespace (models live in Bookshelf.Core). On this page the scenario supplies the main window whose trees are inspected, the App class that starts and stops the app, the global exception handling, and a single-instance guard so that opening the app twice brings the first window forward. The XAML language is covered on XAML Language Fundamentals and the project layout on Project Structure & Build; windows, dialogs and navigation, dependency properties, routed events, the Dispatcher and the Generic Host have their own later pages, and are only introduced here.

The layers of WPF

WPF is a mix of managed code and a small unmanaged core. The conceptual overview on Microsoft Learn divides the managed part into three assemblies and names the unmanaged part the media integration layer (milcore):

Layer Managed or unmanaged What it contributes

PresentationFramework

managed

The top layer that applications touch most: FrameworkElement, Control, the panels and controls, Window and Application, styles and templates, data binding, the XAML loading integration and navigation.

PresentationCore

managed

The WPF core types: Visual, UIElement, input and events infrastructure, geometry, imaging, text and media, the managed wrapper around the composition engine.

WindowsBase

managed

The foundation types: DispatcherObject, Dispatcher, DependencyObject, DependencyProperty, and also the Freezable base class and the packaging APIs (System.IO.Packaging).

milcore (media integration layer)

unmanaged

The rendering engine. It is written in native code for performance, talks to DirectX, and composes the retained scene that managed code describes. Managed code sends it rendering instructions; it draws.

DirectX, user32, the desktop window manager

unmanaged

The platform WPF sits on. A WPF window is one top-level window handle (HWND) whose content WPF draws itself, rather than one HWND per control.

Two further assemblies matter in practice: System.Xaml (the XAML reader and writer that all XAML dialects built on .NET share, see the XAML Language Fundamentals page) and UIAutomationProvider / UIAutomationTypes (the accessibility tree). Application code almost always references the whole bundle through the single Microsoft.WindowsDesktop.App.WPF framework reference that UseWPF adds to the project.

Two consequences of the layering shape everyday WPF work:

  • Retained-mode rendering. An app does not repaint on WM_PAINT. Elements describe what they look like (a drawing, a geometry, text), WPF retains that description, and the composition engine redraws from it. Invalidating and re-laying out the UI means changing the retained description, not painting. This is why OnRender takes a DrawingContext that records instructions instead of a graphics device.

  • Two threads, one affinity rule. The Learn threading-model page describes a UI thread (which owns the elements and runs the Dispatcher) and a separate render thread that composes in the background. Every DispatcherObject belongs to the thread that created it, and touching it from another thread throws an InvalidOperationException. The Threading, the Dispatcher & Async page covers this in depth.

What changed from .NET Framework to .NET 8, 9 and 10

The architecture is the same as on .NET Framework 4.x: the same layers, the same class hierarchy, the same trees and the same Application model. What changed is how the stack is built, shipped and extended:

Topic .NET Framework 4.x .NET 8, 9 and 10 (11 noted)

Where WPF lives

Part of the machine-wide .NET Framework, serviced with Windows.

The Microsoft.WindowsDesktop.App shared framework (or bundled with a self-contained app), selected by the net10.0-windows target framework and UseWPF.

Source and servicing

Closed source.

Open source at https://github.com/dotnet/wpf (managed code, plus the native graphics DLLs shipped with the shared framework). Updates arrive with .NET patch releases.

Native renderer

milcore.dll from the operating system.

A separately shipped native core: the native graphics library ships as wpfgfx_cor3.dll next to the managed assemblies (see the dotnet/wpf repository).

New features

Frozen.

Active but modest. .NET 9 added the Fluent theme (experimental). .NET 10 added more Fluent coverage and fixes. .NET 11 (RC2) lists bug fixes only. See the Themes & Fluent Design page.

Project format

Old-style .csproj, packages.config, app.config.

SDK-style projects, PackageReference, appsettings.json through the Generic Host. See Project Structure & Build.

Removed or unavailable

AppDomains per feature, Remoting, Code Access Security, WCF server types.

Not available on modern .NET; the Migrating from .NET Framework page lists them.

Trimming and Native AOT

Not applicable.

Not supported for WPF (the SDK reports NETSDK1168 if you try).

The element class hierarchy

WPF gives every on-screen thing a place in a single inheritance chain. Each level adds exactly one capability, which is why the chain is the best map of what a given object can do.

classDiagram direction TB class DispatcherObject { Dispatcher Dispatcher CheckAccess() bool VerifyAccess() void } class DependencyObject { GetValue(DependencyProperty) object SetValue(DependencyProperty, object) void ClearValue(DependencyProperty) void } class Freezable { Freeze() void IsFrozen bool } class ContentElement { input and focus for text content } class FrameworkContentElement { Run Paragraph Span Hyperlink } class Visual { rendering and hit testing parent and children in the visual tree } class UIElement { Measure(Size) void Arrange(Rect) void routed events and input Visibility IsEnabled } class FrameworkElement { DataContext Style Resources Width Height Margin logical tree and data binding } class Control { Template Background Foreground Padding tab navigation } class ContentControl { Content } class Window class Button class Panel { Children } class TextBlock DispatcherObject <|-- DependencyObject DependencyObject <|-- Freezable DependencyObject <|-- ContentElement ContentElement <|-- FrameworkContentElement DependencyObject <|-- Visual Visual <|-- UIElement UIElement <|-- FrameworkElement FrameworkElement <|-- Control FrameworkElement <|-- Panel FrameworkElement <|-- TextBlock Control <|-- ContentControl ContentControl <|-- Window ContentControl <|-- Button

The diagram shows the namespace System.Windows and its children. Reading it from the top:

Class Assembly What it adds

DispatcherObject

WindowsBase

Thread affinity. Every instance remembers the Dispatcher of the thread that created it. CheckAccess answers whether the calling thread may touch the object and VerifyAccess throws if it may not. Only the owning thread may use the object, with the exception of Freezable objects once frozen.

DependencyObject

WindowsBase

The WPF property system. GetValue / SetValue read and write dependency properties, which gives value precedence, inheritance, change notification, animation and binding. It also underlies styles, triggers and templates. See the Dependency Properties & Attached Properties page.

Visual

PresentationCore

The ability to be drawn: a place in the visual tree, a coordinate space, hit testing and the connection to the composition engine. Visual has no layout, input or events; those come next.

UIElement

PresentationCore

Layout (Measure, Arrange), routed events, input, focus and commanding hooks. Elements that derive from UIElement but not FrameworkElement are rare.

FrameworkElement

PresentationFramework

The framework-level layer: DataContext and data binding, Style, Resources, Width / Height / Margin, the logical tree, and the initialized, loaded and unloaded lifecycle events.

Control

PresentationFramework

The templatable controls: the Template property, Background / Foreground / Padding and font properties, and keyboard tab navigation. A control’s look comes from its ControlTemplate, not from code in the class.

Three branches deserve a mention:

  • Freezable derives from DependencyObject but not from Visual. Brushes, pens, geometries and transforms are freezable; a frozen object is immutable, may be shared across threads and is cheaper to render.

  • ContentElement and FrameworkContentElement are the non-visual counterparts of UIElement and FrameworkElement. Flow-document inlines such as Run, Span and Hyperlink derive from them. They take part in the logical tree and in routed events, but they are not Visuals: a text run is drawn by its container.

  • Panel and TextBlock derive directly from FrameworkElement, not from Control. They have no ControlTemplate: a Panel lays out its Children and a TextBlock draws text itself.

Choosing a base class

The hierarchy answers a practical question: from which class should a new type derive?

You want to …​ Derive from Why

Store data with binding and animation, but no UI

DependencyObject

Gets dependency properties without layout or rendering. Behaviours and helpers often derive from Freezable or DependencyObject.

Draw something custom and lay it out, without templating

FrameworkElement

Override OnRender, MeasureOverride and ArrangeOverride.

Compose existing controls

UserControl

Pure XAML plus code-behind, no ControlTemplate.

Build a restyleable control with a default look

Control (or ContentControl, ItemsControl)

A ControlTemplate plus a Themes/Generic.xaml default style. See User Controls & Custom Controls.

Hold many children and arrange them

Panel

Override the layout passes. See Layout & Panels.

View model or model type

none of the above

A view model is a plain .NET object with INotifyPropertyChanged; deriving from WPF types ties it to the UI thread. The MVVM Toolkit’s ObservableObject is the usual base.

Thread affinity in two lines

Because DispatcherObject is the root, almost every WPF type is single-thread-affine. The check below is the building block that later pages expand into Dispatcher.InvokeAsync, async / await and IProgress<T>:

using System.Windows.Controls;

namespace Bookshelf.Desktop;

public static class StatusText
{
    // Safe to call from any thread: marshals to the UI thread when needed.
    public static void Show(TextBlock target, string message)
    {
        if (target.Dispatcher.CheckAccess())
        {
            target.Text = message;
        }
        else
        {
            _ = target.Dispatcher.InvokeAsync(() => target.Text = message);
        }
    }
}

Source: Threading model

Logical tree and visual tree

A WPF UI exists as two trees at the same time. The Learn page Trees in WPF defines them: the logical tree describes the elements as they are declared in XAML or created in code, and the visual tree is the expansion of that tree into every Visual that is actually rendered, including everything created by control templates.

Two tree diagrams of the same Bookshelf Desktop window: the logical tree with Window, Grid, TextBox, ListBox and Button, and the visual tree with Border, AdornerDecorator, ContentPresenter and template parts
Logical tree Visual tree

What it contains

The objects you declared plus generated item containers. Content such as a Button with the string "Add book" has that string as its logical child.

Every Visual and Visual3D that is drawn, including the template parts (Border, ContentPresenter, ScrollViewer, ItemsPresenter, TextBlock) a control creates for itself.

Helper class

LogicalTreeHelper (GetChildren, GetParent, FindLogicalNode)

VisualTreeHelper (GetChildrenCount, GetChild, GetParent, HitTest)

What walks it

Dependency property value inheritance (DataContext, FontSize, FlowDirection), resource lookup, DynamicResource, and ElementName binding resolution within a namescope.

Rendering, hit testing, and the route taken by routed events (the tunnelling and bubbling routes follow the visual tree).

When it exists

As soon as the element is created and parented.

After the template is applied, typically at the first measure or at Loaded. Before that, a templated control has no visual children.

Stability

Stable: it mirrors your markup.

Unstable: it changes with the theme (Aero2 versus Fluent) and with any retemplating. Never hard-code a path of GetChild(0) calls.

The two trees explain several behaviors that otherwise look random:

  • A resource declared on a ContextMenu or Popup owner is found because the lookup follows the logical tree; a popup’s visual tree is a separate rooted tree.

  • Binding with ElementName finds a name only in the same namescope, which is why names inside a DataTemplate are invisible from the window (the Markup Extensions & Namescopes page explains namescopes).

  • Loaded can fire on a parent before the templated children of a control exist. Code that needs a template part overrides OnApplyTemplate in a custom control or listens to Loaded and walks the visual tree afterwards.

  • VisualTreeHelper.GetParent works only for Visual and Visual3D; for a Run or another content element use LogicalTreeHelper.GetParent (the visual helper throws for other types).

Walking the trees

The helpers below dump both trees of the Bookshelf Desktop main window and find an ancestor by type. They are diagnostic tools: production code should bind and template rather than reach into the visual tree.

using System.Text;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Media3D;

namespace Bookshelf.Desktop;

public static class TreeInspector
{
    public static string DumpLogical(DependencyObject root)
    {
        var builder = new StringBuilder();
        WriteLogical(root, 0, builder);
        return builder.ToString();
    }

    private static void WriteLogical(object node, int depth, StringBuilder builder)
    {
        builder.Append(' ', depth * 2).AppendLine(Describe(node));
        if (node is not DependencyObject element)
        {
            return; // strings and other plain content are leaves
        }

        foreach (var child in LogicalTreeHelper.GetChildren(element))
        {
            WriteLogical(child, depth + 1, builder);
        }
    }

    public static string DumpVisual(DependencyObject root)
    {
        var builder = new StringBuilder();
        WriteVisual(root, 0, builder);
        return builder.ToString();
    }

    private static void WriteVisual(DependencyObject node, int depth, StringBuilder builder)
    {
        builder.Append(' ', depth * 2).AppendLine(Describe(node));
        if (node is not (Visual or Visual3D))
        {
            return; // content elements are not visuals
        }

        var count = VisualTreeHelper.GetChildrenCount(node);
        for (var index = 0; index < count; index++)
        {
            WriteVisual(VisualTreeHelper.GetChild(node, index), depth + 1, builder);
        }
    }

    public static T? FindVisualAncestor<T>(DependencyObject start)
        where T : DependencyObject
    {
        for (var current = VisualTreeHelper.GetParent(start);
             current is not null;
             current = VisualTreeHelper.GetParent(current))
        {
            if (current is T match)
            {
                return match;
            }
        }

        return null;
    }

    private static string Describe(object node) =>
        node is FrameworkElement { Name.Length: > 0 } named
            ? $"{node.GetType().Name} ({named.Name})"
            : node.GetType().Name;
}

Call it after the window has been laid out, for example from the Loaded event of the main window:

using System.Diagnostics;
using System.Windows;

namespace Bookshelf.Desktop;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        Loaded += OnLoaded;
    }

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        Debug.WriteLine(TreeInspector.DumpLogical(this));
        Debug.WriteLine(TreeInspector.DumpVisual(this));
    }
}

Source: Trees in WPF

Visual Studio shows the same two views without code: the Live Visual Tree window lists the visual tree of a running app and the Live Property Explorer shows where each property value comes from (see Development Environment & Tooling).

The Application class

Every WPF app has exactly one System.Windows.Application instance per process, reachable from any thread through the static Application.Current. It is also a DispatcherObject, so its members belong to the UI thread. The Learn page Application management overview lists its responsibilities:

  • Lifetime: the entry point, Run, the startup and shutdown events and Shutdown.

  • Windows: the Windows collection of all open windows and the MainWindow property.

  • Resources: Application.Resources, the outermost level of resource lookup (see Resources & Resource Dictionaries).

  • Command-line arguments: StartupEventArgs.Args in the Startup event.

  • Unhandled exceptions: DispatcherUnhandledException.

  • State bags: Application.Properties, a dictionary of objects shared across the app. It is a legacy convenience; prefer dependency injection (see the Dependency Injection & the Generic Host page).

App.xaml and the generated entry point

App.xaml is compiled with the ApplicationDefinition build action. The build generates App.g.cs, a partial class with a Main method marked [STAThread] that creates the App, calls InitializeComponent and then calls Run. A WPF thread must be single-threaded apartment (STA) because OLE-based features such as the clipboard, drag and drop and COM-based controls require it. You normally never write Main.

The lifecycle after Run is:

  1. The App constructor runs and InitializeComponent loads App.xaml (resources, event hookups).

  2. Run starts the dispatcher message loop and raises Startup (and calls OnStartup).

  3. If StartupUri is set, WPF creates the window or page at that URI. Otherwise the code in Startup creates the first window.

  4. The loop runs until shutdown is requested; Exit is raised (and OnExit called) and Run returns the exit code.

StartupUri versus the Startup event

Way to open the first window Use it when Limits

StartupUri="MainWindow.xaml" in App.xaml

The first window needs no constructor arguments. This is the default of dotnet new wpf.

WPF creates the window itself through its parameterless constructor, so there is no place to pass in a view model or a service. Command-line arguments are not available to the window constructor.

The Startup event (or OnStartup override), with no StartupUri

You compose dependencies, read arguments, run a single-instance check, show a splash window, or decide at run time which window to show.

You must create and Show the window yourself, and set MainWindow if the window is not the first one created.

A hand-written Main

You need to run code before the App is constructed.

The App.xaml build action must be changed from ApplicationDefinition to Page so the generated entry point no longer exists. From memory (the Learn page does not give the full procedure): also set the project’s StartupObject if there is more than one Main.

The Generic Host

You want dependency injection, configuration and logging.

You start the host in OnStartup and stop it in OnExit. See the Dependency Injection & the Generic Host page.

ShutdownMode

ShutdownMode decides when Run returns. It can be set in XAML (ShutdownMode="OnMainWindowClose") or in code before the app runs; the value can also be changed while it runs.

Value The app shuts down when …​ Typical use

OnLastWindowClose (the default)

the last open window closes, or Shutdown is called.

Document-style apps with peer windows. Careful with a hidden splash window: it keeps the app alive.

OnMainWindowClose

the window in Application.MainWindow closes, or Shutdown is called, even if other windows are still open.

An app with one main window and several tool windows, like Bookshelf Desktop.

OnExplicitShutdown

Shutdown is called, and only then.

Notification-area (tray) apps, background services with a UI that appears on demand, or an app that shows a logon window before the main window.

MainWindow is set automatically to the first window created, which is a common source of surprises (a login window becomes the main window). Application.Shutdown accepts an exit code, which becomes the process exit code unless Exit overrides it through ExitEventArgs.ApplicationExitCode.

Startup, Exit and SessionEnding

Event (and virtual method) Event arguments Purpose

Startup / OnStartup

StartupEventArgs with Args

Compose the app and show the first window. Raised after Run starts and before StartupUri is processed.

Activated / Deactivated

EventArgs

Raised when the application gains or loses foreground status (any of its windows becomes the active window, or none is). Use them to pause polling or timers.

SessionEnding

SessionEndingCancelEventArgs with ReasonSessionEnding (Logoff or Shutdown) and Cancel

Raised when the user logs off or shuts down Windows. Persist state here. Setting Cancel to true asks Windows to stop the logoff or shutdown; do not use it to block the user. If you do not cancel, WPF shuts the app down for you.

Exit / OnExit

ExitEventArgs with ApplicationExitCode

Raised when shutdown has begun and cannot be cancelled. Dispose resources, stop background work, release the single-instance mutex, flush logs. Keep it short: Windows may force-terminate a process that is slow to exit during logoff.

<Application x:Class="Bookshelf.Desktop.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             ShutdownMode="OnMainWindowClose"
             Startup="App_Startup"
             Exit="App_Exit"
             SessionEnding="App_SessionEnding">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Themes/Light.xaml" />
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>

There is no StartupUri: the first window is created in code so a view model can be passed in.

using System.Windows;
using Bookshelf.Core;
using Bookshelf.Data;
using Bookshelf.Desktop.ViewModels;
using Microsoft.Extensions.DependencyInjection;

namespace Bookshelf.Desktop;

public partial class App : Application
{
    private ServiceProvider? _services;
    private MainWindow? _mainWindow;

    private void App_Startup(object sender, StartupEventArgs e)
    {
        // Minimal manual wiring; a later page replaces it with the Generic Host.
        _services = new ServiceCollection().AddBookshelfData().BuildServiceProvider();
        var repository = _services.GetRequiredService<IBookRepository>();
        IDialogService dialogs = new WpfDialogService(_services);

        _mainWindow = new MainWindow { DataContext = new BooksViewModel(repository, dialogs) };
        MainWindow = _mainWindow;
        _mainWindow.Show();
    }

    private void App_SessionEnding(object sender, SessionEndingCancelEventArgs e)
    {
        // Logoff or Windows shutdown: keep this fast and do not block the user.
        // Bookshelf writes every edit through IBookRepository as it happens, so nothing is pending;
        // an app with unsaved work would flush it here, or set e.Cancel = true to ask first.
    }

    private void App_Exit(object sender, ExitEventArgs e)
    {
        // Final cleanup; the exit code can still be set here.
        _services?.Dispose();
        e.ApplicationExitCode = 0;
    }
}

Microsoft Learn documents the Application members and the application lifetime, and the API reference for ShutdownMode lists the three values. It does not show a composition-root pattern for passing a view model into the first window; the code above is a user pattern.

Unhandled exceptions: the three hooks

A .NET application can fail on a UI thread, on a worker thread, or inside a task that nobody awaited. Each failure reaches a different event, and WPF code usually registers all three. Together they are the last line of defence: they exist to log and to shut down cleanly, not to hide bugs.

Hook Covers Can the app continue? Notes

Application.DispatcherUnhandledException

An exception thrown on the UI thread (Application.Dispatcher) that nothing caught: event handlers, bindings that throw, layout, Dispatcher.Invoke callbacks, and the continuation of an async void handler.

Yes, if the handler sets DispatcherUnhandledExceptionEventArgs.Handled to true.

The event argument carries Exception, Handled and the Dispatcher. It fires only for that application’s dispatcher. A second UI thread with its own Dispatcher needs Dispatcher.UnhandledException. Exceptions thrown before Run (for example in the App constructor) never reach it.

AppDomain.CurrentDomain.UnhandledException

An exception on any thread that nothing handled, including worker threads and the finalizer thread.

No. The event is a notification: UnhandledExceptionEventArgs.IsTerminating is true and the runtime ends the process after the handlers return.

Use it to write a crash log and flush it. You cannot mark the exception handled. It also fires for UI-thread exceptions that were not handled by the Dispatcher event.

TaskScheduler.UnobservedTaskException

A faulted Task whose exception was never observed (never awaited, no Wait, no Exception access) when the task is finalized.

Yes. Call UnobservedTaskExceptionEventArgs.SetObserved(). Since .NET Framework 4.5, and on modern .NET, an unobserved task exception does not end the process by default.

It runs on the finalizer thread, at a garbage collection that you do not control, long after the fault. Do not touch UI from it. It is useful as a diagnostic that some Task is fire-and-forget without a try / catch.

Which hook sees which failure:

Where the exception is thrown Dispatcher event AppDomain event Unobserved task event

A button click handler (UI thread)

yes

only if unhandled

no

The continuation of an async void handler (UI thread)

yes

only if unhandled

no

A Task.Run delegate whose task you do not await

no

no

yes, at finalization

A new Thread worker with no try / catch

no

yes, and the process ends

no

The App constructor or a custom Main

no

yes, and the process ends

no

The pattern below installs all three in the App constructor so that errors during startup are also covered. It is a second partial-class file, so the lifecycle code above stays readable. IDialogService is the running-scenario dialog abstraction; the file logger is a deliberately small stand-in for the logging framework configured on the Dependency Injection & the Generic Host page.

using System.IO;
using System.Windows;
using System.Windows.Threading;

namespace Bookshelf.Desktop;

public partial class App
{
    private static readonly string LogPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "Bookshelf",
        "crash.log");

    public App()
    {
        // Register in the constructor so that startup failures are logged too.
        DispatcherUnhandledException += OnDispatcherUnhandledException;
        AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException;
        TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
    }

    private void OnDispatcherUnhandledException(
        object sender, DispatcherUnhandledExceptionEventArgs e)
    {
        Log("Dispatcher", e.Exception);

        // Only keep running for faults you know leave state consistent.
        if (e.Exception is OperationCanceledException)
        {
            e.Handled = true;
            return;
        }

        // Tell the user, then let the exception end the app (Handled stays false).
        MessageBox.Show(
            "Bookshelf hit an unexpected error and must close. Details were saved to the log.",
            "Bookshelf",
            MessageBoxButton.OK,
            MessageBoxImage.Error);
    }

    private static void OnDomainUnhandledException(
        object sender, UnhandledExceptionEventArgs e)
    {
        // The process is going down (IsTerminating). Log synchronously and return quickly.
        Log($"AppDomain (terminating: {e.IsTerminating})", e.ExceptionObject as Exception);
    }

    private static void OnUnobservedTaskException(
        object? sender, UnobservedTaskExceptionEventArgs e)
    {
        // Runs on the finalizer thread at an arbitrary time: log only, never touch UI.
        Log("Task", e.Exception);
        e.SetObserved();
    }

    private static void Log(string source, Exception? exception)
    {
        try
        {
            Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
            File.AppendAllText(
                LogPath,
                $"{DateTimeOffset.Now:O} [{source}] {exception}{Environment.NewLine}");
        }
        catch (IOException)
        {
            // Logging must never throw from an unhandled-exception handler.
        }
    }
}

The sample uses MessageBox for brevity inside the one place where IDialogService itself may be the failing component; a normal feature reports errors through the dialog service and the view model.

Pitfalls

  • Handled = true is a decision, not a default. After an unknown exception the object graph can be half-updated. Continuing hides bugs and can corrupt data. Handle only the exceptions you understand, and exit for the rest.

  • Log the whole exception. An exception can resurface far from its origin through Dispatcher.BeginInvoke or a Task continuation. Log Exception.ToString(), not only the message, to keep the stack trace.

  • Worker thread exceptions bypass the Dispatcher event. Wrap Thread bodies and Task.Run delegates in try / catch, or await the task on the UI thread so the exception returns to the UI context.

  • Fire-and-forget tasks surface late. An UnobservedTaskException log line means a task was dropped without await; fix the call site instead of only handling the event.

  • The handlers run on the thread that failed (the Dispatcher event on the UI thread, the others on whatever thread faulted). Never show UI from the AppDomain or task event.

  • Crash dialogs and re-entrancy. If the error dialog itself throws, the Dispatcher event can fire again. Keep the handler small and guard it with a flag if it does anything non-trivial.

Microsoft Learn documents the individual events but has no single page that explains WPF exception handling as a strategy. The table and rules above combine the API reference pages for these events with common practice.

Single-instance applications

WPF has no switch that restricts an app to one process: opening the app twice starts two processes. The usual goals are to block the second instance, and to hand its command-line arguments (for example "open this file") to the first. Microsoft Learn does not document a single-instance recipe for WPF on modern .NET. The patterns below are user patterns built from documented primitives (Mutex and named pipes), and they are the ones most used in practice.

Pattern How it works Strengths Weaknesses

Named Mutex only

The first process creates a named mutex and owns it. A second process finds that it already exists and exits.

Smallest code. Works across user sessions with a Global prefix, or per user session with a Local prefix.

The second instance cannot pass arguments, nor activate the first window.

Named Mutex plus named pipe

The mutex decides who is first. The first instance listens on a named pipe; the second sends its arguments to the pipe, then exits.

Bring the first window forward and open the file the user double-clicked.

More code; the pipe needs access control and input validation.

Process search (Process.GetProcessesByName)

Compare the process list with the current process.

No named object.

Race conditions when two copies start together; renamed executables defeat it. Avoid it.

Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase

The Visual Basic runtime’s single-instance feature (IsSingleInstance).

Built in.

Needs an assembly reference to Visual Basic support, and a hand-written Main. It is rarely used for WPF today.

Windows App SDK AppInstance

AppInstance.FindOrRegisterForKey and RedirectActivationToAsync (see the comparison table below).

Official API.

WinUI 3 and packaged apps; not part of WPF.

Mutex plus named pipe

The guard below owns the mutex for the lifetime of the first instance and exposes a pipe listener for later instances. The Local prefix scopes the mutex to the current logon session, so two users on the same machine can each run their own copy. PipeOptions.CurrentUserOnly restricts the pipe to the same user account. The mutex name and pipe name are placeholders: choose names that include your product and publisher, because any process can try to open a well-known name.

using System.IO;
using System.IO.Pipes;

namespace Bookshelf.Desktop;

public sealed class SingleInstanceGuard : IDisposable
{
    private const string MutexName = @"Local\Bookshelf.Desktop.SingleInstance";
    private const string PipeName = "Bookshelf.Desktop.SingleInstance.Pipe";

    private readonly Mutex _mutex;
    private readonly CancellationTokenSource _cancellation = new();

    private SingleInstanceGuard(Mutex mutex) => _mutex = mutex;

    // Returns null when another instance already owns the mutex.
    public static SingleInstanceGuard? TryAcquire()
    {
        var mutex = new Mutex(initiallyOwned: true, MutexName, out var createdNew);
        if (createdNew)
        {
            return new SingleInstanceGuard(mutex);
        }

        mutex.Dispose();
        return null;
    }

    // Synchronous on purpose: called from App startup before any window exists,
    // where blocking on an async method could deadlock the dispatcher.
    public static bool SignalFirstInstance(string[] arguments)
    {
        try
        {
            using var client = new NamedPipeClientStream(
                ".", PipeName, PipeDirection.Out, PipeOptions.CurrentUserOnly);
            client.Connect(2000);
            using var writer = new StreamWriter(client);
            writer.Write(string.Join('\n', arguments));
            writer.Flush();
            return true;
        }
        catch (TimeoutException)
        {
            return false;
        }
        catch (IOException)
        {
            return false;
        }
    }

    // Start from the UI thread so the callback runs on the UI synchronization context.
    public async Task ListenAsync(Action<string[]> onActivated)
    {
        var token = _cancellation.Token;
        while (!token.IsCancellationRequested)
        {
            try
            {
                await using var server = new NamedPipeServerStream(
                    PipeName,
                    PipeDirection.In,
                    maxNumberOfServerInstances: 1,
                    PipeTransmissionMode.Byte,
                    PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                await server.WaitForConnectionAsync(token);
                using var reader = new StreamReader(server);
                var message = await reader.ReadToEndAsync(token);
                onActivated(message.Split('\n', StringSplitOptions.RemoveEmptyEntries));
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (IOException)
            {
                // The client disconnected early; wait for the next one.
            }
        }
    }

    public void Dispose()
    {
        _cancellation.Cancel();
        _mutex.ReleaseMutex(); // must run on the thread that owns the mutex: the UI thread
        _mutex.Dispose();
        _cancellation.Dispose();
    }
}

The app wires the guard into startup. The first instance keeps the guard in a field (a mutex that is garbage collected is released and a second instance would then start), listens for later instances, and disposes the guard in Exit. The second instance forwards its arguments and shuts down without creating any window.

using System.Windows;
using Bookshelf.Core;
using Bookshelf.Data;
using Bookshelf.Desktop.ViewModels;
using Microsoft.Extensions.DependencyInjection;

namespace Bookshelf.Desktop;

public partial class App : Application
{
    private SingleInstanceGuard? _guard;
    private ServiceProvider? _services;
    private MainWindow? _mainWindow;

    private void App_Startup(object sender, StartupEventArgs e)
    {
        _guard = SingleInstanceGuard.TryAcquire();
        if (_guard is null)
        {
            SingleInstanceGuard.SignalFirstInstance(e.Args);
            Shutdown(0);
            return;
        }

        _services = new ServiceCollection().AddBookshelfData().BuildServiceProvider();
        var repository = _services.GetRequiredService<IBookRepository>();
        _mainWindow = new MainWindow
        {
            DataContext = new BooksViewModel(repository, new WpfDialogService(_services))
        };
        MainWindow = _mainWindow;
        _mainWindow.Show();

        // Fire and forget on purpose: the loop ends when the guard is disposed.
        _ = _guard.ListenAsync(BringToFront);
    }

    private void BringToFront(string[] arguments)
    {
        if (_mainWindow is null)
        {
            return;
        }

        if (_mainWindow.WindowState == WindowState.Minimized)
        {
            _mainWindow.WindowState = WindowState.Normal;
        }

        _mainWindow.Activate();

        // Treat arguments as untrusted input: validate before opening anything.
    }

    private void App_Exit(object sender, ExitEventArgs e)
    {
        _guard?.Dispose();
        _services?.Dispose();
    }
}

The mutex part derives from the Mutex API reference. Neither page is a WPF single-instance guide; the combination is a pattern, not an official recipe.

Single-instance pitfalls

  • Keep the mutex referenced. A local using var that goes out of scope, or a GC-collected field, releases the name and lets a second instance start.

  • Local versus Global prefix. Local isolates logon sessions (including remote desktop and fast user switching). Global blocks a second copy in any session and normally needs permissions for the first creator.

  • Release on the owning thread. ReleaseMutex throws if called from a different thread than the one that owns the mutex. Create and dispose the guard on the UI thread, as the example does. Abandoned mutexes (a crashed first instance) are released by Windows when the process ends.

  • Foreground rules. Window.Activate may only flash the taskbar button when the calling process does not own the foreground. Windows restricts which process may take focus; the second instance is the one that has the user’s last input, so it is the better place to grant it (AllowSetForegroundWindow through P/Invoke is the documented route, see the Interop: Win32, WinForms & Web Content page).

  • Do not trust pipe input. A named pipe is reachable by other processes. CurrentUserOnly limits it to the same account; still validate paths and commands from arguments exactly as for command-line input.

  • Unpackaged versus packaged. Packaged (MSIX) apps can declare a single-instance behavior through the Windows App SDK instancing API; the mutex pattern works in both.

  • Installers. An installer or updater that needs to know whether the app is running can open the same named mutex to detect a running copy.

WPF versus WinUI 3 versus WinForms

Concern WPF WinUI 3 (Windows App SDK) Windows Forms

Entry type

System.Windows.Application, App.xaml, Run.

Microsoft.UI.Xaml.Application; the OnLaunched override creates the Window.

Application.Run(Form) in Program.cs; ApplicationConfiguration.Initialize() in the .NET 6 and later template.

Root of the element hierarchy

DispatcherObject, then DependencyObject, Visual, UIElement, FrameworkElement, Control.

DependencyObject, UIElement, FrameworkElement, Control in Microsoft.UI.Xaml; there is no managed Visual layer, and Window is not a UIElement.

Component, then Control: one native window handle per control, no dependency properties.

Threading primitive

Dispatcher (Invoke, InvokeAsync).

DispatcherQueue (TryEnqueue).

Control.Invoke / BeginInvoke and SynchronizationContext.

Rendering model

Retained, vector, composed through the native milcore engine; one HWND per window.

Retained composition through the Microsoft.UI.Composition layer; one HWND per window.

Immediate GDI / GDI+ painting per control, each a window handle.

Shutdown control

ShutdownMode and Shutdown; Exit, SessionEnding.

Closing the last window ends the process; Application.Exit exists, plus the app lifecycle APIs.

Application.ExitThread, Application.Exit, ApplicationExit event, FormClosing.

Unhandled UI exceptions

DispatcherUnhandledException.

Application.UnhandledException (marked handled with e.Handled).

Application.ThreadException, configured with Application.SetUnhandledExceptionMode.

Single instance

User pattern (mutex, pipe).

AppInstance.FindOrRegisterForKey and RedirectActivationToAsync in the Windows App SDK.

User pattern, or WindowsFormsApplicationBase.

Logical and visual trees

Both, with LogicalTreeHelper and VisualTreeHelper.

A single XAML visual tree, walked with VisualTreeHelper; no separate logical tree helper.

Parent / child Controls collection only.

The comparison is meant as orientation. The Windows App Platforms: WPF, WinUI 3, WinForms, MAUI & More page compares the stacks as products, and XAML Dialects: WPF, WinUI & MAUI compares their markup. The WinUI 3 and WinForms architecture pages come later in this section.

What Microsoft Learn does not provide

  • There is no modern-.NET rewrite of the WPF architecture or Trees in WPF pages: the text is the .NET Framework text, and it does not describe the native library layout of .NET (the milcore name survives in prose only).

  • There is no recommended single-instance approach for WPF anywhere in the official documentation. The Mutex and named pipe sample is assembled from the Mutex reference and the named pipe how-to.

  • There is no single page on unhandled exception strategy for WPF. The three events are documented individually in the API reference.

  • The Learn pages explain what the visual tree contains but not the exact template parts per theme, and they say nothing about the stability of those parts across Aero2 and Fluent; treat the template structure as an implementation detail.

  • The WPF documentation has no composition-root guidance (how to pass dependencies into the first window); the Dependency Injection & the Generic Host page uses the Generic Host how-to and says so.

Pitfalls

Pitfall What to do

Reading the visual tree before the template is applied.

VisualTreeHelper.GetChildrenCount is 0 on a control that has not been measured. Wait for Loaded, or call ApplyTemplate.

Walking the visual tree to find a control (GetChild(0) chains).

Bind, use named template parts with GetTemplateChild, or expose the data on the view model. Template structure changes with the theme.

Touching an element from a worker thread.

InvalidOperationException: "The calling thread cannot access this object because a different thread owns it". Marshal through the Dispatcher or await on the UI thread.

A hidden window keeping the app alive, or the wrong MainWindow.

Choose the ShutdownMode deliberately; create the real main window first or assign MainWindow explicitly.

Creating Application in unit tests.

Only one Application per AppDomain / process may exist, and Application.Current is null without one. Keep view models free of Application.Current.

Application.Current.Properties as a service locator.

It hides dependencies and is not type-safe. Use constructor injection.

Doing slow work in Startup or OnExit.

Startup delays the first frame; OnExit delays logoff. Move slow work off the critical path.

StartupUri together with a single-instance check.

StartupUri creates the window after Startup; a second instance that calls Shutdown in Startup should not have StartupUri set, or clear it.