WPF conventions
For any WPF or NuGet API surface not pinned down here, resolve signatures with the context7 MCP rather than memory - never by grepping the NuGet cache or decompiled sources (the routing lesson from a sibling leaf: the MCP sat live and unused because the routing line lived only in a router skill this leaf never loads).
WPF is a retained-mode XAML UI on the data-binding engine. The whole discipline below exists to keep view concerns (visuals, the visual tree, the dispatcher) on one side of a line and application state on the other, so the state side stays a plain testable C# object. Floor is .NET 8 / C# 12.
XAML formatting and naming style - attribute ordering (XAML Styler), x:Name vs Name, property-element syntax, namespace prefixes, value-converter naming, and binding style - live in references/xaml-style.md. This SKILL.md owns the WPF architecture (strict MVVM, dependency/attached properties, threading); styles, resource dictionaries, and theming are references/styling-theming.md; the C# naming baseline is the csharp skill. Above these general conventions, a project's own Settings.XamlStyler / .editorconfig and its <docs-path>/PROJECT-CODE-STYLE.md win where they diverge.
On .NET Framework 4.8 these conventions hold, but the CommunityToolkit.Mvvm source generators, Generic
Host composition, and app-level exception wiring carry net48-specific constraints - see
references/net-framework-48.md.
MVVM is the architecture, not a suggestion
Three layers, with a deliberately one-directional dependency:
- View - the
.xamlplus a code-behind file that holds only view-only mechanics (nothing the ViewModel could own). - ViewModel - observable state plus
ICommands. A plain CLR object. - Model - the domain. Knows nothing about either layer above it.
The dependency points one way: the View references its ViewModel, the ViewModel never references the
View. The concrete test is types - if a ViewModel mentions Window, UserControl, Dispatcher,
Visibility, or any visual-tree element, the line has been crossed. State leaves the ViewModel as
bindable properties and commands; the binding engine does the rest.
Set the binding context by convention (a ViewModel-locator or DI-resolved DataContext), not with
new SomeViewModel() in code-behind, so the ViewModel's dependencies stay injectable.
App composition and startup
Compose the app through the .NET generic host, not hand-rolled service location or new in code-behind.
- Build a
Microsoft.Extensions.Hostinghost inApp.xaml.cs, register windows, ViewModels, and services on it, resolve the main window from the container inOnStartup, and dropStartupUri- the window's dependencies then inject through its constructor. - Turn on
ValidateScopesandValidateOnBuildso captive-dependency and disposed-scope mistakes fail at startup instead of at runtime. - Never call
BuildServiceProviderinside registration to pull a service early - it stands up a second container and leaks a duplicate set of singletons.
Pairing with a Windows Service
A WPF desktop is often the front for a Windows Service companion - a tray or dashboard UI over a background daemon. The service half is not WPF code: the worker model is the hosted-worker skill's (BackgroundService lifecycle, graceful shutdown, 24/7 hardening) and the SCM layer is the Windows Service skill's (AddWindowsService, start/stop budgets, install and recovery) - load those for that process where the install has them; without them the service half is a plain generic-host worker with AddWindowsService(), and this skill stops at the contract below. What is WPF's side of the pairing: the two processes share only a contract - a named pipe, a local socket, a file or database, an IPC channel - never a UI thread or a Dispatcher; a service-pushed update crosses into the app as data and marshals onto the UI thread like any other off-thread work.
Naming and pairing
- View:
OrderListView.xamlandOrderListView.xaml.cs. - ViewModel:
OrderListViewModel.cs. - The pair lives in the same feature folder. Folder-per-feature beats type-per-folder (
Views/,ViewModels/) once a screen has more than a couple of files.
Observable state with the toolkit, not by hand
Use CommunityToolkit.Mvvm. Derive the ViewModel from ObservableObject, declare backing fields
with [ObservableProperty], and let the source generator emit the property, the PropertyChanged
raise, and partial change hooks. Hand-writing INotifyPropertyChanged with SetField /
CallerMemberName boilerplate is wasted code and a place for bugs.
[NotifyPropertyChangedFor(nameof(FullName))]keeps a derived property in sync without a manual raise.[NotifyCanExecuteChangedFor(nameof(SaveCommand))]re-queries a command'sCanExecutewhen its input changes - cleaner than callingNotifyCanExecuteChanged()from a setter.
public sealed partial class OrderListViewModel : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string? customerName; // generates CustomerName + change raise
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
// persist through an injected service
}
private bool CanSave() => !string.IsNullOrWhiteSpace(CustomerName);
}
Commands, not Click handlers
- Buttons, menu items, and key gestures bind
Command(anICommand); they do not wireClickin code-behind. Declare commands with[RelayCommand]- the generator produces theIRelayCommandproperty and threadsCanExecutefrom a named predicate. - Pass command parameters through
CommandParameterand a typed[RelayCommand]method argument, not by reaching into the View. - Deeper command orchestration - undo/redo stacks, command queues, snapshot/restore - is plain C#
(Command, Memento, Observer) and routes to
csharp-design-patterns, not into the ViewModel.
Async commands carry a token and own their faults
- An async command is a
Task-returning method under[RelayCommand](which produces anAsyncRelayCommand), orAsyncRelayCommanddirectly. Never wrap async work behind a synchronousICommandand block on.Result/.Wait()- that deadlocks against the UISynchronizationContext. - Bind the generated
IsRunningto button enable-state and a busy indicator. Do not maintain a parallel hand-rolledboolbusy flag. - Take a
CancellationTokenas the last command parameter (the toolkit supplies one) so long work can be cancelled; cancel it on view teardown.
[RelayCommand(IncludeCancelCommand = true)] // also generates LoadOrdersCancelCommand
private async Task LoadOrdersAsync(CancellationToken token)
{
try
{
Orders = await orderService.GetOrdersAsync(token);
}
catch (OperationCanceledException)
{
// cancelled - nothing to surface
}
catch (Exception ex)
{
await dialogService.ShowErrorAsync(ex.Message);
}
}
- A faulting
Taskinside a command is silent by default. Catch inside the command and surface the failure through an injectedIDialogServiceor an error property - never let theTaskfault unobserved. The throw-vs-return baseline and the async rules (ConfigureAwait, no blocking) are thecsharpskill's; they apply unchanged here. AsyncRelayCommandhas two fault models - pick one deliberately. The default awaits and rethrows on the UISynchronizationContext, so a try/catch inside the command sees the fault; settingFlowExceptionsToTaskSchedulerinstead routes it toTaskScheduler.UnobservedTaskException. Prefer the default and catch locally so the failure reaches the user through your dialog or error surface; reach for the flow option only when a deliberate global handler owns it.
Routed events vs commands
Commands are the default for intent. Routed events are for the low-level interactions commands cannot express - drag-drop, mouse capture, manipulation. When a routed-event handler is unavoidable in code-behind, it does one thing: forward to a ViewModel method through a thin private wrapper. No branching, no domain logic, no state in the handler.
Clipboard and drag-drop payloads
- Custom types no longer ride onto the clipboard or a drag payload through
BinaryFormatter- on modern .NET,Clipboard.SetData,SetDataObject,DoDragDrop, and navigation-journal state throwPlatformNotSupportedExceptionfor any non-intrinsic type (the runtime status and replacement aredotnet-security's A08). - Put a serializable shape across the boundary instead: a string, an intrinsic type, or your object serialized to JSON or a
byte[]you re-hydrate yourself. TheSystem.Runtime.Serialization.Formatterscompatibility shim is a migration bridge, not a destination.
Bindings: explicit and direct
- Always state
Mode(OneWay,TwoWay,OneTime,OneWayToSource). Relying on a property's default binding mode is a silent foot-gun when the property's default later changes. UpdateSourceTrigger=PropertyChangedfor inputs that validate per keystroke; otherwise leave the text-box default ofLostFocus.- Reach other elements with
ElementNameorRelativeSource(Self,FindAncestor), not by walkingVisualTreeHelperfrom code-behind. - WPF binds with
{Binding}. Compiled bindings (x:Bind) are a UWP/WinUI feature that WPF does not have - do not reach for it. Setx:DataTypeonly where a tooling analyzer you use consumes it.
Dependency properties vs ViewModel state
The registration mechanics - control-only DependencyProperty, PropertyMetadata, coerce/validate callbacks, and why ViewModel state is never one - are in references/mvvm-advanced.md.
Attached properties
The attached-property mechanics - RegisterAttached plus the GetX / SetX pair, and the symmetric-undo rule for side effects - are in references/mvvm-advanced.md.
Event subscriptions and weak events
Symmetric subscribe/unsubscribe and when to reach for WeakEventManager / WeakReferenceMessenger instead - the leak mechanics are in references/mvvm-advanced.md.
Behaviors over code-behind wiring
- Reach for
Microsoft.Xaml.Behaviors.Wpffor cross-cutting interaction - drag-drop, focus management, data-triggered animation, event-to-command glue. - One behavior per concern; compose several on one element rather than building one omni-behavior.
- This replaces
Loaded/Unloadedsubscriptions in code-behind for cross-cutting work. If you find yourself adding plumbing in code-behind to react to interaction, a behavior is the home.
Validation lives on the ViewModel
The validation depth - INotifyDataErrorInfo via the toolkit's ObservableValidator, C#-not-XAML validation logic, Validation.ErrorTemplate, the validate-on-set / revalidate-on-submit cadence - is in references/mvvm-advanced.md.
Threading: off the UI thread, marshalled back cleanly
- Long work runs off the UI thread -
awaitan I/OTaskdirectly, orTask.Runfor CPU-bound work. The UI thread stays free to render. - Report progress with
IProgress<T>(Progress<T>captures the UISynchronizationContextand marshals callbacks for you). Reach forDispatcher.Invokeonly when there is genuinely no other way - it is the escape hatch, not the tool. - A ViewModel never touches
Application.Current.Dispatcher. If it truly needs to marshal, inject a dispatcher abstraction so the ViewModel stays testable. ObservableCollection<T>must be mutated on the UI thread - it raisesCollectionChangedsynchronously and the binding engine assumes the UI thread. For high-frequency updates, batch into a backing list and replace once, or use a collection type built for cross-thread updates, rather than firing thousands of per-item notifications.
Large lists need virtualization
ItemsControldoes not virtualize by default. For any sizeable collection useListView,ListBox, orDataGrid, which do.- Keep
VirtualizingStackPanel.IsVirtualizing="True",VirtualizingStackPanel.VirtualizationMode="Recycling", andScrollViewer.CanContentScroll="True". Recycling reuses containers instead of rebuilding them. - Do not swap in a
Grid,WrapPanel, orStackPanelas theItemsPanelfor big lists - they measure every child and defeat virtualization. - For tens of thousands of rows,
DataGridwithEnableRowVirtualizationandEnableColumnVirtualizationboth true.
ViewModels are unit tests waiting to happen
- Because a ViewModel is a plain CLR object with no
WindoworDispatcherdependency, it tests directly - no UI host. The mechanics (framework, fakes, assertions) are thedotnet-testingskill's; the WPF-specific points are below. - Assert change notification by subscribing to
PropertyChangedand checking the property name fired. - Test a command by calling
Execute(...)and asserting resulting state or a mocked side effect; assertCanExecute(...)separately from execution. - Inject every collaborator -
INavigationService,IDialogService, repositories - so the test substitutes them. Navigation runs through anINavigationService; a ViewModel never doesnew Window().Show().
Styling and theming
Styling is a View-only concern, the same line as MVVM: the ViewModel exposes state, resources and
styles decide how it paints. Working defaults: keyed styles composed with BasedOn, one resource
dictionary per concern merged into App.xaml, design tokens (named brushes / thicknesses) instead of
inline literals, and DynamicResource for anything theme-dependent so a runtime theme swap actually
repaints; on .NET 9+ prefer the built-in Fluent theme (ThemeMode) over a hand-rolled dark palette.
The full discipline - implicit vs keyed styles, ControlTemplate vs DataTemplate ownership,
theme-dictionary swapping, visual states, Fluent's experimental caveats - lives in
references/styling-theming.md.
Localization
- Every user-facing string comes from a
resxfile (Strings.en.resx,Strings.uk.resx). No literal sentences in XAML or code. - Bind with
{x:Static loc:Strings.OrderListTitle}for static text, or a runtime-resolving markup extension where the culture can switch live without a restart. - Build sentences with composite format strings and named-position placeholders, never string concatenation - word order is not the same across languages.
Forbidden in a ViewModel
The types test from the MVVM section catches all of these; the recurring offenders, each owned by a
section above: MessageBox.Show (go through IDialogService), Application.Current.Dispatcher
(inject a dispatcher abstraction - Threading), FindResource / TryFindResource (resource lookup is
a View concern), business logic in a code-behind event handler (Routed events).