Skip to content
KB

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`.

  • 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 ObservableObject with [ObservableProperty] fields and [RelayCommand] methods (dotnet/wpf-mvvm-framework covers why CommunityToolkit.Mvvm specifically). A view model never touches HttpClient directly; 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, not HttpClient or 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 own Services/Generated/ folder marked as not hand-edited.
  • appsettings.json (and an appsettings.Development.json overlay) - the API’s base URL and any other environment-specific setting. Host.CreateApplicationBuilder loads 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:

examples/dotnet/wpf-rest-api-project-structure/App.xaml.cs
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
examples/dotnet/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();
}
}

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:

examples/dotnet/wpf-rest-api-project-structure/MainWindow.xaml.cs
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}

compiled by the build, not run

Show the whole file
examples/dotnet/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;
}
}

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’s StopAsync (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 resume

Stopping 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:

examples/dotnet/wpf-rest-api-project-structure/App.xaml.cs
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
examples/dotnet/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();
}
}

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):

examples/dotnet/wpf-rest-api-project-structure/IWindowService.cs
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
examples/dotnet/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;
}
examples/dotnet/wpf-rest-api-project-structure/WindowService.cs
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
examples/dotnet/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)}.");
}

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.

IWidgetApiClient names the operations in domain terms - a view model asks for a Widget, not for a HttpResponseMessage it has to parse itself:

examples/dotnet/wpf-rest-api-project-structure/IWidgetApiClient.cs
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
examples/dotnet/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);
}
examples/dotnet/wpf-rest-api-project-structure/WidgetApiClient.cs
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
examples/dotnet/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))!;
}
}

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:

examples/dotnet/wpf-rest-api-project-structure.cs
[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
examples/dotnet/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 honoured
await firstRun;
Assert.True(worker.WasCancelled); // ...and cancels the first call's token as a side effect of starting a second
Assert.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 calls
await 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:

examples/dotnet/wpf-rest-api-project-structure/IWidgetApiClientGenerated.cs
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
examples/dotnet/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);
}

(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 of App.Core once several people implement services against the same interfaces in parallel: it holds only the interfaces and the DTOs/models they pass, so one developer’s WidgetApiClient rewrite in App.Core doesn’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.Wpf split is what makes this possible at all, not just testable: App.Core’s view models are plain ObservableObject classes from CommunityToolkit.Mvvm, which has no WPF dependency and the same package for WPF, MAUI, WinUI and Avalonia (dotnet/wpf-mvvm-framework covers why). Add App.Maui (or App.Avalonia) referencing App.Core exactly as App.Wpf does, and it inherits every view model and every Services/ 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 in App.Core.Services and give each head its own implementation (WpfFilePicker, MauiFilePicker), registered in that head’s own Application_Startup/MauiProgram DI setup, so a view model still depends only on IFilePicker.

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.

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.

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.

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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/wpf-rest-api-project-structure/appsettings.json
{
"ApiBaseUrl": "https://widgets.example/"
}

parsed by the build

wpf-rest-api-project-structure/IWidgetApiClient.cs
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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
examples/dotnet/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 honoured
await firstRun;
Assert.True(worker.WasCancelled); // ...and cancels the first call's token as a side effect of starting a second
Assert.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 calls
await 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

How do I attach a bearer token to every HttpClient request with a DelegatingHandler? (dotnet, howto)How do I attach a bearer tokento every HttpClient request…Should a WPF app use an MVVM framework, or is plain WPF and modern C# enough? (dotnet, howto)Should a WPF app use an MVVMframework, or is plain WPF and…What is the right way to authenticate a .NET desktop client against an API that issues JWTs? (dotnet, howto)What is the right way toauthenticate a .NET desktop…
Open in the graph

Source: https://learn.microsoft.com/en-us/dotnet/desktop/wpf/app-development/how-to-use-host-builder

History: 7 changes
  1. Body changed; example changed
  2. Body changed
  3. Made public
  4. Body changed
  5. Body changed
  6. Body changed
  7. Created