What project structure fits a WPF app that calls a REST API with CommunityToolkit.Mvvm?
howto · dotnet · csharp · wpf · mvvm · rest
One project, folders by role - Models, ViewModels, Views, Services (the API client behind an interface) - wired up with the .NET Generic Host in `App.xaml.cs`: it builds the DI container, registers a typed `HttpClient` for the API and the windows/view models, and resolves `MainWindow` from it instead of `StartupUri`.
The folders and what goes in each
Section titled “The folders and what goes in each”- Models/ - plain classes: the API’s DTOs (shaped exactly like the JSON, deserialized straight into them) and, if the domain is richer than the wire format, separate domain models the DTOs map into. Small apps get away with one set doing both jobs; keep them separate once the API’s shape and the UI’s shape start to diverge.
- ViewModels/ - one per view, built on
ObservableObjectwith[ObservableProperty]fields and[RelayCommand]methods (dotnet/wpf-mvvm-frameworkcovers why CommunityToolkit.Mvvm specifically). A view model never touchesHttpClientdirectly; it takes a service interface through its constructor and awaits that. - Views/ - the XAML and its code-behind, code-behind limited to what only the view can do (focusing a control, starting an animation), never business logic or API calls.
- Services/ - the REST client, behind an interface (
IWidgetApiClient/WidgetApiClient) so a view model depends on the interface, notHttpClientor a concrete URL. This is also where a generated client (NSwag, Kiota, Refit) would live if the API has an OpenAPI document worth generating from, in its ownServices/Generated/folder marked as not hand-edited. - appsettings.json (and an
appsettings.Development.jsonoverlay) - the API’s base URL and any other environment-specific setting.Host.CreateApplicationBuilderloads it from the app’s content root, not automatically from wherever the app happens to run; the next section covers pinning that root and copying the file to the output directory so it is actually there to load. Never a hardcoded string in the service class.
Wiring it together with the Generic Host: the app’s composition root
Section titled “Wiring it together with the Generic Host: the app’s composition root”WPF has no built-in DI container, but the same Microsoft.Extensions.Hosting host ASP.NET Core uses
drops into App.xaml.cs: remove StartupUri from App.xaml, and build and start the host in the
Startup event instead. This is the one place in the app that calls builder.Services.Add...() and
the one place that calls GetRequiredService - the composition root every other entry in this
cluster that registers a service (an app-wide window service, covered below, a session service, a
validator) assumes exists here, next to MainWindow:
private async void Application_Startup(object sender, StartupEventArgs e){ var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { // Host.CreateApplicationBuilder() defaults ContentRootPath to the // current directory, which is whatever launched the process set it // to - the project folder from Visual Studio, but a shortcut's own // "Start in", or nothing at all, once it is not. Anchoring it to // where the executable actually is makes appsettings.json load the // same way regardless of how the app was started. ContentRootPath = AppContext.BaseDirectory, });
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client => client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!)) // A singleton view model that takes IWidgetApiClient in its // constructor keeps this typed client for the app's whole life // instead of the short-lived instance IHttpClientFactory assumes, // so its own handler - not the factory recycling a client it is // never asked for again - has to be the one that notices a DNS // change. .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), });
// App-wide singletons - the services every window and view model may // need, not only the one that opens first - are registered here, next // to MainWindow, whatever they are: IWindowService below, and the same // slot fits a session service or a settings service the same way. builder.Services.AddSingleton<IWindowService, WindowService>(); builder.Services.AddSingleton<MainViewModel>(); builder.Services.AddSingleton<MainWindow>();
_host = builder.Build(); await _host.StartAsync();
var window = _host.Services.GetRequiredService<MainWindow>(); window.Show();}compiled by the build, not run
Show the whole file
using System.Net.Http;using System.Threading;using System.Windows;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;
namespace WpfRestApiProjectStructure;
public partial class App : Application{ private IHost? _host;
private async void Application_Startup(object sender, StartupEventArgs e) { var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { // Host.CreateApplicationBuilder() defaults ContentRootPath to the // current directory, which is whatever launched the process set it // to - the project folder from Visual Studio, but a shortcut's own // "Start in", or nothing at all, once it is not. Anchoring it to // where the executable actually is makes appsettings.json load the // same way regardless of how the app was started. ContentRootPath = AppContext.BaseDirectory, });
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client => client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!)) // A singleton view model that takes IWidgetApiClient in its // constructor keeps this typed client for the app's whole life // instead of the short-lived instance IHttpClientFactory assumes, // so its own handler - not the factory recycling a client it is // never asked for again - has to be the one that notices a DNS // change. .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), });
// App-wide singletons - the services every window and view model may // need, not only the one that opens first - are registered here, next // to MainWindow, whatever they are: IWindowService below, and the same // slot fits a session service or a settings service the same way. builder.Services.AddSingleton<IWindowService, WindowService>(); builder.Services.AddSingleton<MainViewModel>(); builder.Services.AddSingleton<MainWindow>();
_host = builder.Build(); await _host.StartAsync();
var window = _host.Services.GetRequiredService<MainWindow>(); window.Show(); }
private void Application_Exit(object sender, ExitEventArgs e) { // Not async void: WPF raises Exit synchronously while shutting down, // and an await in here can hand control back to the caller (which // tears the process down) before the continuation that finishes // StopAsync ever runs. Blocking here, for the few hundred milliseconds // a typed client and a window service take to stop, is the trade that // keeps stopping guaranteed instead of merely likely; a host with a // slow IHostedService to drain is exactly what the timeout bounds. using var host = _host; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); host?.StopAsync(cts.Token).GetAwaiter().GetResult(); }}AddHttpClient<TClient, TImplementation> is the point that matters for a REST-backed app: it gives
WidgetApiClient a pooled, correctly-disposed HttpClient through IHttpClientFactory instead of
one new HttpClient() per call (which exhausts sockets). The window and its view model are
registered the same way as any other service and resolved once, from _host.Services, rather than
new’d in OnStartup - that is what lets MainWindow’s constructor ask for MainViewModel and get
it injected instead of setting DataContext by hand:
public MainWindow(MainViewModel viewModel){ InitializeComponent(); DataContext = viewModel;}compiled by the build, not run
Show the whole file
using System.Windows;
namespace WpfRestApiProjectStructure;
public partial class MainWindow : Window{ public MainWindow(MainViewModel viewModel) { InitializeComponent(); DataContext = viewModel; }}Loading appsettings.json from wherever the app was actually started
Section titled “Loading appsettings.json from wherever the app was actually started”Host.CreateApplicationBuilder() defaults ContentRootPath, where it looks for appsettings.json,
to Environment.CurrentDirectory - the process’s current working directory, which is whoever
launched it. Run from Visual Studio or a terminal in the project folder, that is the project folder,
so the file loads and the trap stays hidden; launched from a shortcut, a pinned taskbar icon or the
Start menu, the current directory is whatever the shell happened to set (since .NET 7, Host
defaults it to the app’s own folder only in the one case where that would otherwise be
%windir%\system32, not for a shortcut with some other “Start in” folder, or none) - appsettings.json
then is not found where the app looks, and builder.Configuration["ApiBaseUrl"] comes back null.
Setting ContentRootPath = AppContext.BaseDirectory explicitly, as the composition root above does,
anchors it to the folder the executable actually runs from regardless of how the process was started.
The file still has to be there to find: <None Update="appsettings.json"> with
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> in the .csproj copies it next to the
executable on every build, the same way any other project type copies a content file.
appsettings.Development.json layers on top of appsettings.json automatically, but only once the
app’s environment is Development - HostApplicationBuilder reads that from the DOTNET_ENVIRONMENT
environment variable and defaults to Production when it is unset, so the overlay is silently skipped
on a machine that never set it, not merged with a warning. Set DOTNET_ENVIRONMENT=Development in the
launch profile (Properties/launchSettings.json) for a debug run, and leave it unset wherever the app
is actually deployed.
Stopping the host without losing the shutdown
Section titled “Stopping the host without losing the shutdown”The Exit event is where the host stops, but the natural-looking async void Application_Exit
handler that awaits _host.StopAsync() is not reliable here: WPF raises Exit synchronously as
part of shutting down, and an async void method returns control to its caller at its first await
- the continuation that finishes stopping the host is then queued to resume later, but “later” can be
after the process has already gone, so an
IHostedService’sStopAsync(closing a log file, flushing a queued write) may never run to completion:
sequenceDiagram
participant WPF as WPF shutdown
participant Exit as Application_Exit
participant Host as Host.StopAsync
WPF->>Exit: raise Exit (async void)
Exit->>Host: call StopAsync, await
Exit-->>WPF: returns at the first await
WPF->>WPF: shutdown continues, process may end
Note over Host: queued continuation may never resumeStopping synchronously instead, bounded by a timeout so a slow or hung service cannot hang the app on the way out, keeps it guaranteed rather than merely likely:
private void Application_Exit(object sender, ExitEventArgs e){ // Not async void: WPF raises Exit synchronously while shutting down, // and an await in here can hand control back to the caller (which // tears the process down) before the continuation that finishes // StopAsync ever runs. Blocking here, for the few hundred milliseconds // a typed client and a window service take to stop, is the trade that // keeps stopping guaranteed instead of merely likely; a host with a // slow IHostedService to drain is exactly what the timeout bounds. using var host = _host; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); host?.StopAsync(cts.Token).GetAwaiter().GetResult();}compiled by the build, not run
Show the whole file
using System.Net.Http;using System.Threading;using System.Windows;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;
namespace WpfRestApiProjectStructure;
public partial class App : Application{ private IHost? _host;
private async void Application_Startup(object sender, StartupEventArgs e) { var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { // Host.CreateApplicationBuilder() defaults ContentRootPath to the // current directory, which is whatever launched the process set it // to - the project folder from Visual Studio, but a shortcut's own // "Start in", or nothing at all, once it is not. Anchoring it to // where the executable actually is makes appsettings.json load the // same way regardless of how the app was started. ContentRootPath = AppContext.BaseDirectory, });
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client => client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!)) // A singleton view model that takes IWidgetApiClient in its // constructor keeps this typed client for the app's whole life // instead of the short-lived instance IHttpClientFactory assumes, // so its own handler - not the factory recycling a client it is // never asked for again - has to be the one that notices a DNS // change. .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), });
// App-wide singletons - the services every window and view model may // need, not only the one that opens first - are registered here, next // to MainWindow, whatever they are: IWindowService below, and the same // slot fits a session service or a settings service the same way. builder.Services.AddSingleton<IWindowService, WindowService>(); builder.Services.AddSingleton<MainViewModel>(); builder.Services.AddSingleton<MainWindow>();
_host = builder.Build(); await _host.StartAsync();
var window = _host.Services.GetRequiredService<MainWindow>(); window.Show(); }
private void Application_Exit(object sender, ExitEventArgs e) { // Not async void: WPF raises Exit synchronously while shutting down, // and an await in here can hand control back to the caller (which // tears the process down) before the continuation that finishes // StopAsync ever runs. Blocking here, for the few hundred milliseconds // a typed client and a window service take to stop, is the trade that // keeps stopping guaranteed instead of merely likely; a host with a // slow IHostedService to drain is exactly what the timeout bounds. using var host = _host; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); host?.StopAsync(cts.Token).GetAwaiter().GetResult(); }}StopAsync(cts.Token).GetAwaiter().GetResult() blocks the UI thread for as long as stopping actually
takes, which is fine here - there is nothing left for that thread to do once Exit has fired. It does
not deadlock, either: the host’s own StopAsync awaits every IHostedService with
ConfigureAwait(false), so its chain never needs to resume back on the blocked UI thread - the one way
to reintroduce that risk is a hosted service whose own StopAsync awaits something without
ConfigureAwait(false) that needs the UI thread to resume, which is a reason to keep a hosted
service’s shutdown work off the UI thread’s own concerns, not a reason to avoid blocking here. The
other way to get the same guarantee is to stop relying
on the Exit event at all: give the app a hand-written Main (WPF’s default one is generated because
App.xaml’s build action is ApplicationDefinition; changing it to Page turns generation off) that
builds and starts the host, calls new App().Run(), and stops the host in an ordinary, non-event
await once Run() returns - worth the extra ceremony only
once shutdown needs to do more than a bounded StopAsync, a confirmation dialog with its own async
work, say.
App-wide services: the window service, and its kin
Section titled “App-wide services: the window service, and its kin”Everything a view model needs that is not itself is a service resolved through the same container,
registered once here and injected wherever it is needed - not only the API client. Opening a second
window from a view model without giving that view model a reference to WPF’s own Window type
needs one such service, kept small on purpose (one method per way of opening a window):
public interface IWindowService{ void Show<TViewModel>(TViewModel viewModel) where TViewModel : notnull; bool? ShowDialog<TViewModel>(TViewModel viewModel) where TViewModel : notnull;}compiled by the build, not run
Show the whole file
namespace WpfRestApiProjectStructure;
public interface IWindowService{ void Show<TViewModel>(TViewModel viewModel) where TViewModel : notnull; bool? ShowDialog<TViewModel>(TViewModel viewModel) where TViewModel : notnull;}public sealed class WindowService : IWindowService{ public void Show<TViewModel>(TViewModel viewModel) where TViewModel : notnull => CreateWindow(viewModel).Show();
public bool? ShowDialog<TViewModel>(TViewModel viewModel) where TViewModel : notnull => CreateWindow(viewModel).ShowDialog();
// One case per window that opens this way; mapping a view model type to // its window, and resolving one with its own dependencies instead of // constructing it by hand, is what grows here as the app gains windows. private static Window CreateWindow<TViewModel>(TViewModel viewModel) where TViewModel : notnull => throw new NotSupportedException($"No window is registered for {typeof(TViewModel)}.");}compiled by the build, not run
Show the whole file
using System.Windows;
namespace WpfRestApiProjectStructure;
public sealed class WindowService : IWindowService{ public void Show<TViewModel>(TViewModel viewModel) where TViewModel : notnull => CreateWindow(viewModel).Show();
public bool? ShowDialog<TViewModel>(TViewModel viewModel) where TViewModel : notnull => CreateWindow(viewModel).ShowDialog();
// One case per window that opens this way; mapping a view model type to // its window, and resolving one with its own dependencies instead of // constructing it by hand, is what grows here as the app gains windows. private static Window CreateWindow<TViewModel>(TViewModel viewModel) where TViewModel : notnull => throw new NotSupportedException($"No window is registered for {typeof(TViewModel)}.");}registered AddSingleton<IWindowService, WindowService>() next to MainWindow in the composition
root above. The same slot fits any other app-wide singleton a bigger app grows into - a service that
tracks who is signed in and that every view model reads, a settings service - registered the same way
and injected into whichever view model’s constructor needs it; none of them are special beyond
IWidgetApiClient, they just happen to have nothing to do with the REST API this entry is about. Once
there are more than a handful, group the calls into one IServiceCollection extension method per
feature rather than one long list in Application_Startup.
The interface and its implementation
Section titled “The interface and its implementation”IWidgetApiClient names the operations in domain terms - a view model asks for a Widget, not for a
HttpResponseMessage it has to parse itself:
public interface IWidgetApiClient{ Task<Widget> GetAsync(int id, CancellationToken ct = default); Task<Widget> CreateAsync(NewWidget widget, CancellationToken ct = default);}compiled by the build, not run
Show the whole file
using System.Threading;using System.Threading.Tasks;
namespace WpfRestApiProjectStructure;
public interface IWidgetApiClient{ Task<Widget> GetAsync(int id, CancellationToken ct = default); Task<Widget> CreateAsync(NewWidget widget, CancellationToken ct = default);}public sealed class WidgetApiClient(HttpClient http) : IWidgetApiClient{ public async Task<Widget> GetAsync(int id, CancellationToken ct = default) { using var response = await http.GetAsync($"widgets/{id}", ct); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync<Widget>(cancellationToken: ct))!; }
public async Task<Widget> CreateAsync(NewWidget widget, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync("widgets", widget, ct); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync<Widget>(cancellationToken: ct))!; }}compiled by the build, not run
Show the whole file
using System.Net.Http;using System.Net.Http.Json;using System.Threading;using System.Threading.Tasks;
namespace WpfRestApiProjectStructure;
public sealed class WidgetApiClient(HttpClient http) : IWidgetApiClient{ public async Task<Widget> GetAsync(int id, CancellationToken ct = default) { using var response = await http.GetAsync($"widgets/{id}", ct); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync<Widget>(cancellationToken: ct))!; }
public async Task<Widget> CreateAsync(NewWidget widget, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync("widgets", widget, ct); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync<Widget>(cancellationToken: ct))!; }}The constructor takes only the HttpClient - no base address, no default headers - because
AddHttpClient<IWidgetApiClient, WidgetApiClient> set those once, at registration, above; a
constructor that also accepted a base-URL string would invite a call site to new one up by hand and
land back in the trap this structure exists to avoid. ReadFromJsonAsync and PostAsJsonAsync (from
System.Net.Http.Json, part of the shared framework since .NET 5, no extra package to add) both go
through System.Text.Json under its default options, so Widget and NewWidget round-trip without a
hand-written (de)serializer as long as their property names match the wire format (or carry
[JsonPropertyName] where they don’t). EnsureSuccessStatusCode turns a non-2xx response into an
HttpRequestException (its StatusCode property has been set since .NET 5), which is enough for a
view model that only needs to show “something went wrong”; one that must branch on which error -
404 to a “not found” message, 409 to a merge-conflict prompt - reads response.StatusCode before
calling EnsureSuccessStatusCode, or catches the exception and inspects it there, and the client
throws a small set of app-specific exception types so the view model’s catch names a domain
condition instead of an HTTP status code.
Holding onto a typed client for longer than one call
Section titled “Holding onto a typed client for longer than one call”MainViewModel above is a singleton - it lives for the app’s whole run, not one per window opened -
and it takes IWidgetApiClient through its constructor, so the WidgetApiClient the container hands
it is held for just as long, not re-resolved per call the way AddHttpClient<TClient, TImplementation>
assumes a typed client is used. That is not the mistake it looks like: current guidance treats a
long-lived HttpClient as just as valid as one IHttpClientFactory hands out fresh per request,
provided its handler’s PooledConnectionLifetime is set - without it, a HttpClient that lives
for the app’s whole run keeps its first connection, and therefore its first DNS lookup, until the
process exits, which is the failure mode a static HttpClient field is usually blamed for. Setting
PooledConnectionLifetime on the handler AddHttpClient builds this client from, as the composition
root above does, fixes it at the source: the connection itself is recycled on that schedule and a
fresh DNS lookup happens with it, whether or not the app ever asks the factory for a new client
instance. A view model that instead re-resolved IWidgetApiClient per call from IHttpClientFactory
directly would get the same freshness from the factory’s own handler rotation (two minutes, by
default) without configuring anything - worth doing when a service is itself long-lived and calls the
API often enough that a PooledConnectionLifetime is one more number to tune, not when a view model’s
constructor is already the natural place to take the dependency it needs.
The CancellationToken a command generates, and what actually cancels it
Section titled “The CancellationToken a command generates, and what actually cancels it”Every method above ends in CancellationToken ct = default, and CommunityToolkit.Mvvm’s
[RelayCommand] recognizes a trailing CancellationToken on an async method: it generates an
IAsyncRelayCommand that creates and passes one automatically, without any cancellation plumbing
written by hand in the view model. What actually signals that token is easy to get wrong, though.
By default (AllowConcurrentExecutions = false, the default) the command’s CanExecute reports
false while a call is in flight - that is what a Button bound the ordinary way
(Command="{Binding LoadCommand}") reads through ICommand.CanExecuteChanged, so it disables
itself and a second click never reaches Execute at all. Calling Execute/ExecuteAsync directly
instead - bypassing that check, which nothing stops code from doing - is a different story: it
cancels whatever call is already in flight and starts a fresh one regardless of
AllowConcurrentExecutions, but that is not a path a bound Button ever takes. Either way, nothing
about a window closing signals the token by itself: a view model whose work should stop when its
window closes calls Cancel() from the window’s Closing handler, explicitly, and that or a
generated cancel command ([RelayCommand(IncludeCancelCommand = true)], bound to a separate,
always-enabled “Cancel” button) are the deliberate way to stop a pending call, rather than relying on
how a second invocation happens to behave:
[RelayCommand]private async Task DoWorkAsync(CancellationToken ct){ Invocations++; try { await Task.Delay(TimeSpan.FromSeconds(2), ct); } catch (OperationCanceledException) { WasCancelled = true; }}
[RelayCommand(IncludeCancelCommand = true)]private async Task DoCancellableWorkAsync(CancellationToken ct){ try { await Task.Delay(TimeSpan.FromSeconds(2), ct); } catch (OperationCanceledException) { CancellableWasCancelled = true; }}compiled and run by the build
Show the whole file
#:package CommunityToolkit.Mvvm@8.4.2#:package xunit.assert@2.9.3
// What [RelayCommand] actually does with a trailing CancellationToken. By// default (AllowConcurrentExecutions = false) CanExecute reports false while// a call is in flight, which is what a Button bound the normal way reads: it// disables itself, so a second click never reaches Execute at all. Calling// Execute (or ExecuteAsync) directly - bypassing that check, which nothing// stops code from doing - is a different story: it cancels whatever call is// in flight and starts a fresh one regardless of AllowConcurrentExecutions.// Neither path is triggered by a window closing; only an explicit Cancel(),// or a generated cancel command (IncludeCancelCommand = true), does that. A// failed Assert throws, and the run exits non-zero.using CommunityToolkit.Mvvm.ComponentModel;using CommunityToolkit.Mvvm.Input;using Xunit;
var worker = new Worker();var firstRun = worker.DoWorkCommand.ExecuteAsync(null);Assert.False(worker.DoWorkCommand.CanExecute(null)); // not executable while the call above is in flight
worker.DoWorkCommand.Execute(null); // bypasses the CanExecute check a bound Button would have honouredawait firstRun;Assert.True(worker.WasCancelled); // ...and cancels the first call's token as a side effect of starting a secondAssert.Equal(2, worker.Invocations);
var closing = new Worker();var pending = closing.DoWorkCommand.ExecuteAsync(null);closing.DoWorkCommand.Cancel(); // the deliberate way - what a window's Closing handler callsawait pending;Assert.True(closing.WasCancelled);
var cancellable = new Worker();var cancellableRun = cancellable.DoCancellableWorkCommand.ExecuteAsync(null);Assert.True(cancellable.DoCancellableWorkCancelCommand.CanExecute(null)); // enabled only while the linked call runs
cancellable.DoCancellableWorkCancelCommand.Execute(null);await cancellableRun;Assert.True(cancellable.CancellableWasCancelled);
Console.WriteLine("wpf-rest-api-project-structure: cancellation claims hold");
public sealed partial class Worker : ObservableObject{ public int Invocations { get; private set; } public bool WasCancelled { get; private set; } public bool CancellableWasCancelled { get; private set; }
[RelayCommand] private async Task DoWorkAsync(CancellationToken ct) { Invocations++; try { await Task.Delay(TimeSpan.FromSeconds(2), ct); } catch (OperationCanceledException) { WasCancelled = true; } }
[RelayCommand(IncludeCancelCommand = true)] private async Task DoCancellableWorkAsync(CancellationToken ct) { try { await Task.Delay(TimeSpan.FromSeconds(2), ct); } catch (OperationCanceledException) { CancellableWasCancelled = true; } }}That is still the WPF-specific payoff of taking the token at all: an explicit Cancel(), wherever it
is wired from, stops the pending call instead of leaving it running against a view model nothing
references anymore.
Variant: a generated client instead of a hand-written one
Section titled “Variant: a generated client instead of a hand-written one”When the API publishes an OpenAPI document, generating the implementation (Refit, NSwag, Kiota - see the Services/ bullet, above) trades the method bodies above for an interface decorated with HTTP attributes and no body at all:
public interface IWidgetApiClientGenerated{ [Get("/widgets/{id}")] Task<Widget> GetAsync(int id, CancellationToken ct = default);
[Post("/widgets")] Task<Widget> CreateAsync([Body] NewWidget widget, CancellationToken ct = default);}compiled by the build, not run
Show the whole file
using System.Threading;using System.Threading.Tasks;using Refit;
namespace WpfRestApiProjectStructure;
// The Refit variant from the "generated client" section: the same interface// a view model would depend on, but with no hand-written implementation -// Refit.HttpClientFactory generates one from the attributes at build time.// Named apart from IWidgetApiClient only so both can live in one example// project; a real app keeps whichever one it picked.public interface IWidgetApiClientGenerated{ [Get("/widgets/{id}")] Task<Widget> GetAsync(int id, CancellationToken ct = default);
[Post("/widgets")] Task<Widget> CreateAsync([Body] NewWidget widget, CancellationToken ct = default);}(services.AddRefitClient<IWidgetApiClient>(), from the Refit.HttpClientFactory package, wires this
into the same IHttpClientFactory-backed HttpClient that AddHttpClient<TClient, TImplementation>
sets up for a hand-written client, PooledConnectionLifetime included.) The interface a view model
depends on is unchanged either way - that is what makes swapping a hand-written client for a generated
one, or the reverse, a Services/ folder change nobody outside it needs to know happened.
The trap: a view model that new’s up its own HttpClient
Section titled “The trap: a view model that new’s up its own HttpClient”The failure this structure exists to avoid is a view model that does
new HttpClient().GetAsync(...) inline: it works in a demo and then fails under real use in two
separate ways - sockets exhausted from a fresh HttpClient per call, and no way to substitute a fake
implementation in a unit test, since the concrete class is compiled into the view model. Putting the
call behind IWidgetApiClient fixes both at once: the factory owns the HttpClient’s lifetime, and a
test constructs the view model with a mock of the interface instead of hitting the network.
Scaling up: when one project stops being enough
Section titled “Scaling up: when one project stops being enough”A single WPF project holding all of the folders above is the right start for anything one person can
hold in their head. Split into App.Core (Models, ViewModels, Services - a plain class library with no
WPF reference, since ICommand and INotifyPropertyChanged live in the base class libraries, not in
WPF itself) and App.Wpf (Views plus App.xaml.cs) once either becomes true: the view models need to
be unit-tested without loading the WPF runtime, or the same core is meant to back a second front end
(a WinUI or Avalonia head) later. App.Tests then references App.Core alone. Splitting earlier than
that just adds project-reference ceremony with nothing yet to show for it.
Splitting further for a team, or for a second UI later
Section titled “Splitting further for a team, or for a second UI later”A Views/ViewModels/Services split expressed as three separate projects instead of three folders
is a common team instinct - “everyone owns their layer” - but it is the wrong axis: a view model almost
always needs its model and its service in the same compile step, so a layer-per-project split adds
project-reference plumbing without decoupling anyone’s work. Split by what actually changes
independently instead, on top of the App.Core / App.Wpf split above:
App.Core.Contracts- carved out ofApp.Coreonce several people implement services against the same interfaces in parallel: it holds only the interfaces and the DTOs/models they pass, so one developer’sWidgetApiClientrewrite inApp.Coredoesn’t force everyone else’s branch to recompile against it. Skip this until a team is actually large enough to collide there - under perhaps five people it is a project reference for a problem nobody has yet.- A solution folder per project (
Core/,Wpf/,Tests/, and any further split) in Visual Studio’s Solution Explorer, mirroring the project split - so the boundary a code review enforces is the same one a developer already navigates by, instead of a second, informal grouping layered on top. - A second UI head for another platform - the same
App.Core/App.Wpfsplit is what makes this possible at all, not just testable:App.Core’s view models are plainObservableObjectclasses from CommunityToolkit.Mvvm, which has no WPF dependency and the same package for WPF, MAUI, WinUI and Avalonia (dotnet/wpf-mvvm-frameworkcovers why). AddApp.Maui(orApp.Avalonia) referencingApp.Coreexactly asApp.Wpfdoes, and it inherits every view model and everyServices/interface unchanged. The one piece that does not carry over automatically is a service that touches something platform-specific - a file picker, the clipboard, a tray icon: define its interface inApp.Core.Servicesand give each head its own implementation (WpfFilePicker,MauiFilePicker), registered in that head’s ownApplication_Startup/MauiProgramDI setup, so a view model still depends only onIFilePicker.
Once every piece above exists, the reference graph looks like this - two heads and a test project pointing at the shared core, the core pointing down into its own contracts project, and each head supplying its own implementation of a core-declared interface:
flowchart TD
Wpf["App.Wpf<br>Views, WpfFilePicker"] --> Core["App.Core<br>Services, IFilePicker"]
Maui["App.Maui<br>MauiFilePicker"] --> Core
Tests[App.Tests] --> Core
Core --> Contracts[App.Core.Contracts]Nothing in this graph points from App.Core back to App.Wpf or App.Maui, which is what makes the
core headless in the first place.
The check that the split actually holds: remove App.Wpf’s .csproj from the solution and build
App.Core and App.Tests alone. If that fails, something that belongs in App.Core - a using System.Windows that snuck into a “core” view model, a service call that assumes a Dispatcher -
is still coupled to WPF specifically, and splitting the UI project further or adding a second head
will surface that coupling as a build error instead of a runtime one.
Wait for the second head to be real, funded work before creating its project, not a line on a roadmap
slide: an empty App.Maui nobody builds yet is one more project to keep green in CI for no return, and
the interfaces it would need are easy to add in App.Core.Services later, when there is an actual
platform-specific implementation to write against them.
The .gitignore
Section titled “The .gitignore”Generate it rather than hand-typing one: dotnet new gitignore in the repository root (built into the
SDK since .NET Core 3.0) writes the same file GitHub’s own VisualStudio.gitignore template does - bin/,
obj/, .vs/, *.user, *.suo and the rest of what Visual Studio and the SDK scatter around a
solution. Add one line yourself for a appsettings.*.json overlay that carries a real secret (a
non-local API key, a connection string) rather than a placeholder; the template has no way to know
which overlay is which, and appsettings.Development.json with only a local API base URL in it is
fine to commit.
Verifying it
Section titled “Verifying it”A view model built this way is testable without a WPF Application running at all: construct it with
a mocked IWidgetApiClient, invoke the [RelayCommand] method, and assert on the [ObservableProperty]
it set - if that runs under a plain unit test project referencing only App.Core, the boundary between
UI and API access is where it should be. Running the app for real and killing the network mid-request
is the other check: a properly awaited call surfaces as a caught exception the view model turns into an
error message, not an unhandled exception dialog. To check that shutdown actually completes, put a
breakpoint (or a log line) in an IHostedService.StopAsync and close the app from the taskbar or
Alt+F4, not just Visual Studio’s stop button, which tears the process down without running Exit at
all - the breakpoint should still hit before the process ends.
When this does not apply
Section titled “When this does not apply”A one-window utility that calls one endpoint once has little to gain from the Generic Host, a
Services/ folder and a split into two projects - a single code-behind file with an inline
HttpClient is not a problem worth solving at that size (the same threshold dotnet/wpf-mvvm-framework
draws for skipping an MVVM library entirely). Reach for this structure once there is more than one view
model, more than one API call, or a test suite worth writing.
Example files
All the files of examples/dotnet/wpf-rest-api-project-structure/ and examples/dotnet/wpf-rest-api-project-structure.cs, where the code above comes from.
wpf-rest-api-project-structure/App.xaml
<Application x:Class="WpfRestApiProjectStructure.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" />compiled by the build, not run
wpf-rest-api-project-structure/App.xaml.cs
using System.Net.Http;using System.Threading;using System.Windows;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;
namespace WpfRestApiProjectStructure;
public partial class App : Application{ private IHost? _host;
private async void Application_Startup(object sender, StartupEventArgs e) { var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { // Host.CreateApplicationBuilder() defaults ContentRootPath to the // current directory, which is whatever launched the process set it // to - the project folder from Visual Studio, but a shortcut's own // "Start in", or nothing at all, once it is not. Anchoring it to // where the executable actually is makes appsettings.json load the // same way regardless of how the app was started. ContentRootPath = AppContext.BaseDirectory, });
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client => client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!)) // A singleton view model that takes IWidgetApiClient in its // constructor keeps this typed client for the app's whole life // instead of the short-lived instance IHttpClientFactory assumes, // so its own handler - not the factory recycling a client it is // never asked for again - has to be the one that notices a DNS // change. .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), });
// App-wide singletons - the services every window and view model may // need, not only the one that opens first - are registered here, next // to MainWindow, whatever they are: IWindowService below, and the same // slot fits a session service or a settings service the same way. builder.Services.AddSingleton<IWindowService, WindowService>(); builder.Services.AddSingleton<MainViewModel>(); builder.Services.AddSingleton<MainWindow>();
_host = builder.Build(); await _host.StartAsync();
var window = _host.Services.GetRequiredService<MainWindow>(); window.Show(); }
private void Application_Exit(object sender, ExitEventArgs e) { // Not async void: WPF raises Exit synchronously while shutting down, // and an await in here can hand control back to the caller (which // tears the process down) before the continuation that finishes // StopAsync ever runs. Blocking here, for the few hundred milliseconds // a typed client and a window service take to stop, is the trade that // keeps stopping guaranteed instead of merely likely; a host with a // slow IHostedService to drain is exactly what the timeout bounds. using var host = _host; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); host?.StopAsync(cts.Token).GetAwaiter().GetResult(); }}compiled by the build, not run
wpf-rest-api-project-structure/appsettings.json
{ "ApiBaseUrl": "https://widgets.example/"}parsed by the build
wpf-rest-api-project-structure/IWidgetApiClient.cs
using System.Threading;using System.Threading.Tasks;
namespace WpfRestApiProjectStructure;
public interface IWidgetApiClient{ Task<Widget> GetAsync(int id, CancellationToken ct = default); Task<Widget> CreateAsync(NewWidget widget, CancellationToken ct = default);}compiled by the build, not run
wpf-rest-api-project-structure/IWidgetApiClientGenerated.cs
using System.Threading;using System.Threading.Tasks;using Refit;
namespace WpfRestApiProjectStructure;
// The Refit variant from the "generated client" section: the same interface// a view model would depend on, but with no hand-written implementation -// Refit.HttpClientFactory generates one from the attributes at build time.// Named apart from IWidgetApiClient only so both can live in one example// project; a real app keeps whichever one it picked.public interface IWidgetApiClientGenerated{ [Get("/widgets/{id}")] Task<Widget> GetAsync(int id, CancellationToken ct = default);
[Post("/widgets")] Task<Widget> CreateAsync([Body] NewWidget widget, CancellationToken ct = default);}compiled by the build, not run
wpf-rest-api-project-structure/IWindowService.cs
namespace WpfRestApiProjectStructure;
public interface IWindowService{ void Show<TViewModel>(TViewModel viewModel) where TViewModel : notnull; bool? ShowDialog<TViewModel>(TViewModel viewModel) where TViewModel : notnull;}compiled by the build, not run
wpf-rest-api-project-structure/MainViewModel.cs
using System.Threading;using System.Threading.Tasks;using CommunityToolkit.Mvvm.ComponentModel;using CommunityToolkit.Mvvm.Input;
namespace WpfRestApiProjectStructure;
public sealed partial class MainViewModel(IWidgetApiClient client) : ObservableObject{ [ObservableProperty] private Widget? widget;
[RelayCommand] private async Task LoadAsync(CancellationToken ct) => Widget = await client.GetAsync(1, ct);}compiled by the build, not run
wpf-rest-api-project-structure/MainWindow.xaml
<Window x:Class="WpfRestApiProjectStructure.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Widgets" Height="200" Width="300"> <StackPanel Margin="16"> <TextBlock Text="{Binding Widget.Name}" /> <Button Content="Load" Command="{Binding LoadCommand}" /> </StackPanel></Window>compiled by the build, not run
wpf-rest-api-project-structure/MainWindow.xaml.cs
using System.Windows;
namespace WpfRestApiProjectStructure;
public partial class MainWindow : Window{ public MainWindow(MainViewModel viewModel) { InitializeComponent(); DataContext = viewModel; }}compiled by the build, not run
wpf-rest-api-project-structure/Widget.cs
namespace WpfRestApiProjectStructure;
public sealed record Widget(int Id, string Name);
public sealed record NewWidget(string Name);compiled by the build, not run
wpf-rest-api-project-structure/WidgetApiClient.cs
using System.Net.Http;using System.Net.Http.Json;using System.Threading;using System.Threading.Tasks;
namespace WpfRestApiProjectStructure;
public sealed class WidgetApiClient(HttpClient http) : IWidgetApiClient{ public async Task<Widget> GetAsync(int id, CancellationToken ct = default) { using var response = await http.GetAsync($"widgets/{id}", ct); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync<Widget>(cancellationToken: ct))!; }
public async Task<Widget> CreateAsync(NewWidget widget, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync("widgets", widget, ct); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync<Widget>(cancellationToken: ct))!; }}compiled by the build, not run
wpf-rest-api-project-structure/WindowService.cs
using System.Windows;
namespace WpfRestApiProjectStructure;
public sealed class WindowService : IWindowService{ public void Show<TViewModel>(TViewModel viewModel) where TViewModel : notnull => CreateWindow(viewModel).Show();
public bool? ShowDialog<TViewModel>(TViewModel viewModel) where TViewModel : notnull => CreateWindow(viewModel).ShowDialog();
// One case per window that opens this way; mapping a view model type to // its window, and resolving one with its own dependencies instead of // constructing it by hand, is what grows here as the app gains windows. private static Window CreateWindow<TViewModel>(TViewModel viewModel) where TViewModel : notnull => throw new NotSupportedException($"No window is registered for {typeof(TViewModel)}.");}compiled by the build, not run
wpf-rest-api-project-structure/WpfRestApiProjectStructure.csproj
<Project Sdk="Microsoft.NET.Sdk">
<!-- The Generic Host composition root the entry describes, with the API client, the app-wide window service and the main window it resolves from the container. WPF runs on Windows only; the gate compiles this on Linux (EnableWindowsTargeting) and does not run it. --> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net10.0-windows</TargetFramework> <UseWPF>true</UseWPF> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <RootNamespace>WpfRestApiProjectStructure</RootNamespace> </PropertyGroup>
<ItemGroup> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.12" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" /> <PackageReference Include="Refit.HttpClientFactory" Version="16.1.0" /> </ItemGroup>
<ItemGroup> <None Update="appsettings.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>
</Project>compiled by the build, not run
wpf-rest-api-project-structure.cs
#:package CommunityToolkit.Mvvm@8.4.2#:package xunit.assert@2.9.3
// What [RelayCommand] actually does with a trailing CancellationToken. By// default (AllowConcurrentExecutions = false) CanExecute reports false while// a call is in flight, which is what a Button bound the normal way reads: it// disables itself, so a second click never reaches Execute at all. Calling// Execute (or ExecuteAsync) directly - bypassing that check, which nothing// stops code from doing - is a different story: it cancels whatever call is// in flight and starts a fresh one regardless of AllowConcurrentExecutions.// Neither path is triggered by a window closing; only an explicit Cancel(),// or a generated cancel command (IncludeCancelCommand = true), does that. A// failed Assert throws, and the run exits non-zero.using CommunityToolkit.Mvvm.ComponentModel;using CommunityToolkit.Mvvm.Input;using Xunit;
var worker = new Worker();var firstRun = worker.DoWorkCommand.ExecuteAsync(null);Assert.False(worker.DoWorkCommand.CanExecute(null)); // not executable while the call above is in flight
worker.DoWorkCommand.Execute(null); // bypasses the CanExecute check a bound Button would have honouredawait firstRun;Assert.True(worker.WasCancelled); // ...and cancels the first call's token as a side effect of starting a secondAssert.Equal(2, worker.Invocations);
var closing = new Worker();var pending = closing.DoWorkCommand.ExecuteAsync(null);closing.DoWorkCommand.Cancel(); // the deliberate way - what a window's Closing handler callsawait pending;Assert.True(closing.WasCancelled);
var cancellable = new Worker();var cancellableRun = cancellable.DoCancellableWorkCommand.ExecuteAsync(null);Assert.True(cancellable.DoCancellableWorkCancelCommand.CanExecute(null)); // enabled only while the linked call runs
cancellable.DoCancellableWorkCancelCommand.Execute(null);await cancellableRun;Assert.True(cancellable.CancellableWasCancelled);
Console.WriteLine("wpf-rest-api-project-structure: cancellation claims hold");
public sealed partial class Worker : ObservableObject{ public int Invocations { get; private set; } public bool WasCancelled { get; private set; } public bool CancellableWasCancelled { get; private set; }
[RelayCommand] private async Task DoWorkAsync(CancellationToken ct) { Invocations++; try { await Task.Delay(TimeSpan.FromSeconds(2), ct); } catch (OperationCanceledException) { WasCancelled = true; } }
[RelayCommand(IncludeCancelCommand = true)] private async Task DoCancellableWorkAsync(CancellationToken ct) { try { await Task.Delay(TimeSpan.FromSeconds(2), ct); } catch (OperationCanceledException) { CancellableWasCancelled = true; } }}compiled and run by the build
Source: https://learn.microsoft.com/en-us/dotnet/desktop/wpf/app-development/how-to-use-host-builder
History: 7 changes
- Body changed; example changed
- Body changed
- Made public
- Body changed
- Body changed
- Body changed
- Created