Claude Skill

dotnet-mvvm

Implement the Model-View-ViewModel pattern in .NET applications with proper separation of concerns, data binding, commands, and testable ViewModels using MVVM Toolkit.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download postpartum-genushyacinthus29-dotnet-skills-skills_dotnet-mvvm-bfa4ebd.zip · 7 KB
Part of postpartum-genushyacinthus29/dotnet-skills — 80 skills

Install

skills CLI npx skills add https://github.com/Postpartum-genushyacinthus29/dotnet-skills/tree/main/skills/dotnet-mvvm
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install postpartum-genushyacinthus29-dotnet-skills@llmmart
Git git clone https://github.com/Postpartum-genushyacinthus29/dotnet-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole postpartum-genushyacinthus29/dotnet-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

MVVM Pattern for .NET

Trigger On

  • implementing UI separation with Model-View-ViewModel
  • using MVVM Toolkit (CommunityToolkit.Mvvm) for ViewModels
  • designing testable UI architecture
  • handling commands, property changes, and messaging
  • choosing between MVVM frameworks

Documentation

References

See detailed examples in the references/ folder:

Core Concepts

Component Responsibility Example
Model Business logic and data Product, Order, User
View UI presentation (XAML/Razor) ProductPage.xaml
ViewModel UI logic and state ProductViewModel

Workflow

  1. Keep Views dumb — no business logic in code-behind
  2. Use data binding — connect View to ViewModel properties
  3. Commands for actions — handle user interactions via ICommand
  4. Inject dependencies — services go into ViewModel constructors
  5. Test ViewModels — they should be unit testable without UI

MVVM Toolkit Setup

<PackageReference Include="CommunityToolkit.Mvvm" Version="8.*" />

ViewModel Patterns

Basic ViewModel with Source Generators

public partial class ProductViewModel(IProductService productService) : ObservableObject
{
    [ObservableProperty]
    private string _name = string.Empty;

    [ObservableProperty]
    private decimal _price;

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
    private bool _isValid;

    [RelayCommand(CanExecute = nameof(CanSave))]
    private async Task SaveAsync()
    {
        await productService.SaveAsync(new Product { Name = Name, Price = Price });
    }

    private bool CanSave() => IsValid && !string.IsNullOrEmpty(Name);
}

Property Changed Notifications

public partial class OrderViewModel : ObservableObject
{
    [ObservableProperty]
    private int _quantity;

    [ObservableProperty]
    private decimal _unitPrice;

    // Computed property - manually notify
    public decimal Total => Quantity * UnitPrice;

    partial void OnQuantityChanged(int value)
    {
        OnPropertyChanged(nameof(Total));
    }

    partial void OnUnitPriceChanged(decimal value)
    {
        OnPropertyChanged(nameof(Total));
    }
}

Collection ViewModel

public partial class ProductListViewModel(IProductService productService) : ObservableObject
{
    [ObservableProperty]
    private ObservableCollection<ProductViewModel> _products = [];

    [ObservableProperty]
    private ProductViewModel? _selectedProduct;

    [ObservableProperty]
    private bool _isLoading;

    [RelayCommand]
    private async Task LoadProductsAsync()
    {
        IsLoading = true;
        try
        {
            var items = await productService.GetAllAsync();
            Products = new ObservableCollection<ProductViewModel>(
                items.Select(p => new ProductViewModel(productService)
                {
                    Name = p.Name,
                    Price = p.Price
                }));
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private void DeleteProduct(ProductViewModel product)
    {
        Products.Remove(product);
    }
}

Commands

Async Commands with Cancellation

public partial class SearchViewModel : ObservableObject
{
    [ObservableProperty]
    private string _searchText = string.Empty;

    [RelayCommand(IncludeCancelCommand = true)]
    private async Task SearchAsync(CancellationToken token)
    {
        await Task.Delay(500, token); // Debounce
        // Search logic with cancellation support
    }
}

Command with Parameter

public partial class NavigationViewModel : ObservableObject
{
    [RelayCommand]
    private void NavigateTo(string page)
    {
        // Navigate to page
    }

    [RelayCommand]
    private async Task OpenItemAsync(int itemId)
    {
        // Load and open item
    }
}

Messenger Pattern

Sending Messages

// Define message
public record ProductSelectedMessage(Product Product);

// Send from one ViewModel
WeakReferenceMessenger.Default.Send(new ProductSelectedMessage(selectedProduct));

Receiving Messages

public partial class ProductDetailViewModel : ObservableRecipient
{
    public ProductDetailViewModel()
    {
        IsActive = true; // Enable message reception
    }

    protected override void OnActivated()
    {
        Messenger.Register<ProductDetailViewModel, ProductSelectedMessage>(
            this, (r, m) => r.LoadProduct(m.Product));
    }

    private void LoadProduct(Product product)
    {
        // Update UI with product details
    }
}

Validation

Using ObservableValidator

public partial class RegistrationViewModel : ObservableValidator
{
    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "Email is required")]
    [EmailAddress(ErrorMessage = "Invalid email format")]
    private string _email = string.Empty;

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required]
    [MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
    private string _password = string.Empty;

    [RelayCommand(CanExecute = nameof(CanRegister))]
    private async Task RegisterAsync()
    {
        ValidateAllProperties();
        if (HasErrors) return;

        // Registration logic
    }

    private bool CanRegister() => !HasErrors;
}

Dependency Injection

Registration

// Services
services.AddSingleton<IProductService, ProductService>();
services.AddSingleton<INavigationService, NavigationService>();

// ViewModels
services.AddTransient<ProductListViewModel>();
services.AddTransient<ProductDetailViewModel>();

// Views (for View-first navigation)
services.AddTransient<ProductListPage>();
services.AddTransient<ProductDetailPage>();

ViewModel Locator Pattern

public class ViewModelLocator
{
    private static IServiceProvider _provider = null!;

    public static void Initialize(IServiceProvider provider) => _provider = provider;

    public ProductListViewModel ProductList => _provider.GetRequiredService<ProductListViewModel>();
    public ProductDetailViewModel ProductDetail => _provider.GetRequiredService<ProductDetailViewModel>();
}

View Binding

XAML Binding

<Page x:Class="MyApp.Views.ProductListPage"
      xmlns:vm="using:MyApp.ViewModels"
      x:DataType="vm:ProductListViewModel">

    <Grid>
        <ProgressRing IsActive="{x:Bind ViewModel.IsLoading, Mode=OneWay}"
                      Visibility="{x:Bind ViewModel.IsLoading, Mode=OneWay}" />

        <ListView ItemsSource="{x:Bind ViewModel.Products, Mode=OneWay}"
                  SelectedItem="{x:Bind ViewModel.SelectedProduct, Mode=TwoWay}">
            <ListView.ItemTemplate>
                <DataTemplate x:DataType="vm:ProductViewModel">
                    <StackPanel>
                        <TextBlock Text="{x:Bind Name, Mode=OneWay}" />
                        <TextBlock Text="{x:Bind Price, Mode=OneWay}" />
                    </StackPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>

        <Button Content="Load"
                Command="{x:Bind ViewModel.LoadProductsCommand}" />
    </Grid>
</Page>

Anti-Patterns to Avoid

Anti-Pattern Why It's Bad Better Approach
Logic in code-behind Not testable Move to ViewModel
ViewModel knows View Tight coupling Use interfaces/messaging
Manual INotifyPropertyChanged Verbose, error-prone Use source generators
God ViewModel Unmaintainable Split responsibilities
Direct service calls in View Violates separation Go through ViewModel
Exposing Model directly Leaks implementation Create ViewModel properties

Testing ViewModels

public class ProductViewModelTests
{
    [Fact]
    public async Task LoadProducts_PopulatesCollection()
    {
        // Arrange
        var mockService = new Mock<IProductService>();
        mockService.Setup(s => s.GetAllAsync())
            .ReturnsAsync([new Product { Name = "Test", Price = 10 }]);

        var viewModel = new ProductListViewModel(mockService.Object);

        // Act
        await viewModel.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        Assert.Single(viewModel.Products);
        Assert.Equal("Test", viewModel.Products[0].Name);
    }

    [Fact]
    public void SaveCommand_CannotExecute_WhenInvalid()
    {
        var viewModel = new ProductViewModel(Mock.Of<IProductService>())
        {
            Name = "",
            IsValid = false
        };

        Assert.False(viewModel.SaveCommand.CanExecute(null));
    }
}

Framework Comparison

Feature MVVM Toolkit Prism MVVMLight
Source generators Yes No No
Maintenance Active Active Deprecated
DI built-in No Yes No
Navigation No Yes No
Weight Light Heavy Light

Deliver

  • ViewModels that are fully unit testable
  • Clean separation between UI and business logic
  • Proper use of commands and data binding
  • Messaging for loose coupling between components

Validate

  • No business logic in code-behind files
  • ViewModels don't reference View types
  • Commands are used for all user actions
  • Properties use ObservableProperty or equivalent
  • Dependencies are injected, not created
  • Unit tests cover ViewModel logic
Files (dotnet-skills)
  • references
    • anti-patterns.md 6.3 KB
      # MVVM Anti-Patterns Reference
      
      ## 1. Logic in Code-Behind
      
      ### Bad
      ```csharp
      // MainPage.xaml.cs
      public partial class MainPage : Page
      {
          private async void SaveButton_Click(object sender, RoutedEventArgs e)
          {
              var service = new ProductService(); // Direct instantiation
              await service.SaveAsync(new Product
              {
                  Name = NameTextBox.Text,
                  Price = decimal.Parse(PriceTextBox.Text)
              });
              MessageBox.Show("Saved!");
          }
      }
      ```
      
      ### Good
      ```csharp
      // MainPage.xaml.cs
      public partial class MainPage : Page
      {
          public MainPage(MainViewModel viewModel)
          {
              InitializeComponent();
              DataContext = viewModel;
          }
      }
      
      // MainViewModel.cs
      public partial class MainViewModel(IProductService productService) : ObservableObject
      {
          [RelayCommand]
          private async Task SaveAsync()
          {
              await productService.SaveAsync(new Product { Name = Name, Price = Price });
          }
      }
      ```
      
      ## 2. ViewModel Referencing View Types
      
      ### Bad
      ```csharp
      public class MainViewModel : ObservableObject
      {
          private readonly MainPage _page; // Direct View reference
      
          public void UpdateUI()
          {
              _page.StatusLabel.Text = "Updated"; // Manipulating View directly
          }
      }
      ```
      
      ### Good
      ```csharp
      public partial class MainViewModel : ObservableObject
      {
          [ObservableProperty]
          private string _statusText = string.Empty;
      }
      ```
      
      ## 3. Manual INotifyPropertyChanged
      
      ### Bad
      ```csharp
      public class ProductViewModel : INotifyPropertyChanged
      {
          private string _name;
          public string Name
          {
              get => _name;
              set
              {
                  if (_name != value)
                  {
                      _name = value;
                      PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
                  }
              }
          }
      
          public event PropertyChangedEventHandler? PropertyChanged;
      }
      ```
      
      ### Good
      ```csharp
      public partial class ProductViewModel : ObservableObject
      {
          [ObservableProperty]
          private string _name = string.Empty;
      }
      ```
      
      ## 4. God ViewModel
      
      ### Bad
      ```csharp
      public partial class MainViewModel : ObservableObject
      {
          // 500+ lines handling products, orders, users, settings, navigation, etc.
          [ObservableProperty] private ObservableCollection<Product> _products;
          [ObservableProperty] private ObservableCollection<Order> _orders;
          [ObservableProperty] private User _currentUser;
          [ObservableProperty] private AppSettings _settings;
          // ... hundreds more properties and commands
      }
      ```
      
      ### Good
      ```csharp
      // Split into focused ViewModels
      public partial class ProductListViewModel : ObservableObject { /* Products only */ }
      public partial class OrderListViewModel : ObservableObject { /* Orders only */ }
      public partial class UserProfileViewModel : ObservableObject { /* User only */ }
      public partial class SettingsViewModel : ObservableObject { /* Settings only */ }
      ```
      
      ## 5. Direct Service Calls in View
      
      ### Bad
      ```xml
      <Button Click="OnClick" />
      ```
      ```csharp
      private async void OnClick(object sender, EventArgs e)
      {
          var service = App.ServiceProvider.GetService<IDataService>();
          var data = await service.GetDataAsync();
          // ...
      }
      ```
      
      ### Good
      ```xml
      <Button Command="{Binding LoadDataCommand}" />
      ```
      ```csharp
      public partial class DataViewModel(IDataService service) : ObservableObject
      {
          [RelayCommand]
          private async Task LoadDataAsync()
          {
              var data = await service.GetDataAsync();
              // ...
          }
      }
      ```
      
      ## 6. Exposing Model Directly
      
      ### Bad
      ```csharp
      public partial class OrderViewModel : ObservableObject
      {
          [ObservableProperty]
          private Order _order; // Exposes database entity directly
      
          // View binds to Order.Customer.Address.City
      }
      ```
      
      ### Good
      ```csharp
      public partial class OrderViewModel : ObservableObject
      {
          private readonly Order _order;
      
          public string CustomerName => _order.Customer.Name;
          public string ShippingAddress => FormatAddress(_order.Customer.Address);
          public decimal Total => _order.Items.Sum(i => i.Price * i.Quantity);
      }
      ```
      
      ## 7. Synchronous Operations on UI Thread
      
      ### Bad
      ```csharp
      [RelayCommand]
      private void LoadData()
      {
          var data = _service.GetData(); // Blocking call
          Items = new ObservableCollection<Item>(data);
      }
      ```
      
      ### Good
      ```csharp
      [RelayCommand]
      private async Task LoadDataAsync()
      {
          var data = await _service.GetDataAsync();
          Items = new ObservableCollection<Item>(data);
      }
      ```
      
      ## 8. Missing Validation
      
      ### Bad
      ```csharp
      [RelayCommand]
      private async Task SaveAsync()
      {
          await _service.SaveAsync(new Product { Name = Name, Price = Price });
      }
      ```
      
      ### Good
      ```csharp
      public partial class ProductViewModel : ObservableValidator
      {
          [ObservableProperty]
          [NotifyDataErrorInfo]
          [Required]
          private string _name = string.Empty;
      
          [RelayCommand(CanExecute = nameof(CanSave))]
          private async Task SaveAsync()
          {
              ValidateAllProperties();
              if (!HasErrors)
              {
                  await _service.SaveAsync(new Product { Name = Name, Price = Price });
              }
          }
      
          private bool CanSave() => !HasErrors;
      }
      ```
      
      ## 9. Tight Coupling Between ViewModels
      
      ### Bad
      ```csharp
      public class OrderViewModel : ObservableObject
      {
          private readonly CustomerViewModel _customerViewModel; // Direct reference
      
          public void SelectCustomer()
          {
              _customerViewModel.ShowSelectionDialog();
              Customer = _customerViewModel.SelectedCustomer;
          }
      }
      ```
      
      ### Good
      ```csharp
      public partial class OrderViewModel : ObservableRecipient
      {
          public OrderViewModel()
          {
              IsActive = true;
          }
      
          protected override void OnActivated()
          {
              Messenger.Register<CustomerSelectedMessage>(this, (r, m) =>
              {
                  Customer = m.Customer;
              });
          }
      }
      ```
      
      ## 10. Not Using IDisposable
      
      ### Bad
      ```csharp
      public class LiveDataViewModel : ObservableObject
      {
          private readonly Timer _timer;
      
          public LiveDataViewModel()
          {
              _timer = new Timer(UpdateData, null, 0, 1000);
          }
          // Timer never stopped, memory leak
      }
      ```
      
      ### Good
      ```csharp
      public partial class LiveDataViewModel : ObservableObject, IDisposable
      {
          private readonly Timer _timer;
          private bool _disposed;
      
          public LiveDataViewModel()
          {
              _timer = new Timer(UpdateData, null, 0, 1000);
          }
      
          public void Dispose()
          {
              if (!_disposed)
              {
                  _timer.Dispose();
                  _disposed = true;
              }
          }
      }
      ```
      
    • patterns.md 6.9 KB
      # MVVM Patterns Reference
      
      ## Property Patterns
      
      ### Simple Observable Property
      ```csharp
      [ObservableProperty]
      private string _name = string.Empty;
      ```
      
      ### Property with Change Notification
      ```csharp
      [ObservableProperty]
      private int _quantity;
      
      partial void OnQuantityChanged(int oldValue, int newValue)
      {
          // React to change
          OnPropertyChanged(nameof(Total));
      }
      ```
      
      ### Property Affecting Commands
      ```csharp
      [ObservableProperty]
      [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
      [NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
      private bool _isValid;
      ```
      
      ### Property with Validation
      ```csharp
      [ObservableProperty]
      [NotifyDataErrorInfo]
      [Required(ErrorMessage = "Name is required")]
      [MinLength(2, ErrorMessage = "Name must be at least 2 characters")]
      private string _name = string.Empty;
      ```
      
      ## Command Patterns
      
      ### Simple Async Command
      ```csharp
      [RelayCommand]
      private async Task LoadDataAsync()
      {
          var data = await _service.GetDataAsync();
          Items = new ObservableCollection<Item>(data);
      }
      ```
      
      ### Command with CanExecute
      ```csharp
      [RelayCommand(CanExecute = nameof(CanSave))]
      private async Task SaveAsync()
      {
          await _service.SaveAsync(CurrentItem);
      }
      
      private bool CanSave() => IsValid && !IsBusy;
      ```
      
      ### Command with Parameter
      ```csharp
      [RelayCommand]
      private void SelectItem(Item item)
      {
          SelectedItem = item;
      }
      
      [RelayCommand]
      private async Task DeleteItemAsync(int itemId)
      {
          await _service.DeleteAsync(itemId);
          Items.Remove(Items.First(i => i.Id == itemId));
      }
      ```
      
      ### Command with Cancellation
      ```csharp
      [RelayCommand(IncludeCancelCommand = true)]
      private async Task SearchAsync(string query, CancellationToken token)
      {
          await Task.Delay(300, token); // Debounce
          var results = await _searchService.SearchAsync(query, token);
          SearchResults = new ObservableCollection<SearchResult>(results);
      }
      ```
      
      ## Collection Patterns
      
      ### Master-Detail
      ```csharp
      public partial class MasterDetailViewModel : ObservableObject
      {
          [ObservableProperty]
          private ObservableCollection<Item> _items = [];
      
          [ObservableProperty]
          private Item? _selectedItem;
      
          partial void OnSelectedItemChanged(Item? value)
          {
              if (value is not null)
              {
                  LoadDetails(value.Id);
              }
          }
      
          private async void LoadDetails(int id)
          {
              DetailItem = await _service.GetDetailsAsync(id);
          }
      }
      ```
      
      ### Filtered Collection
      ```csharp
      public partial class FilteredListViewModel : ObservableObject
      {
          private readonly List<Item> _allItems = [];
      
          [ObservableProperty]
          private ObservableCollection<Item> _filteredItems = [];
      
          [ObservableProperty]
          private string _searchText = string.Empty;
      
          partial void OnSearchTextChanged(string value)
          {
              FilterItems();
          }
      
          private void FilterItems()
          {
              var filtered = string.IsNullOrEmpty(SearchText)
                  ? _allItems
                  : _allItems.Where(i => i.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
      
              FilteredItems = new ObservableCollection<Item>(filtered);
          }
      }
      ```
      
      ## Navigation Patterns
      
      ### ViewModel-First Navigation
      ```csharp
      public interface INavigationService
      {
          Task NavigateToAsync<TViewModel>() where TViewModel : ObservableObject;
          Task NavigateToAsync<TViewModel>(object parameter) where TViewModel : ObservableObject;
          Task GoBackAsync();
      }
      
      public partial class MainViewModel(INavigationService navigation) : ObservableObject
      {
          [RelayCommand]
          private async Task OpenDetailsAsync(Item item)
          {
              await navigation.NavigateToAsync<ItemDetailViewModel>(item.Id);
          }
      }
      ```
      
      ### View-First Navigation
      ```csharp
      // Shell navigation (MAUI)
      [RelayCommand]
      private async Task NavigateToSettings()
      {
          await Shell.Current.GoToAsync("settings");
      }
      
      // Frame navigation (WPF/WinUI)
      [RelayCommand]
      private void NavigateToSettings()
      {
          _frame.Navigate(typeof(SettingsPage));
      }
      ```
      
      ## State Management Patterns
      
      ### Loading State
      ```csharp
      public partial class DataViewModel : ObservableObject
      {
          [ObservableProperty]
          [NotifyPropertyChangedFor(nameof(ShowContent))]
          [NotifyPropertyChangedFor(nameof(ShowLoading))]
          [NotifyPropertyChangedFor(nameof(ShowError))]
          private ViewState _state = ViewState.Idle;
      
          [ObservableProperty]
          private string? _errorMessage;
      
          public bool ShowLoading => State == ViewState.Loading;
          public bool ShowContent => State == ViewState.Success;
          public bool ShowError => State == ViewState.Error;
      
          [RelayCommand]
          private async Task LoadAsync()
          {
              State = ViewState.Loading;
              try
              {
                  var data = await _service.GetDataAsync();
                  Items = new ObservableCollection<Item>(data);
                  State = ViewState.Success;
              }
              catch (Exception ex)
              {
                  ErrorMessage = ex.Message;
                  State = ViewState.Error;
              }
          }
      }
      
      public enum ViewState { Idle, Loading, Success, Error }
      ```
      
      ### Undo/Redo Pattern
      ```csharp
      public partial class EditViewModel : ObservableObject
      {
          private readonly Stack<Action> _undoStack = new();
          private readonly Stack<Action> _redoStack = new();
      
          [ObservableProperty]
          [NotifyCanExecuteChangedFor(nameof(UndoCommand))]
          private bool _canUndo;
      
          [ObservableProperty]
          [NotifyCanExecuteChangedFor(nameof(RedoCommand))]
          private bool _canRedo;
      
          public void ExecuteCommand(Action doAction, Action undoAction)
          {
              doAction();
              _undoStack.Push(undoAction);
              _redoStack.Clear();
              UpdateCanUndoRedo();
          }
      
          [RelayCommand(CanExecute = nameof(CanUndo))]
          private void Undo()
          {
              var action = _undoStack.Pop();
              action();
              _redoStack.Push(action);
              UpdateCanUndoRedo();
          }
      }
      ```
      
      ## Dialog Patterns
      
      ### Confirmation Dialog
      ```csharp
      public interface IDialogService
      {
          Task<bool> ConfirmAsync(string title, string message);
          Task<string?> PromptAsync(string title, string message);
          Task AlertAsync(string title, string message);
      }
      
      [RelayCommand]
      private async Task DeleteItemAsync(Item item)
      {
          var confirmed = await _dialogService.ConfirmAsync(
              "Delete Item",
              $"Are you sure you want to delete '{item.Name}'?");
      
          if (confirmed)
          {
              await _service.DeleteAsync(item.Id);
              Items.Remove(item);
          }
      }
      ```
      
      ## Dependency Injection Patterns
      
      ### Constructor Injection (Preferred)
      ```csharp
      public partial class ProductViewModel(
          IProductService productService,
          INavigationService navigation,
          IDialogService dialogs) : ObservableObject
      {
          // Use injected services directly
      }
      ```
      
      ### Factory Pattern for ViewModels
      ```csharp
      public interface IViewModelFactory
      {
          TViewModel Create<TViewModel>() where TViewModel : ObservableObject;
      }
      
      public class ViewModelFactory(IServiceProvider provider) : IViewModelFactory
      {
          public TViewModel Create<TViewModel>() where TViewModel : ObservableObject
          {
              return provider.GetRequiredService<TViewModel>();
          }
      }
      ```
      
  • SKILL.md 10.4 KB
    ---
    name: dotnet-mvvm
    version: "1.0.0"
    category: "Cross-Platform UI"
    description: "Implement the Model-View-ViewModel pattern in .NET applications with proper separation of concerns, data binding, commands, and testable ViewModels using MVVM Toolkit."
    compatibility: "Applies to WPF, MAUI, WinUI, Uno Platform, Avalonia, and Blazor projects."
    ---
    
    # MVVM Pattern for .NET
    
    ## Trigger On
    
    - implementing UI separation with Model-View-ViewModel
    - using MVVM Toolkit (CommunityToolkit.Mvvm) for ViewModels
    - designing testable UI architecture
    - handling commands, property changes, and messaging
    - choosing between MVVM frameworks
    
    ## Documentation
    
    - [MVVM Toolkit Overview](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/)
    - [ObservableObject](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/observableobject)
    - [RelayCommand](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/relaycommand)
    - [Messenger](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/messenger)
    - [Source Generators](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/generators/overview)
    
    ## References
    
    See detailed examples in the `references/` folder:
    - [`patterns.md`](references/patterns.md) — ViewModel, command, navigation, and state patterns
    - [`anti-patterns.md`](references/anti-patterns.md) — Common mistakes and how to fix them
    
    ## Core Concepts
    
    | Component | Responsibility | Example |
    |-----------|---------------|---------|
    | **Model** | Business logic and data | `Product`, `Order`, `User` |
    | **View** | UI presentation (XAML/Razor) | `ProductPage.xaml` |
    | **ViewModel** | UI logic and state | `ProductViewModel` |
    
    ## Workflow
    
    1. **Keep Views dumb** — no business logic in code-behind
    2. **Use data binding** — connect View to ViewModel properties
    3. **Commands for actions** — handle user interactions via ICommand
    4. **Inject dependencies** — services go into ViewModel constructors
    5. **Test ViewModels** — they should be unit testable without UI
    
    ## MVVM Toolkit Setup
    
    ```xml
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.*" />
    ```
    
    ## ViewModel Patterns
    
    ### Basic ViewModel with Source Generators
    ```csharp
    public partial class ProductViewModel(IProductService productService) : ObservableObject
    {
        [ObservableProperty]
        private string _name = string.Empty;
    
        [ObservableProperty]
        private decimal _price;
    
        [ObservableProperty]
        [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
        private bool _isValid;
    
        [RelayCommand(CanExecute = nameof(CanSave))]
        private async Task SaveAsync()
        {
            await productService.SaveAsync(new Product { Name = Name, Price = Price });
        }
    
        private bool CanSave() => IsValid && !string.IsNullOrEmpty(Name);
    }
    ```
    
    ### Property Changed Notifications
    ```csharp
    public partial class OrderViewModel : ObservableObject
    {
        [ObservableProperty]
        private int _quantity;
    
        [ObservableProperty]
        private decimal _unitPrice;
    
        // Computed property - manually notify
        public decimal Total => Quantity * UnitPrice;
    
        partial void OnQuantityChanged(int value)
        {
            OnPropertyChanged(nameof(Total));
        }
    
        partial void OnUnitPriceChanged(decimal value)
        {
            OnPropertyChanged(nameof(Total));
        }
    }
    ```
    
    ### Collection ViewModel
    ```csharp
    public partial class ProductListViewModel(IProductService productService) : ObservableObject
    {
        [ObservableProperty]
        private ObservableCollection<ProductViewModel> _products = [];
    
        [ObservableProperty]
        private ProductViewModel? _selectedProduct;
    
        [ObservableProperty]
        private bool _isLoading;
    
        [RelayCommand]
        private async Task LoadProductsAsync()
        {
            IsLoading = true;
            try
            {
                var items = await productService.GetAllAsync();
                Products = new ObservableCollection<ProductViewModel>(
                    items.Select(p => new ProductViewModel(productService)
                    {
                        Name = p.Name,
                        Price = p.Price
                    }));
            }
            finally
            {
                IsLoading = false;
            }
        }
    
        [RelayCommand]
        private void DeleteProduct(ProductViewModel product)
        {
            Products.Remove(product);
        }
    }
    ```
    
    ## Commands
    
    ### Async Commands with Cancellation
    ```csharp
    public partial class SearchViewModel : ObservableObject
    {
        [ObservableProperty]
        private string _searchText = string.Empty;
    
        [RelayCommand(IncludeCancelCommand = true)]
        private async Task SearchAsync(CancellationToken token)
        {
            await Task.Delay(500, token); // Debounce
            // Search logic with cancellation support
        }
    }
    ```
    
    ### Command with Parameter
    ```csharp
    public partial class NavigationViewModel : ObservableObject
    {
        [RelayCommand]
        private void NavigateTo(string page)
        {
            // Navigate to page
        }
    
        [RelayCommand]
        private async Task OpenItemAsync(int itemId)
        {
            // Load and open item
        }
    }
    ```
    
    ## Messenger Pattern
    
    ### Sending Messages
    ```csharp
    // Define message
    public record ProductSelectedMessage(Product Product);
    
    // Send from one ViewModel
    WeakReferenceMessenger.Default.Send(new ProductSelectedMessage(selectedProduct));
    ```
    
    ### Receiving Messages
    ```csharp
    public partial class ProductDetailViewModel : ObservableRecipient
    {
        public ProductDetailViewModel()
        {
            IsActive = true; // Enable message reception
        }
    
        protected override void OnActivated()
        {
            Messenger.Register<ProductDetailViewModel, ProductSelectedMessage>(
                this, (r, m) => r.LoadProduct(m.Product));
        }
    
        private void LoadProduct(Product product)
        {
            // Update UI with product details
        }
    }
    ```
    
    ## Validation
    
    ### Using ObservableValidator
    ```csharp
    public partial class RegistrationViewModel : ObservableValidator
    {
        [ObservableProperty]
        [NotifyDataErrorInfo]
        [Required(ErrorMessage = "Email is required")]
        [EmailAddress(ErrorMessage = "Invalid email format")]
        private string _email = string.Empty;
    
        [ObservableProperty]
        [NotifyDataErrorInfo]
        [Required]
        [MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
        private string _password = string.Empty;
    
        [RelayCommand(CanExecute = nameof(CanRegister))]
        private async Task RegisterAsync()
        {
            ValidateAllProperties();
            if (HasErrors) return;
    
            // Registration logic
        }
    
        private bool CanRegister() => !HasErrors;
    }
    ```
    
    ## Dependency Injection
    
    ### Registration
    ```csharp
    // Services
    services.AddSingleton<IProductService, ProductService>();
    services.AddSingleton<INavigationService, NavigationService>();
    
    // ViewModels
    services.AddTransient<ProductListViewModel>();
    services.AddTransient<ProductDetailViewModel>();
    
    // Views (for View-first navigation)
    services.AddTransient<ProductListPage>();
    services.AddTransient<ProductDetailPage>();
    ```
    
    ### ViewModel Locator Pattern
    ```csharp
    public class ViewModelLocator
    {
        private static IServiceProvider _provider = null!;
    
        public static void Initialize(IServiceProvider provider) => _provider = provider;
    
        public ProductListViewModel ProductList => _provider.GetRequiredService<ProductListViewModel>();
        public ProductDetailViewModel ProductDetail => _provider.GetRequiredService<ProductDetailViewModel>();
    }
    ```
    
    ## View Binding
    
    ### XAML Binding
    ```xml
    <Page x:Class="MyApp.Views.ProductListPage"
          xmlns:vm="using:MyApp.ViewModels"
          x:DataType="vm:ProductListViewModel">
    
        <Grid>
            <ProgressRing IsActive="{x:Bind ViewModel.IsLoading, Mode=OneWay}"
                          Visibility="{x:Bind ViewModel.IsLoading, Mode=OneWay}" />
    
            <ListView ItemsSource="{x:Bind ViewModel.Products, Mode=OneWay}"
                      SelectedItem="{x:Bind ViewModel.SelectedProduct, Mode=TwoWay}">
                <ListView.ItemTemplate>
                    <DataTemplate x:DataType="vm:ProductViewModel">
                        <StackPanel>
                            <TextBlock Text="{x:Bind Name, Mode=OneWay}" />
                            <TextBlock Text="{x:Bind Price, Mode=OneWay}" />
                        </StackPanel>
                    </DataTemplate>
                </ListView.ItemTemplate>
            </ListView>
    
            <Button Content="Load"
                    Command="{x:Bind ViewModel.LoadProductsCommand}" />
        </Grid>
    </Page>
    ```
    
    ## Anti-Patterns to Avoid
    
    | Anti-Pattern | Why It's Bad | Better Approach |
    |--------------|--------------|-----------------|
    | Logic in code-behind | Not testable | Move to ViewModel |
    | ViewModel knows View | Tight coupling | Use interfaces/messaging |
    | Manual INotifyPropertyChanged | Verbose, error-prone | Use source generators |
    | God ViewModel | Unmaintainable | Split responsibilities |
    | Direct service calls in View | Violates separation | Go through ViewModel |
    | Exposing Model directly | Leaks implementation | Create ViewModel properties |
    
    ## Testing ViewModels
    
    ```csharp
    public class ProductViewModelTests
    {
        [Fact]
        public async Task LoadProducts_PopulatesCollection()
        {
            // Arrange
            var mockService = new Mock<IProductService>();
            mockService.Setup(s => s.GetAllAsync())
                .ReturnsAsync([new Product { Name = "Test", Price = 10 }]);
    
            var viewModel = new ProductListViewModel(mockService.Object);
    
            // Act
            await viewModel.LoadProductsCommand.ExecuteAsync(null);
    
            // Assert
            Assert.Single(viewModel.Products);
            Assert.Equal("Test", viewModel.Products[0].Name);
        }
    
        [Fact]
        public void SaveCommand_CannotExecute_WhenInvalid()
        {
            var viewModel = new ProductViewModel(Mock.Of<IProductService>())
            {
                Name = "",
                IsValid = false
            };
    
            Assert.False(viewModel.SaveCommand.CanExecute(null));
        }
    }
    ```
    
    ## Framework Comparison
    
    | Feature | MVVM Toolkit | Prism | MVVMLight |
    |---------|--------------|-------|-----------|
    | Source generators | Yes | No | No |
    | Maintenance | Active | Active | Deprecated |
    | DI built-in | No | Yes | No |
    | Navigation | No | Yes | No |
    | Weight | Light | Heavy | Light |
    
    ## Deliver
    
    - ViewModels that are fully unit testable
    - Clean separation between UI and business logic
    - Proper use of commands and data binding
    - Messaging for loose coupling between components
    
    ## Validate
    
    - No business logic in code-behind files
    - ViewModels don't reference View types
    - Commands are used for all user actions
    - Properties use ObservableProperty or equivalent
    - Dependencies are injected, not created
    - Unit tests cover ViewModel logic
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related