Navigation, Dialogs & Services in MVVM

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.

A view model must be able to say "show the book editor", "ask the user to confirm" or "go back" without knowing what a Window, a MessageBox or a Frame is. If it does know, it can no longer be unit tested, and it cannot be reused on another UI stack. The usual answer is a small service interface that the view model depends on, with the UI-specific implementation registered in the dependency injection container. This page shows the services that Bookshelf Desktop needs: INavigationService, IDialogService and IFileDialogService. 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; none of the APIs used here is listed as changed for it.

These services are user patterns. There is no official navigation or dialog API for MVVM in WPF, WinUI 3 or CommunityToolkit.Mvvm. Microsoft documents the building blocks (Window.ShowDialog, MessageBox, Frame, NavigationService, ContentDialog, NavigationView) and, in the .NET MAUI enterprise book, the idea of a view-model-first navigation service. The interfaces below are therefore a design, not an API you can look up, and the toolkit has no ViewModelLocator or DialogService either (those belonged to MvvmLight). The Windows, Dialogs & Navigation page introduced a synchronous, older form of IDialogService and a Frame-first INavigationService; the ViewModel-first, asynchronous form on this page refines it, and Bookshelf Desktop uses these shapes from here on. The MVVM Pattern and MVVM Toolkit pages explain the pattern and the library; Dependency Injection & the Generic Host shows the registration; Messaging Between View Models covers IMessenger in depth; Testing View Models & UI uses the fakes shown here.

Why services, and what Microsoft says

The MAUI enterprise book lists the problems any MVVM navigation design has to solve. They apply unchanged to WPF and WinUI 3.

Problem named in the book How this page answers it

Identify the target view without coupling views to each other

The view model names the target by view-model type, NavigateTo<BookEditorViewModel>(), never by view type.

Instantiate and initialize view and view model together (with a DI container)

The service resolves the view model from the container; a DataTemplate (or a registry) supplies the view.

View-first or view-model-first navigation

This page is view-model-first. The view-first Frame form is the older variant in Windows, Dialogs & Navigation.

Navigation initiated from the view model, not only from the view

The service is injected into view models; commands call it.

Pass parameters to the destination

NavigateTo<TViewModel>(object parameter) plus an optional INavigationAware callback.

Confirm or cancel navigation (unsaved changes)

The view model awaits IDialogService.ConfirmAsync before it calls the service.

The book’s own INavigationService is route-based (NavigateToAsync(string route, IDictionary<string, object>), PopAsync, InitializeAsync) and is tied to .NET MAUI Shell; it says explicitly that it is simplistic and that other navigation mechanisms need their own implementation. It is a design reference here, not code to copy.

The Bookshelf Desktop services at a glance

Service Contract (Bookshelf.Core, except IFileDialogService in Bookshelf.Desktop) Implemented by

INavigationService

bool CanGoBack, void NavigateTo<TViewModel>(), void NavigateTo<TViewModel>(object parameter), void GoBack()

ViewModelNavigationService (ContentControl), FrameViewModelNavigationService (Frame variant)

IDialogService

Task<bool> ConfirmAsync(string title, string message), Task<Book?> EditBookAsync(Book? existing) (null on cancel)

WpfDialogService (WPF), a ContentDialog version on WinUI 3

IFileDialogService

PickFileToImportAsync, PickFileToSaveAsync, PickFolderAsync (null on cancel)

WpfFileDialogService

All three are asynchronous or return plain values, never UI types. Each returns the user’s decision (a bool, a Book?, a path string), so a fake in a test can answer instantly.

ViewModel-first navigation with a ContentControl

The most common WPF form is a single shell window whose content area is a ContentControl bound to the current view model. WPF picks the view with an implicit DataTemplate: a template with a DataType and no x:Key is applied automatically to any object of that type shown by a content presenter. This is documented WPF behaviour (see the data-templating overview); the navigation service on top of it is the user pattern.

sequenceDiagram actor U as User participant V as BooksView participant B as BooksViewModel participant N as ViewModelNavigationService participant C as DI container participant S as Shell ContentControl U->>V: clicks Edit V->>B: EditSelectedCommand B->>N: NavigateTo of BookEditorViewModel with the Book N->>C: GetRequiredService of BookEditorViewModel C-->>N: new BookEditorViewModel N->>N: push current view model on the back stack N->>N: Current = new view model, call OnNavigatedTo N-->>S: PropertyChanged Current S->>S: implicit DataTemplate selects BookEditorView U->>S: clicks Back S->>N: GoBack through ShellViewModel N->>N: pop the previous BooksViewModel N-->>S: PropertyChanged Current

The contract and an optional parameter hook

namespace Bookshelf.Core;

public interface INavigationService
{
    bool CanGoBack { get; }

    /// <summary>Shows the view for <typeparamref name="TViewModel"/>.</summary>
    void NavigateTo<TViewModel>() where TViewModel : class;

    /// <summary>Shows the view and hands <paramref name="parameter"/> to the new view model.</summary>
    void NavigateTo<TViewModel>(object parameter) where TViewModel : class;

    void GoBack();
}

/// <summary>Optional: implemented by view models that accept a navigation parameter.</summary>
public interface INavigationAware
{
    void OnNavigatedTo(object? parameter);
}

The service

using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Extensions.DependencyInjection;

namespace Bookshelf.Desktop;

public sealed partial class ViewModelNavigationService(IServiceProvider services)
    : ObservableObject, INavigationService
{
    private readonly Stack<object> _backStack = new();

    /// <summary>The view model shown by the shell; bound by the ContentControl.</summary>
    [ObservableProperty]
    public partial object? Current { get; set; }

    public bool CanGoBack => _backStack.Count > 0;

    public void NavigateTo<TViewModel>() where TViewModel : class => Show<TViewModel>(null);

    public void NavigateTo<TViewModel>(object parameter) where TViewModel : class => Show<TViewModel>(parameter);

    public void GoBack()
    {
        if (_backStack.Count == 0)
        {
            return;
        }

        // The previous instance is restored, so scroll position, selection and edits survive.
        Current = _backStack.Pop();
        OnPropertyChanged(nameof(CanGoBack));
    }

    private void Show<TViewModel>(object? parameter) where TViewModel : class
    {
        var next = services.GetRequiredService<TViewModel>();
        if (next is INavigationAware aware)
        {
            aware.OnNavigatedTo(parameter);
        }

        if (Current is not null)
        {
            _backStack.Push(Current);
        }

        Current = next;
        OnPropertyChanged(nameof(CanGoBack));
    }
}
using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace Bookshelf.Desktop.ViewModels;

public sealed partial class ShellViewModel : ObservableObject
{
    private readonly INavigationService _navigation;

    public ShellViewModel(ViewModelNavigationService navigation)
    {
        Navigation = navigation;
        _navigation = navigation;
        navigation.PropertyChanged += (_, e) =>
        {
            if (e.PropertyName == nameof(INavigationService.CanGoBack))
            {
                GoBackCommand.NotifyCanExecuteChanged();
            }
        };
        _navigation.NavigateTo<BooksViewModel>();
    }

    /// <summary>Exposed so XAML can bind Navigation.Current.</summary>
    public ViewModelNavigationService Navigation { get; }

    private bool CanGoBack => _navigation.CanGoBack;

    [RelayCommand(CanExecute = nameof(CanGoBack))]
    private void GoBack() => _navigation.GoBack();
}
<Window x:Class="Bookshelf.Desktop.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="clr-namespace:Bookshelf.Desktop.ViewModels"
        xmlns:views="clr-namespace:Bookshelf.Desktop.Views"
        Title="Bookshelf Desktop" Width="900" Height="600">
    <Window.Resources>
        <!-- No x:Key: these are implicit templates, matched by DataType. -->
        <DataTemplate DataType="{x:Type vm:BooksViewModel}">
            <views:BooksView />
        </DataTemplate>
        <DataTemplate DataType="{x:Type vm:BookEditorViewModel}">
            <views:BookEditorView />
        </DataTemplate>
    </Window.Resources>
    <DockPanel>
        <ToolBar DockPanel.Dock="Top">
            <Button Content="Back" Command="{Binding GoBackCommand}" />
        </ToolBar>
        <ContentControl Content="{Binding Navigation.Current}" />
    </DockPanel>
</Window>

Registration in the Generic Host (the host itself is covered in Dependency Injection & the Generic Host):

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

namespace Bookshelf.Desktop;

public static class NavigationRegistration
{
    public static IServiceCollection AddBookshelfNavigation(this IServiceCollection services)
    {
        // One instance, visible both as the concrete type (for the shell) and as the interface.
        services.AddSingleton<ViewModelNavigationService>();
        services.AddSingleton<INavigationService>(sp => sp.GetRequiredService<ViewModelNavigationService>());

        // View models are transient: every NavigateTo gets a fresh instance.
        services.AddTransient<BooksViewModel>();
        services.AddTransient<BookEditorViewModel>();
        services.AddSingleton<ShellViewModel>();
        return services;
    }
}

This is a user pattern; WPF supplies only the implicit DataTemplate resolution and the data binding. The BookEditorViewModel is built from a Book, so it cannot be created by the container alone: in practice it implements INavigationAware and is told the Book by OnNavigatedTo, or the application uses the dialog route shown below instead of navigation for editing. Treat the registration above as a sketch of the shape, not as a finished editor flow.

A Frame-based variant

When the shell must use Frame (for the WPF journal, page transitions, or because the pages already exist), the same ViewModel-first contract can sit on top of it. The service keeps a map from view-model type to page type, creates the page, assigns its DataContext, and lets the Frame own the back stack. The Frame-first variant, NavigateTo<TPage>() where TPage : Page, is in Windows, Dialogs & Navigation.

using System.Windows.Controls;
using Bookshelf.Core;
using Microsoft.Extensions.DependencyInjection;

namespace Bookshelf.Desktop;

public sealed class FrameViewModelNavigationService(
    IServiceProvider services,
    IReadOnlyDictionary<Type, Type> pageForViewModel) : INavigationService
{
    private Frame? _frame;

    /// <summary>Called once by the shell, from its Loaded handler.</summary>
    public void Attach(Frame frame) => _frame = frame;

    public bool CanGoBack => _frame?.CanGoBack ?? false;

    public void NavigateTo<TViewModel>() where TViewModel : class => Show(typeof(TViewModel), null);

    public void NavigateTo<TViewModel>(object parameter) where TViewModel : class => Show(typeof(TViewModel), parameter);

    public void GoBack()
    {
        if (_frame is { CanGoBack: true })
        {
            _frame.GoBack();
        }
    }

    private void Show(Type viewModelType, object? parameter)
    {
        var viewModel = services.GetRequiredService(viewModelType);
        if (viewModel is INavigationAware aware)
        {
            aware.OnNavigatedTo(parameter);
        }

        var page = (Page)services.GetRequiredService(pageForViewModel[viewModelType]);
        page.DataContext = viewModel;

        // Navigating to an object keeps that instance (and its DataContext) in the journal.
        _frame?.Navigate(page);
    }
}
ContentControl + implicit templates Frame + pages

Back stack

Your own (Stack<object>), small and testable

The WPF journal, shared with Navigating / Navigated events

Page lifetime

The view is recreated by the template; the view model instance is what you keep

Pages are kept alive when you navigate to an instance; see KeepAlive in the page-lifetime rules

Transitions, browser-style chrome

None built in

NavigationWindow / Frame provide the navigation UI

Cancelling navigation

Your service decides before it changes Current

Handle Frame.Navigating and set e.Cancel

Typical use

Line-of-business shells, master/detail workspaces

Wizards, settings areas, apps that need the journal

IDialogService with a WPF implementation

Dialogs are modal questions. Making them asynchronous (Task<bool>, Task<Book?>) lets the same interface work on WPF, where ShowDialog blocks the calling code, and on WinUI 3, where ContentDialog.ShowAsync genuinely awaits. In WPF the returned task is usually already completed; the shape is chosen for the more demanding stack.

namespace Bookshelf.Core;

public interface IDialogService
{
    /// <summary>Asks a yes/no question; true means the user confirmed.</summary>
    Task<bool> ConfirmAsync(string title, string message);

    /// <summary>Opens the book editor; returns the edited Book, or null when the user cancelled.</summary>
    Task<Book?> EditBookAsync(Book? existing);
}
using System.Windows;
using Bookshelf.Core;
using Bookshelf.Desktop.ViewModels;
using Microsoft.Extensions.DependencyInjection;

namespace Bookshelf.Desktop;

public sealed class WpfDialogService(IServiceProvider services) : IDialogService
{
    public Task<bool> ConfirmAsync(string title, string message)
    {
        var answer = MessageBox.Show(
            GetOwner(), message, title,
            MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
        return Task.FromResult(answer == MessageBoxResult.Yes);
    }

    public Task<Book?> EditBookAsync(Book? existing)
    {
        var editor = new BookEditorViewModel(existing ?? new Book(0, string.Empty, string.Empty));
        var window = ActivatorUtilities.CreateInstance<BookEditorWindow>(services, editor);
        window.Owner = GetOwner();

        // ShowDialog blocks here until the window closes.
        var saved = window.ShowDialog() == true;
        return Task.FromResult(saved ? editor.ToBook() : null);
    }

    // The active window is the natural owner; fall back to the main window.
    private static Window? GetOwner() =>
        Application.Current.Windows.OfType<Window>().FirstOrDefault(w => w.IsActive)
        ?? Application.Current.MainWindow;
}
using System.Windows;
using Bookshelf.Desktop.ViewModels;

namespace Bookshelf.Desktop;

public partial class BookEditorWindow : Window
{
    private readonly BookEditorViewModel _editor;

    public BookEditorWindow(BookEditorViewModel editor)
    {
        InitializeComponent();
        _editor = editor;
        DataContext = editor;
    }

    // The Save button has IsDefault="True"; Cancel has IsCancel="True" and closes the window by itself.
    private void OnSave(object sender, RoutedEventArgs e)
    {
        // Validate() is the editor's own method, shown below.
        if (_editor.Validate())
        {
            DialogResult = true;
        }
    }
}

The editor’s Validate() method, a member of the canonical BookEditorViewModel(Book book) shown on the MVVM Toolkit (CommunityToolkit.Mvvm) page:

// Member of Bookshelf.Desktop.ViewModels.BookEditorViewModel : ObservableValidator.
public bool Validate()
{
    ValidateAllProperties();
    return !HasErrors;
}
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace Bookshelf.Desktop.ViewModels;

// Members of BooksViewModel, added inside the class declaration that owns the primary constructor.
[RelayCommand]
private async Task EditSelectedAsync()
{
    if (SelectedBook is null)
    {
        return;
    }

    // The view model never sees a Window: it sends a Book and gets a Book back.
    var edited = await dialogs.EditBookAsync(SelectedBook.Model);
    if (edited is null)
    {
        return; // cancelled
    }

    await repository.UpdateAsync(edited, CancellationToken.None);
    await LoadAsync(CancellationToken.None);
}

[RelayCommand]
private async Task DeleteSelectedAsync()
{
    if (SelectedBook is not { } selected)
    {
        return;
    }

    if (!await dialogs.ConfirmAsync("Delete book", $"Delete \"{selected.Title}\"?"))
    {
        return;
    }

    await repository.DeleteAsync(selected.Id, CancellationToken.None);
    Books.Remove(selected);
}

BooksViewModel is the canonical BooksViewModel(IBookRepository repository, IDialogService dialogs); the two commands above belong inside that class (primary-constructor parameters are not visible from another partial part), and LoadAsync is its existing load method. BookEditorViewModel is the ObservableValidator described on the MVVM Toolkit (CommunityToolkit.Mvvm) page (validation itself is covered on the Validation page), built from a Book and producing one with ToBook(). Validate() is a method of this application’s own editor view model; it is not a toolkit member.

Unit-testing against the services

Because the contracts hold no UI types, a hand-written fake is enough, and no mocking library is required.

using Bookshelf.Core;

namespace Bookshelf.Desktop.Tests;

public sealed class FakeDialogService : IDialogService
{
    public bool ConfirmAnswer { get; set; } = true;
    public Book? EditResult { get; set; }
    public List<string> Questions { get; } = [];

    public Task<bool> ConfirmAsync(string title, string message)
    {
        Questions.Add(message);
        return Task.FromResult(ConfirmAnswer);
    }

    public Task<Book?> EditBookAsync(Book? existing) => Task.FromResult(EditResult);
}

IFileDialogService

File and folder pickers are the same problem: a UI object that blocks and needs an owner window. The contract returns paths only.

namespace Bookshelf.Desktop;

public interface IFileDialogService
{
    /// <summary>Asks for a CSV file to import; null when cancelled.</summary>
    Task<string?> PickFileToImportAsync();

    /// <summary>Asks where to export the catalogue; null when cancelled.</summary>
    Task<string?> PickFileToSaveAsync(string suggestedName);

    /// <summary>Asks for a backup folder; null when cancelled.</summary>
    Task<string?> PickFolderAsync();
}
using System.Windows;
using Microsoft.Win32;

namespace Bookshelf.Desktop;

public sealed class WpfFileDialogService : IFileDialogService
{
    public Task<string?> PickFileToImportAsync()
    {
        var dialog = new OpenFileDialog
        {
            Title = "Import books",
            Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
            CheckFileExists = true,
        };
        return Task.FromResult(dialog.ShowDialog(Owner()) == true ? dialog.FileName : null);
    }

    public Task<string?> PickFileToSaveAsync(string suggestedName)
    {
        var dialog = new SaveFileDialog
        {
            Title = "Export books",
            FileName = suggestedName,
            DefaultExt = ".csv",
            Filter = "CSV files (*.csv)|*.csv",
            OverwritePrompt = true,
        };
        return Task.FromResult(dialog.ShowDialog(Owner()) == true ? dialog.FileName : null);
    }

    public Task<string?> PickFolderAsync()
    {
        var dialog = new OpenFolderDialog { Title = "Backup folder" };
        return Task.FromResult(dialog.ShowDialog(Owner()) == true ? dialog.FolderName : null);
    }

    private static Window? Owner() => Application.Current.MainWindow;
}

OpenFolderDialog was added to WPF in .NET 8; on .NET Framework only OpenFileDialog and SaveFileDialog exist. The view model passes the returned path to ordinary file code; if it then reads the file, it does so off the UI thread, as described in Threading, Dispatcher & Async.

IMessenger or a service?

IMessenger (CommunityToolkit.Mvvm) is also used to make a view model request something from the UI, so the two tools are often confused. They answer different questions.

Service (IDialogService, INavigationService) IMessenger

Shape

Request/response: the caller waits for an answer

Publish/subscribe: the sender does not know who listens, or whether anyone does

Result

A typed return value (Task<bool>, Task<Book?>)

None, unless you use a request message such as RequestMessage<T>

Coupling

The consumer depends on one interface; easy to find and to fake

The sender and receiver share a message type; the wiring is implicit

Failure when nobody is there

A missing registration fails at startup or at first resolve

The message is silently dropped

Best for

"Ask the user", "go to", "pick a file"

"A book was saved, refresh the list", broadcasting state to unrelated view models

Source: Messenger

A good split for Bookshelf Desktop: EditBookAsync returns the edited book to the caller through the service, and the caller then sends a BookSavedMessage through the messenger so the other open view models can refresh. Using a message to ask a question (and waiting for a reply) is possible with request messages but turns a typed call into an indirect one; prefer a service for that. See Messaging Between View Models for the details.

The same ideas on WinUI 3

WinUI 3 has the same two halves: a shell that hosts content, and dialog/picker objects that need a UI context.

NavigationView plus Frame

NavigationView is a navigation chrome; it does not navigate by itself. The Learn page says that it only shows the item as selected and raises ItemInvoked (and SelectionChanged), and that you handle those events to navigate and update the header. It has a built-in back button that does not navigate on its own either: you handle BackRequested, and you control the button with IsBackButtonVisible and IsBackEnabled (which can be bound to Frame.CanGoBack). PaneDisplayMode defaults to Auto, which adapts between LeftMinimal, LeftCompact and Left as the window width changes.

<NavigationView x:Name="NavView"
                PaneDisplayMode="Auto"
                IsBackButtonVisible="Visible"
                IsBackEnabled="{x:Bind ContentFrame.CanGoBack, Mode=OneWay}"
                SelectionChanged="OnSelectionChanged"
                BackRequested="OnBackRequested">
    <NavigationView.MenuItems>
        <NavigationViewItem Content="Books" Tag="Bookshelf.WinUI.Views.BooksPage" />
        <NavigationViewItem Content="Statistics" Tag="Bookshelf.WinUI.Views.StatisticsPage" />
    </NavigationView.MenuItems>
    <Frame x:Name="ContentFrame" />
</NavigationView>
using Bookshelf.WinUI.Views;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Animation;

namespace Bookshelf.WinUI;

public sealed partial class ShellPage : Page
{
    public ShellPage() => InitializeComponent();

    private void OnSelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
    {
        if (args.IsSettingsSelected)
        {
            ContentFrame.Navigate(typeof(SettingsPage), null, new EntranceNavigationTransitionInfo());
        }
        else if (args.SelectedItemContainer?.Tag is string pageTypeName
                 && Type.GetType(pageTypeName) is { } pageType)
        {
            ContentFrame.Navigate(pageType, null, new EntranceNavigationTransitionInfo());
        }
    }

    private void OnBackRequested(NavigationView sender, NavigationViewBackRequestedEventArgs args)
    {
        if (ContentFrame.CanGoBack)
        {
            ContentFrame.GoBack();
        }
    }
}

Source: NavigationView

That is page-first navigation in code-behind, which is the documented sample. To make it ViewModel-first, give the shell one INavigationService implementation that wraps the same Frame, exactly like FrameViewModelNavigationService above, but calling Frame.Navigate(Type, object?) (WinUI’s Frame navigates to a page type, not to a page instance, so the page is created by the frame and the service sets its view model in Navigated, or the page resolves it from the container). The NavigationView item selection is then translated into NavigateTo<TViewModel>() calls by the shell view model, or left in the code-behind as the single place that touches page types.

ContentDialog as IDialogService

WinUI 3 has no MessageBox and no DialogResult; the dialog control is ContentDialog, awaited with ShowAsync(), which returns a ContentDialogResult (Primary, Secondary, or None for the close button). Two rules matter to a service: you must set XamlRoot, otherwise it throws at runtime, and only one ContentDialog can be open per window, so a second request throws until the first closes.

using Bookshelf.Core;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace Bookshelf.WinUI;

public sealed class WinUiDialogService : IDialogService
{
    private XamlRoot? _xamlRoot;

    /// <summary>Called by the shell page when it is loaded.</summary>
    public void Attach(XamlRoot xamlRoot) => _xamlRoot = xamlRoot;

    public async Task<bool> ConfirmAsync(string title, string message)
    {
        var dialog = new ContentDialog
        {
            XamlRoot = _xamlRoot,
            Title = title,
            Content = message,
            PrimaryButtonText = "Yes",
            CloseButtonText = "No",
            DefaultButton = ContentDialogButton.Close,
        };
        return await dialog.ShowAsync() == ContentDialogResult.Primary;
    }

    public Task<Book?> EditBookAsync(Book? existing)
    {
        // Same contract: show a ContentDialog subclass that hosts the editor and return Book or null.
        throw new NotImplementedException("Bookshelf editor ContentDialog is not shown on this page.");
    }
}

WPF, WinUI 3 and Windows Forms compared

WPF WinUI 3 Windows Forms

Shell content host

ContentControl, Frame, NavigationWindow

Frame (usually inside NavigationView), ContentControl

A Panel that swaps UserControl instances, or MDI

Pick view by view model

Implicit DataTemplate with DataType

DataTemplateSelector or code-behind mapping; the implicit-by-type template resolution is a WPF feature

A dictionary of view model type to control type in your service

Back stack

Own stack, or the Frame journal

Frame.BackStack / CanGoBack, NavigationView back button

Own stack

Modal dialog

Window.ShowDialog(), blocking; MessageBox

ContentDialog.ShowAsync(), truly asynchronous; needs XamlRoot

Form.ShowDialog(); ShowDialogAsync (stable in .NET 10)

File pickers

Microsoft.Win32 dialogs, OpenFolderDialog on .NET 8+

Windows App SDK pickers; desktop apps must associate them with a window handle

OpenFileDialog, FolderBrowserDialog

Official MVVM navigation API

None

None (NavigationView + Frame is the documented chrome)

None

Choosing a form

Situation Recommendation

One shell, several top-level workspaces, line-of-business app

ContentControl + implicit DataTemplates + ViewModelNavigationService

Wizard or settings area, or you need the WPF journal

Frame + FrameViewModelNavigationService

WinUI 3 app with a side menu

NavigationView + Frame, with the service wrapping the frame

Edit form that returns a value

IDialogService (modal window or ContentDialog), not navigation

Notify unrelated view models that something changed

IMessenger

Pitfalls

  • The container cannot create everything. A view model that needs a Book in its constructor cannot be resolved by GetRequiredService. Use an initialization hook such as INavigationAware, a factory, or ActivatorUtilities.

  • Singleton versus transient view models. A transient view model gives a fresh screen on every NavigateTo; a singleton keeps its state but can hold stale data. Choose deliberately per view model.

  • The back stack holds references. Every view model on the stack stays alive, with its event subscriptions. Clear the stack when the user leaves a workflow, and unsubscribe or use WeakReferenceMessenger for message handlers.

  • ShowDialog pumps messages. Code after ShowDialog runs only when the dialog closes, but other windows continue to process messages meanwhile; guard against a command running twice by disabling it while the dialog is open.

  • Owner windows. A dialog without an Owner can appear behind the main window or in the task switcher. Use the active window, as in GetOwner().

  • UI thread only. Dialog services touch UI objects; call them from the UI thread. A RelayCommand runs there, but code after a ConfigureAwait(false) does not.

  • One ContentDialog per window on WinUI 3. Queue requests in the service, or a second ConfirmAsync throws.

  • Unsaved changes. Navigation guards are a convention: have the view model ask ConfirmAsync before it calls NavigateTo or GoBack; the service does not know about unsaved data.

  • Hidden coupling through code-behind. A view that calls NavigationService or MessageBox directly defeats the pattern and the tests.

  • MessageBox is not themed. The WPF MessageBox uses the system look; with Fluent theming, use your own dialog window if consistency matters.

What changed across .NET versions

Version Relevant change

.NET Framework 4.x

OpenFileDialog / SaveFileDialog, MessageBox, Window.ShowDialog, Frame and the journal. No OpenFolderDialog.

.NET Core 3.x to .NET 7

Same WPF APIs; MVVM services are always user code. WinUI 3 arrives with Windows App SDK.

.NET 8

OpenFolderDialog added to WPF.

.NET 9 and 10

WPF MessageBox gets additional buttons in .NET 10 (see Windows, Dialogs & Navigation); no navigation or dialog service API.

.NET 11 (RC2)

No change to these APIs is listed.

What Microsoft Learn does not provide

  • No official INavigationService, IDialogService or IFileDialogService for WPF, WinUI 3, Windows Forms or the toolkit. The only official design reference is the .NET MAUI book, whose service is tied to MAUI Shell.

  • No WPF-specific MVVM navigation article; the WPF navigation overview documents Frame, Page, NavigationService and the journal as browser-style navigation.

  • No guidance for testing navigation or dialog services; the unit-testing chapter of the MAUI book is general.

  • No documentation of a view-model-to-view mapping convention for WinUI 3.