
Mvvm
- 17 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
Helps with ai & agent building tasks.
About
mvvm is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- mvvm
- AI & Agent Building
- AI-coding skill
Mvvm by the numbers
- 17 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #10,861 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill mvvmAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
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:
- `patterns.md` — ViewModel, command, navigation, and state patterns
- `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
<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
{
"version": "1.0.0",
"category": "Cross-Platform UI"
}
MVVM Anti-Patterns Reference
1. Logic in Code-Behind
Bad
// 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
// 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
public class MainViewModel : ObservableObject
{
private readonly MainPage _page; // Direct View reference
public void UpdateUI()
{
_page.StatusLabel.Text = "Updated"; // Manipulating View directly
}
}Good
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _statusText = string.Empty;
}3. Manual INotifyPropertyChanged
Bad
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
public partial class ProductViewModel : ObservableObject
{
[ObservableProperty]
private string _name = string.Empty;
}4. God ViewModel
Bad
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
// 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
<Button Click="OnClick" />private async void OnClick(object sender, EventArgs e)
{
var service = App.ServiceProvider.GetService<IDataService>();
var data = await service.GetDataAsync();
// ...
}Good
<Button Command="{Binding LoadDataCommand}" />public partial class DataViewModel(IDataService service) : ObservableObject
{
[RelayCommand]
private async Task LoadDataAsync()
{
var data = await service.GetDataAsync();
// ...
}
}6. Exposing Model Directly
Bad
public partial class OrderViewModel : ObservableObject
{
[ObservableProperty]
private Order _order; // Exposes database entity directly
// View binds to Order.Customer.Address.City
}Good
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
[RelayCommand]
private void LoadData()
{
var data = _service.GetData(); // Blocking call
Items = new ObservableCollection<Item>(data);
}Good
[RelayCommand]
private async Task LoadDataAsync()
{
var data = await _service.GetDataAsync();
Items = new ObservableCollection<Item>(data);
}8. Missing Validation
Bad
[RelayCommand]
private async Task SaveAsync()
{
await _service.SaveAsync(new Product { Name = Name, Price = Price });
}Good
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
public class OrderViewModel : ObservableObject
{
private readonly CustomerViewModel _customerViewModel; // Direct reference
public void SelectCustomer()
{
_customerViewModel.ShowSelectionDialog();
Customer = _customerViewModel.SelectedCustomer;
}
}Good
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
public class LiveDataViewModel : ObservableObject
{
private readonly Timer _timer;
public LiveDataViewModel()
{
_timer = new Timer(UpdateData, null, 0, 1000);
}
// Timer never stopped, memory leak
}Good
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;
}
}
}MVVM Patterns Reference
Property Patterns
Simple Observable Property
[ObservableProperty]
private string _name = string.Empty;Property with Change Notification
[ObservableProperty]
private int _quantity;
partial void OnQuantityChanged(int oldValue, int newValue)
{
// React to change
OnPropertyChanged(nameof(Total));
}Property Affecting Commands
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
private bool _isValid;Property with Validation
[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
[RelayCommand]
private async Task LoadDataAsync()
{
var data = await _service.GetDataAsync();
Items = new ObservableCollection<Item>(data);
}Command with CanExecute
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync()
{
await _service.SaveAsync(CurrentItem);
}
private bool CanSave() => IsValid && !IsBusy;Command with Parameter
[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
[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
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
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
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
// 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
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
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
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)
public partial class ProductViewModel(
IProductService productService,
INavigationService navigation,
IDialogService dialogs) : ObservableObject
{
// Use injected services directly
}Factory Pattern for ViewModels
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>();
}
}Related skills
AI & Agent Buildingagents