MVVM Architecture Pattern
Overview
This pattern defines the Model-View-ViewModel (MVVM) architecture used across MepDash dashboards. MVVM provides clean separation of concerns between UI presentation and business logic, enabling testable, maintainable, and scalable WPF applications.
Core Concepts
- Model: Business logic and data entities
- View: UI presentation layer (XAML)
- ViewModel: View logic and state management
- Data Binding: Automatic synchronization between View and ViewModel
- Commands: Decoupled user action handling
- Property Notification: Observable property changes
Pattern Implementation
Base ViewModel Pattern
All ViewModels inherit from a base class implementing core functionality:
// Pattern: Base ViewModel
public abstract class BaseViewModel : INotifyPropertyChanged, IDisposable
{
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
// Property dependency support
protected void RaisePropertyChanged(string propertyName)
{
OnPropertyChanged(propertyName);
}
protected void RaisePropertyChanged(params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
OnPropertyChanged(propertyName);
}
}
// Disposal pattern
private bool _disposed = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Dispose managed resources
DisposeManaged();
}
_disposed = true;
}
}
protected virtual void DisposeManaged()
{
// Override in derived classes
}
}
Routable ViewModel Pattern
ViewModels that participate in navigation extend the base with routing support:
// Pattern: Routable ViewModel
public abstract class BaseRouteableViewModel : BaseViewModel, INavigationTarget
{
private readonly INavigationService _navigationService;
protected BaseRouteableViewModel(INavigationService navigationService)
{
_navigationService = navigationService;
}
// Navigation lifecycle hooks
public virtual void OnNavigatedTo(NavigationContext context)
{
// Called when navigated to this view
LoadData(context.Parameters);
}
public virtual void OnNavigatingFrom(NavigationContext context)
{
// Called when navigating away
SaveState();
}
public virtual bool CanNavigateFrom(NavigationContext context)
{
// Return false to prevent navigation
return !HasUnsavedChanges;
}
// Navigation helpers
protected void NavigateTo(string viewName, object parameters = null)
{
_navigationService.Navigate(viewName, parameters);
}
protected void NavigateBack()
{
_navigationService.GoBack();
}
// State management
protected virtual void LoadData(object parameters) { }
protected virtual void SaveState() { }
protected bool HasUnsavedChanges { get; set; }
}
Property Binding Pattern
Observable properties with change notification:
// Pattern: Observable Properties
public class CustomerViewModel : BaseViewModel
{
private string _customerCode;
private string _customerName;
private decimal _creditLimit;
private bool _isActive;
public string CustomerCode
{
get => _customerCode;
set
{
if (SetField(ref _customerCode, value))
{
// Trigger dependent property updates
RaisePropertyChanged(nameof(DisplayName));
ValidateCustomerCode();
}
}
}
public string CustomerName
{
get => _customerName;
set => SetField(ref _customerName, value);
}
public decimal CreditLimit
{
get => _creditLimit;
set
{
if (SetField(ref _creditLimit, value))
{
RaisePropertyChanged(nameof(CreditLimitDisplay));
RaisePropertyChanged(nameof(HasCreditAvailable));
}
}
}
// Computed properties
public string DisplayName => $"{CustomerCode} - {CustomerName}";
public string CreditLimitDisplay => CreditLimit.ToString("C");
public bool HasCreditAvailable => CreditLimit > 0 && IsActive;
// Validation
private void ValidateCustomerCode()
{
if (string.IsNullOrEmpty(CustomerCode))
{
AddError(nameof(CustomerCode), "Customer code is required");
}
else
{
RemoveError(nameof(CustomerCode));
}
}
}
Command Pattern
Decoupled command handling for user actions:
// Pattern: Command Implementation
public class RelayCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Predicate<object> _canExecute;
public RelayCommand(Action<object> execute, Predicate<object> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public bool CanExecute(object parameter)
{
return _canExecute?.Invoke(parameter) ?? true;
}
public void Execute(object parameter)
{
_execute(parameter);
}
public void RaiseCanExecuteChanged()
{
CommandManager.InvalidateRequerySuggested();
}
}
// Async command pattern
public class AsyncRelayCommand : ICommand
{
private readonly Func<object, Task> _executeAsync;
private readonly Predicate<object> _canExecute;
private bool _isExecuting;
public AsyncRelayCommand(Func<object, Task> executeAsync, Predicate<object> canExecute = null)
{
_executeAsync = executeAsync ?? throw new ArgumentNullException(nameof(executeAsync));
_canExecute = canExecute;
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public bool CanExecute(object parameter)
{
return !_isExecuting && (_canExecute?.Invoke(parameter) ?? true);
}
public async void Execute(object parameter)
{
if (_isExecuting) return;
_isExecuting = true;
RaiseCanExecuteChanged();
try
{
await _executeAsync(parameter);
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
public void RaiseCanExecuteChanged()
{
CommandManager.InvalidateRequerySuggested();
}
}
ViewModel Implementation Pattern
Complete ViewModel with commands and data binding:
// Pattern: Complete ViewModel
public class OrderViewModel : BaseRouteableViewModel
{
private readonly IOrderService _orderService;
private readonly ILogger<OrderViewModel> _logger;
public OrderViewModel(
IOrderService orderService,
INavigationService navigationService,
ILogger<OrderViewModel> logger)
: base(navigationService)
{
_orderService = orderService;
_logger = logger;
InitializeCommands();
InitializeCollections();
}
// Observable collections
public ObservableCollection<OrderItem> OrderItems { get; private set; }
public BindingList<Customer> Customers { get; private set; }
// Commands
public ICommand SaveCommand { get; private set; }
public ICommand CancelCommand { get; private set; }
public ICommand AddItemCommand { get; private set; }
public ICommand DeleteItemCommand { get; private set; }
public ICommand RefreshCommand { get; private set; }
private void InitializeCommands()
{
SaveCommand = new AsyncRelayCommand(
async _ => await SaveOrderAsync(),
_ => CanSaveOrder());
CancelCommand = new RelayCommand(
_ => CancelOrder(),
_ => HasUnsavedChanges);
AddItemCommand = new RelayCommand(
_ => AddOrderItem());
DeleteItemCommand = new RelayCommand(
item => DeleteOrderItem(item as OrderItem),
item => item is OrderItem);
RefreshCommand = new AsyncRelayCommand(
async _ => await RefreshDataAsync());
}
private void InitializeCollections()
{
OrderItems = new ObservableCollection<OrderItem>();
Customers = new BindingList<Customer>();
// Subscribe to collection changes
OrderItems.CollectionChanged += OnOrderItemsChanged;
}
private void OnOrderItemsChanged(object sender, NotifyCollectionChangedEventArgs e)
{
// Handle collection changes
if (e.Action == NotifyCollectionChangedAction.Add)
{
foreach (OrderItem item in e.NewItems)
{
item.PropertyChanged += OnItemPropertyChanged;
}
}
RecalculateTotals();
HasUnsavedChanges = true;
}
private void OnItemPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(OrderItem.Quantity) ||
e.PropertyName == nameof(OrderItem.Price))
{
RecalculateTotals();
}
}
// Business logic
private async Task SaveOrderAsync()
{
try
{
IsBusy = true;
var order = BuildOrderFromViewModel();
var result = await _orderService.SaveOrderAsync(order);
if (result.Success)
{
HasUnsavedChanges = false;
NavigateBack();
}
else
{
ShowError(result.ErrorMessage);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save order");
ShowError("Failed to save order");
}
finally
{
IsBusy = false;
}
}
}
Data Binding in XAML
View implementation with data binding:
<!-- Pattern: XAML Data Binding -->
<UserControl x:Class="MepApps.Views.OrderView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources>
<!-- Data Templates -->
<DataTemplate x:Key="OrderItemTemplate">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Description}"/>
<TextBox Grid.Column="1"
Text="{Binding Quantity, UpdateSourceTrigger=PropertyChanged}"/>
<TextBlock Grid.Column="2"
Text="{Binding Total, StringFormat=C}"/>
<Button Grid.Column="3"
Command="{Binding DataContext.DeleteItemCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}">
<Image Source="/Images/Delete.png"/>
</Button>
</Grid>
</DataTemplate>
</UserControl.Resources>
<Grid>
<!-- Main content -->
<DockPanel>
<!-- Toolbar -->
<ToolBar DockPanel.Dock="Top">
<Button Command="{Binding SaveCommand}">
<StackPanel Orientation="Horizontal">
<Image Source="/Images/Save.png"/>
<TextBlock Text="Save" Margin="5,0"/>
</StackPanel>
</Button>
<Button Command="{Binding CancelCommand}"
IsEnabled="{Binding HasUnsavedChanges}">
Cancel
</Button>
<Separator/>
<Button Command="{Binding RefreshCommand}">
Refresh
</Button>
</ToolBar>
<!-- Content -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Customer selection -->
<ComboBox Grid.Row="0"
ItemsSource="{Binding Customers}"
SelectedItem="{Binding SelectedCustomer}"
DisplayMemberPath="DisplayName"
IsEnabled="{Binding IsNotBusy}"/>
<!-- Order items -->
<DataGrid Grid.Row="1"
ItemsSource="{Binding OrderItems}"
AutoGenerateColumns="False"
CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Item"
Binding="{Binding StockCode}"/>
<DataGridTextColumn Header="Description"
Binding="{Binding Description}"/>
<DataGridTextColumn Header="Quantity"
Binding="{Binding Quantity}"/>
<DataGridTextColumn Header="Price"
Binding="{Binding Price, StringFormat=C}"/>
<DataGridTextColumn Header="Total"
Binding="{Binding Total, StringFormat=C}"
IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
<!-- Summary -->
<Grid Grid.Row="2">
<TextBlock HorizontalAlignment="Right">
<Run Text="Total: "/>
<Run Text="{Binding OrderTotal, StringFormat=C}"
FontWeight="Bold"/>
</TextBlock>
</Grid>
</Grid>
</DockPanel>
<!-- Busy indicator -->
<Border Background="#80000000"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}">
<ProgressBar IsIndeterminate="True"
Height="4"
VerticalAlignment="Center"/>
</Border>
</Grid>
</UserControl>
ViewModel Communication Patterns
Cross-ViewModel communication without coupling:
// Pattern: Event Aggregator
public interface IEventAggregator
{
void Subscribe<TEvent>(Action<TEvent> handler);
void Unsubscribe<TEvent>(Action<TEvent> handler);
void Publish<TEvent>(TEvent eventToPublish);
}
public class EventAggregator : IEventAggregator
{
private readonly Dictionary<Type, List<Delegate>> _handlers = new();
public void Subscribe<TEvent>(Action<TEvent> handler)
{
var eventType = typeof(TEvent);
if (!_handlers.ContainsKey(eventType))
{
_handlers[eventType] = new List<Delegate>();
}
_handlers[eventType].Add(handler);
}
public void Publish<TEvent>(TEvent eventToPublish)
{
var eventType = typeof(TEvent);
if (_handlers.ContainsKey(eventType))
{
foreach (Action<TEvent> handler in _handlers[eventType])
{
handler(eventToPublish);
}
}
}
}
// Usage in ViewModels
public class CustomerViewModel : BaseViewModel
{
private readonly IEventAggregator _eventAggregator;
public CustomerViewModel(IEventAggregator eventAggregator)
{
_eventAggregator = eventAggregator;
// Subscribe to events
_eventAggregator.Subscribe<CustomerUpdatedEvent>(OnCustomerUpdated);
}
private void OnCustomerUpdated(CustomerUpdatedEvent e)
{
if (e.CustomerId == CurrentCustomer?.Id)
{
RefreshCustomer();
}
}
private void PublishCustomerChange()
{
_eventAggregator.Publish(new CustomerUpdatedEvent
{
CustomerId = CurrentCustomer.Id
});
}
}
Dashboard-Specific Implementations
AR Payment Reversal
- Property change optimization with SetField pattern
- Weak event patterns for memory leak prevention
- Complex validation with IDataErrorInfo
- See: AR MVVM Patterns
Inventory Mini MRP
- ObservableCollection for dynamic lists
- BindingList for two-way grid binding
- Service mediation for ViewModel coordination
- See: Inventory MVVM Patterns
AP EFT Remittance
- Simplified base ViewModels
- RelayCommand for all user actions
- DataContext binding strategies
- See: AP MVVM Patterns
Best Practices
- Keep Views simple - No logic in code-behind
- Make ViewModels testable - Use dependency injection
- Use commands for all user actions
- Implement proper disposal - Clean up resources
- Handle UI threading - Use Dispatcher when needed
- Validate input - Implement IDataErrorInfo or INotifyDataErrorInfo
- Optimize property notifications - Avoid excessive updates
- Use weak events - Prevent memory leaks
Common Pitfalls
- Logic in Views - Business logic in code-behind
- Direct View references - ViewModels referencing Views
- Memory leaks - Not unsubscribing from events
- UI thread blocking - Long operations on UI thread
- Excessive notifications - Triggering too many property changes