Articles › Mobile & Desktop
WPF and MVVM: Build Maintainable Desktop Apps in C#
Learn MVVM in WPF: data binding, commands, CommunityToolkit.Mvvm source generators, dependency injection, navigation and testable view models.
WPF is still one of the most productive ways to build Windows desktop applications: rich layouts, powerful data binding and a mature ecosystem. Many line-of-business tools — accounting, inventory, point of sale, hardware control panels — are WPF apps. The key to keeping them maintainable is MVVM (Model–View–ViewModel). This guide explains the pattern and builds a small app with modern tooling.
Why MVVM?
Without a pattern, logic piles up in code-behind (MainWindow.xaml.cs): button click handlers that read text boxes, call the database and update labels. It works at first, then becomes impossible to test or change.
MVVM separates responsibilities:
| Layer | Responsibility | Knows about |
|---|---|---|
| Model | data and business rules (entities, services, repositories) | nothing UI-related |
| ViewModel | state and behavior of a screen: properties, commands, validation | the Model, not the View |
| View | XAML layout and styling | binds to the ViewModel |
The glue is data binding: the View observes ViewModel properties and invokes its commands. The ViewModel never touches a TextBox — so it can be unit tested like any class.
Data binding basics
<TextBox Text="{Binding CustomerName, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="{Binding Greeting}" />
<Button Content="Save" Command="{Binding SaveCommand}" />
The DataContext of the window (or control) decides which object the bindings read from. Binding modes:
OneWay— ViewModel → View (default for most properties).TwoWay— both directions (default forTextBox.Text,CheckBox.IsChecked).OneTime— read once.
INotifyPropertyChanged by hand
For the View to update when a property changes, the ViewModel raises PropertyChanged:
public class CustomerViewModel : INotifyPropertyChanged
{
private string _name = "";
public string Name
{
get => _name;
set
{
if (_name == value) return;
_name = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Greeting));
}
}
public string Greeting => $"Hello, {Name}!";
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
That's a lot of boilerplate per property — which is why almost everyone uses a helper library.
CommunityToolkit.Mvvm: modern MVVM with source generators
dotnet new wpf -n InventoryApp
dotnet add package CommunityToolkit.Mvvm
The toolkit generates the boilerplate at compile time:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class ProductsViewModel(IProductService products) : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string _newProductName = "";
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private ProductDto? _selectedProduct;
public ObservableCollection<ProductDto> Products { get; } = [];
[RelayCommand]
private async Task LoadAsync()
{
IsLoading = true;
try
{
Products.Clear();
foreach (var p in await products.GetAllAsync())
Products.Add(p);
}
finally { IsLoading = false; }
}
[RelayCommand(CanExecute = nameof(CanAdd))]
private async Task AddAsync()
{
var created = await products.CreateAsync(NewProductName.Trim());
Products.Add(created);
NewProductName = "";
}
private bool CanAdd() => !string.IsNullOrWhiteSpace(NewProductName);
partial void OnSelectedProductChanged(ProductDto? value)
{
// react to selection changes, e.g. load details
}
}
What gets generated:
[ObservableProperty]on_newProductName→ a publicNewProductNameproperty that raisesPropertyChanged.[RelayCommand]onAddAsync→ anAddCommand(IAsyncRelayCommand) with built-inIsRunningand protection against running twice.NotifyCanExecuteChangedFor→ the Add button enables/disables automatically as the user types.partial void On...Changed→ hooks for reacting to property changes.
The View
<Window x:Class="InventoryApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Inventory" Height="500" Width="800">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,12">
<TextBox Width="250" Text="{Binding NewProductName, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="Add" Margin="8,0,0,0" Command="{Binding AddCommand}" />
<Button Content="Refresh" Margin="8,0,0,0" Command="{Binding LoadCommand}" />
</StackPanel>
<ProgressBar DockPanel.Dock="Top" Height="4" IsIndeterminate="True"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}" />
<ListView ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}" Width="300" />
<GridViewColumn Header="Stock" DisplayMemberBinding="{Binding Stock}" Width="100" />
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Window>
ObservableCollection<T> notifies the ListView when items are added or removed, so the list updates automatically.
Tip: Turn on binding error output (Visual Studio → Output window, or the XAML Binding Failures panel). A typo in a binding path fails silently at runtime — this is the #1 source of "nothing happens" bugs in WPF.
Dependency injection with the Generic Host
Use the same DI container as ASP.NET Core:
public partial class App : Application
{
public static IHost Host { get; } = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddDbContext<AppDbContext>(o => o.UseSqlite("Data Source=inventory.db"));
services.AddScoped<IProductService, ProductService>();
services.AddTransient<ProductsViewModel>();
services.AddSingleton<MainWindow>();
})
.Build();
protected override async void OnStartup(StartupEventArgs e)
{
await Host.StartAsync();
var window = Host.Services.GetRequiredService<MainWindow>();
window.Show();
base.OnStartup(e);
}
protected override async void OnExit(ExitEventArgs e)
{
await Host.StopAsync();
Host.Dispose();
base.OnExit(e);
}
}
public partial class MainWindow : Window
{
public MainWindow(ProductsViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
Loaded += async (_, _) => await viewModel.LoadCommand.ExecuteAsync(null);
}
}
Remove StartupUri from App.xaml since the window is created by DI. Code-behind is now tiny — just wiring.
Navigation between screens
A common pattern: a shell view model holds the current page view model, and DataTemplates map each view model type to a view:
<Window.Resources>
<DataTemplate DataType="{x:Type vm:ProductsViewModel}"><views:ProductsView /></DataTemplate>
<DataTemplate DataType="{x:Type vm:SettingsViewModel}"><views:SettingsView /></DataTemplate>
</Window.Resources>
<ContentControl Content="{Binding CurrentPage}" />
public partial class ShellViewModel(IServiceProvider services) : ObservableObject
{
[ObservableProperty] private ObservableObject? _currentPage;
[RelayCommand] private void GoToProducts() => CurrentPage = services.GetRequiredService<ProductsViewModel>();
[RelayCommand] private void GoToSettings() => CurrentPage = services.GetRequiredService<SettingsViewModel>();
}
For communication between view models without direct references, use the toolkit's WeakReferenceMessenger.
Validation
Inherit from ObservableValidator and use data annotations:
public partial class CustomerFormViewModel : ObservableValidator
{
[ObservableProperty]
[NotifyDataErrorInfo]
[Required(ErrorMessage = "Name is required.")]
[MinLength(3)]
private string _name = "";
}
WPF reads INotifyDataErrorInfo and shows a red border; customize it with Validation.ErrorTemplate.
Keep the UI responsive
- Make I/O async (
awaitdatabase and HTTP calls); never block the UI thread with.Resultor.Wait(). - Run CPU-heavy work with
Task.Run, then update bound properties afterawait— you're back on the UI thread automatically. - For very large lists, keep
VirtualizingStackPanelenabled (the default forListView/DataGrid) and avoid wrapping them in aScrollViewer.
Testing view models
Because view models don't depend on WPF controls, tests are simple:
[Fact]
public async Task Add_appends_product_and_clears_input()
{
var service = new Mock<IProductService>();
service.Setup(s => s.CreateAsync("Keyboard")).ReturnsAsync(new ProductDto(1, "Keyboard", 0));
var vm = new ProductsViewModel(service.Object) { NewProductName = "Keyboard" };
await vm.AddCommand.ExecuteAsync(null);
Assert.Single(vm.Products);
Assert.Equal("", vm.NewProductName);
}
Summary
MVVM keeps WPF applications maintainable by moving state and behavior into view models that the XAML view binds to. CommunityToolkit.Mvvm removes the boilerplate with [ObservableProperty] and [RelayCommand], the Generic Host brings dependency injection, DataTemplates handle navigation, and async commands keep the UI responsive. The result is a desktop app whose logic is as testable as any backend service.
FAQ
Is WPF still worth learning?
Yes, for Windows desktop software. It's actively maintained in modern .NET and widely used in enterprise, industrial and retail applications. For cross-platform desktop, look at Avalonia (very similar to WPF) or .NET MAUI.
Is code-behind forbidden in MVVM?
No. View-only concerns (animations, focus, window behavior) belong in code-behind or behaviors. Business logic and state don't.
Can I use MVVM with WinForms?
Partially — WinForms has data binding, and CommunityToolkit.Mvvm works there too — but WPF's binding system is far more powerful, which is why MVVM is most natural in WPF, Avalonia and MAUI.