Skip to content
Development
Skill

/desktop-mvvm

MVVM for .NET desktop apps (WPF, Avalonia, WinForms, MAUI) with CommunityToolkit.Mvvm 8.4+ source generators: [ObservableProperty] partial properties, [RelayCommand] async commands, Generic Host + DI, UI-thread dispatching, navigation, messaging, and testable view models. Load

From plugin
dotnet-claude-kit
81752 skills11 agents2 hooks1 MCP

Claude Code

$ npx -y skills add codewithmukesh/dotnet-claude-kit --skill desktop-mvvm --agent claude-code

SKILL.md

desktop-mvvm.SKILL.md
name: desktop-mvvm
description: >
  MVVM for .NET desktop apps (WPF, Avalonia, WinForms, MAUI) with
  CommunityToolkit.Mvvm 8.4+ source generators: [ObservableProperty] partial
  properties, [RelayCommand] async commands, Generic Host + DI, UI-thread
  dispatching, navigation, messaging, and testable view models. Load when the
  user mentions "MVVM", "ViewModel", "WPF", "desktop app", "ObservableProperty",
  "RelayCommand", "ICommand", "INotifyPropertyChanged", "Dispatcher", or "UI thread".

Desktop MVVM (.NET 10, CommunityToolkit.Mvvm)

Core Principles

1. **View models know nothing about the UI framework** — No `Window`, `MessageBox`, `Dispatcher`, or `Visibility` in a view model. That keeps them unit-testable without a UI thread and portable between WPF, Avalonia, and MAUI. 2. **Source generators over hand-written `INotifyPropertyChanged`** — CommunityToolkit.Mvvm (8.4.x, verified) generates the property, change notification, and command boilerplate at compile time. There's no reflection, so it stays trimming- and AOT-friendly. 3. **Use partial properties, not fields** — Since 8.4, `[ObservableProperty]` goes on `partial` properties. From 8.4.1 that works on C# 14 with no `LangVersion=preview`. You get real properties that you can put attributes on and debug, instead of fields with magic names. 4. **Async commands, never `async void`** — `[RelayCommand]` on a `Task`-returning method generates an `IAsyncRelayCommand`. It disables itself while running, surfaces exceptions, and supports cancellation. 5. **Generic Host is the composition root** — DI, configuration, logging, and `IHttpClientFactory` work the same as in ASP.NET Core. Views and view models are resolved from the container, never `new`-ed in code-behind.

Patterns

View Model with Generated Properties and Commands

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public sealed partial class OrdersViewModel(IOrderService orders, TimeProvider clock) : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
    public partial string? CustomerName { get; set; }

    [ObservableProperty]
    public partial OrderRow? SelectedOrder { get; set; }

    public ObservableCollection<OrderRow> Orders { get; } = [];

    // Generates LoadCommand (IAsyncRelayCommand) + LoadCancelCommand
    [RelayCommand(IncludeCancelCommand = true)]
    private async Task LoadAsync(CancellationToken ct)
    {
        Orders.Clear();
        await foreach (var order in orders.StreamAsync(ct))
            Orders.Add(order); // continuation resumes on the UI thread
    }

    [RelayCommand(CanExecute = nameof(CanSave))]
    private Task SaveAsync(CancellationToken ct) =>
        orders.CreateAsync(CustomerName!, clock.GetUtcNow(), ct);

    private bool CanSave() => !string.IsNullOrWhiteSpace(CustomerName);

    partial void OnSelectedOrderChanged(OrderRow? value) =>
        CustomerName = value?.Customer; // generated hook, no setter boilerplate
}

**Why:** the class must be `partial` so the generator can add members. `Async` is stripped from command names (`LoadAsync` → `LoadCommand`). The command is disabled while it runs (unless `AllowConcurrentExecutions = true`), so double-clicks can't start two loads. Bind a busy indicator to `LoadCommand.IsRunning` instead of keeping a hand-rolled `IsBusy` flag.

Generic Host in WPF

Remove `StartupUri` from `App.xaml`, then own startup in `App.xaml.cs`:

public sealed partial class App : Application
{
    private readonly IHost host = CreateHost();

    private static IHost CreateHost()
    {
        var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
        {
            ContentRootPath = AppContext.BaseDirectory // appsettings.json next to the exe, not the cwd
        });
        builder.Services.AddSingleton(TimeProvider.System);
        builder.Services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);
        builder.Services.AddHttpClient<IOrderService, OrderService>();
        builder.Services.AddTransient<OrdersViewModel>();
        builder.Services.AddSingleton<MainWindow>();
        return builder.Build();
    }

    protected override void OnStartup(StartupEventArgs e)
    {
        host.Start(); // starts IHostedService background work, if any
        host.Services.GetRequiredService<MainWindow>().Show();
        base.OnStartup(e);
    }

    protected override void OnExit(ExitEventArgs e)
    {
        // Process exit is the one place sync-over-async is acceptable (like Program.cs)
        host.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
        host.Dispose();
        base.OnExit(e);
    }
}

public sealed partial class MainWindow : Window
{
    public MainWindow(OrdersViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}

**Why:** one composition root shared with your backend knowledge. Constructor injection into windows keeps code-behind to `InitializeComponent` + `DataContext`. Setting the content root to `AppContext.BaseDirectory` stops a shortcut's "Start in" folder from changing which config loads. Avalonia wiring lives in the `avalonia` skill.

UI-Thread Dispatching

`await` started on the UI thread resumes on the UI thread, so most code needs **no** dispatching. Dispatch only for callbacks that arrive on background threads (timers, `FileSystemWatcher`, sockets, message buses).

// 1. Progress<T> captures the UI SynchronizationContext when constructed on the UI thread
[RelayCommand]
private async Task ExportAsync(CancellationToken ct)
{
    var progress = new Progress<int>(percent => ExportProgress = percent);
    await Task.Run(() => exporter.Export(Orders.ToList(), progress, ct), ct); // CPU work off the UI thread
}

// 2. A tiny abstraction for background-origin events, implemented per framework
public interface IUiDispatcher
{
    Task InvokeAsync(Action action
Read more
@codewithmukesh
Ships withdotnet-claude-kit

Make Claude Code a .NET 10 Expert

81752 skills11 agents1 MCP
Get the whole plugin

Stars on GitHub

Momentum

stars in total
817
Star history of codewithmukesh/dotnet-claude-kit on GitHub, a chart by star-history.com

Other skills on dotnet-claude-kit.

More skills from other plugins.

brainstorming
Auto-invoked

brainstorming

You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or…

@obra@obraInstall Skill
ask-matt

ask-matt

Ask which skill or flow fits your situation. A router over the skills in this repo.

skills2w309k
@mattpocock@mattpocockInstall Skill
accessibility
Auto-invoked

accessibility

Design, implement, and audit accessible UI to WCAG 2.2 Level AA across Web, iOS, and Android — semantic ARIA roles and labels,…

ecc2w293k
@affaan-m@affaan-mInstall Skill
compiler-commit

compiler-commit

Use when you want to verify compiler changes and commit with the correct convention. Runs tests, lint, and format, then commits…

react5d252k
@facebook@facebookInstall Skill

Questions about desktop-mvvm.

Which coding agents can use desktop-mvvm?
desktop-mvvm installs in Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and Windsurf with the skills CLI, using each agent's flag shown on this page. Flowy's auto-invocation, which fires a skill when your prompt matches, runs in Claude Code only.