MVVM Patterns
Overview
This document provides detailed documentation of the MVVM (Model-View-ViewModel) implementation patterns used in the AR Payment Reversal dashboard. It covers INotifyPropertyChanged implementation, command binding strategies, data binding examples, ViewModel lifecycle management, ViewModel communication patterns, and property change notification optimization techniques.
Key Concepts
- INotifyPropertyChanged: Property change notification mechanism
- Command Pattern: Decoupling UI actions from logic
- Data Binding: Two-way synchronization between View and ViewModel
- ViewModel Lifecycle: Creation, initialization, and disposal
- Weak Events: Preventing memory leaks
- Property Dependencies: Cascading property notifications
Implementation Details
INotifyPropertyChanged Implementation
Base ViewModel Pattern
// MepApps.Dash.Ar.Maint.PaymentReversal/Models/BindingModels/BaseViewModel.cs
public abstract class BaseViewModel : INotifyPropertyChanged
{
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;
}
// Notify multiple properties
protected void OnPropertiesChanged(params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
OnPropertyChanged(propertyName);
}
}
// Notify all properties
protected void OnAllPropertiesChanged()
{
OnPropertyChanged(string.Empty);
}
}
Property Implementation Examples
// MepApps.Dash.Ar.Maint.PaymentReversal/ViewModels/ArReversePaymentQueueViewModel.cs (Lines 99-253)
public class ArReversePaymentQueueViewModel : BaseViewModel, IArReversePaymentQueueViewModel
{
// Simple property with backing field
private decimal _totalReversePaymentValue;
public decimal TotalReversePaymentValue
{
get => _totalReversePaymentValue;
set => SetField(ref _totalReversePaymentValue, value);
}
// Collection property with change notification
private IEnumerable<ArReversePaymentHeader> _reversePaymentQueueHeaders;
public IEnumerable<ArReversePaymentHeader> ReversePaymentQueueHeaders
{
get => _reversePaymentQueueHeaders;
set
{
if (SetField(ref _reversePaymentQueueHeaders, value))
{
// Trigger dependent property updates
OnPropertyChanged(nameof(HasPayments));
OnPropertyChanged(nameof(PaymentCount));
UpdateCommandStates();
}
}
}
// Computed property (no backing field)
public bool HasPayments => ReversePaymentQueueHeaders?.Any() ?? false;
public int PaymentCount => ReversePaymentQueueHeaders?.Count() ?? 0;
// Property with validation
private SelectionItem _selectedPostPeriod;
public SelectionItem SelectedPostPeriod
{
get => _selectedPostPeriod;
set
{
if (SetField(ref _selectedPostPeriod, value))
{
ValidatePostPeriod();
ReversePaymentsCmd.RaiseCanExecuteChanged();
}
}
}
// Observable collection for real-time updates
private ObservableCollection<ArReversePaymentDetail> _invoiceDetails;
public ObservableCollection<ArReversePaymentDetail> InvoiceDetails
{
get => _invoiceDetails ??= new ObservableCollection<ArReversePaymentDetail>();
set => SetField(ref _invoiceDetails, value);
}
}
Command Binding Strategies
RelayCommand Implementation
// MepApps.Dash.Ar.Maint.PaymentReversal/Commands/RelayCommand.cs
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool> _canExecute;
public RelayCommand(Action execute, Func<bool> 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() ?? true;
}
public void Execute(object parameter)
{
_execute();
}
public void RaiseCanExecuteChanged()
{
CommandManager.InvalidateRequerySuggested();
}
}
// Async command variant
public class RelayCommandAsync : ICommand
{
private readonly Func<Task> _executeAsync;
private readonly Func<bool> _canExecute;
private bool _isExecuting;
public RelayCommandAsync(Func<Task> executeAsync, Func<bool> 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() ?? true);
}
public async void Execute(object parameter)
{
if (_isExecuting)
return;
_isExecuting = true;
RaiseCanExecuteChanged();
try
{
await _executeAsync();
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
public void RaiseCanExecuteChanged()
{
CommandManager.InvalidateRequerySuggested();
}
}
Command Usage in ViewModels
// MepApps.Dash.Ar.Maint.PaymentReversal/ViewModels/ArReversePaymentQueueViewModel.cs (Lines 39-50, 258-372)
public class ArReversePaymentQueueViewModel : BaseViewModel
{
// Command properties
public RelayCommandAsync AddPaymentCmd { get; private set; }
public RelayCommandAsync GetQueuedPaymentsCmd { get; private set; }
public RelayCommandAsync<IEnumerable<ArReversePaymentHeader>> DeletePaymentsCmd { get; private set; }
public RelayCommandAsync ClearPaymentsCmd { get; private set; }
public RelayCommandAsync ReversePaymentsCmd { get; private set; }
public ArReversePaymentQueueViewModel(
ILogger<ArReversePaymentQueueViewModel> logger,
IArReversePaymentService service)
{
_logger = logger;
_service = service;
// Initialize commands with execute and canExecute delegates
AddPaymentCmd = new RelayCommandAsync(
executeAsync: AddPayment,
canExecute: () => AddPaymentsEnabled);
GetQueuedPaymentsCmd = new RelayCommandAsync(
executeAsync: GetQueuedPayments,
canExecute: () => GetQueuedPaymentsEnabled);
DeletePaymentsCmd = new RelayCommandAsync<IEnumerable<ArReversePaymentHeader>>(
executeAsync: DeletePayments,
canExecute: (payments) => payments?.Any() ?? false);
ClearPaymentsCmd = new RelayCommandAsync(
executeAsync: ClearPayments,
canExecute: () => ClearPaymentsEnabled);
ReversePaymentsCmd = new RelayCommandAsync(
executeAsync: ReversePaymentsAsync,
canExecute: CanReversePayments);
}
// Command execution methods
private async Task AddPayment()
{
try
{
IsLoading = true;
await Task.Delay(300); // UI responsiveness
NavigateToAddPayment?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in AddPayment. {@AddPaymentContext}",
new { timestamp = DateTime.Now });
NotificationEvent?.Invoke(this, new NotificationEventArgs("Error", ex.Message));
}
finally
{
IsLoading = false;
}
}
private bool CanReversePayments()
{
return ReversePaymentQueueHeaders?.Any() == true
&& SelectedPostPeriod != null
&& !IsLoading;
}
// Update command states when properties change
private void UpdateCommandStates()
{
AddPaymentCmd.RaiseCanExecuteChanged();
ClearPaymentsCmd.RaiseCanExecuteChanged();
ReversePaymentsCmd.RaiseCanExecuteChanged();
}
}
Data Binding Examples
View-to-ViewModel Binding
<!-- Two-way binding examples -->
<StackPanel>
<!-- Simple property binding -->
<TextBox Text="{Binding SelectedCustomer, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
<!-- Binding with validation -->
<TextBox Text="{Binding CheckValue, Mode=TwoWay, ValidatesOnDataErrors=True,
NotifyOnValidationError=True, UpdateSourceTrigger=LostFocus}"/>
<!-- Command binding -->
<Button Command="{Binding AddPaymentCmd}"
Content="Add Payment"
IsEnabled="{Binding AddPaymentsEnabled}"/>
<!-- Collection binding -->
<ListBox ItemsSource="{Binding PaymentHeaders}"
SelectedItem="{Binding SelectedPaymentHeader, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Customer}"/>
<TextBlock Text="{Binding CheckNumber}" Margin="10,0"/>
<TextBlock Text="{Binding CheckValue, StringFormat=C}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Multi-binding example -->
<TextBlock>
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} payments totaling {1:C}">
<Binding Path="PaymentCount"/>
<Binding Path="TotalValue"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<!-- Binding with converter -->
<Border Visibility="{Binding HasErrors, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red"/>
</Border>
</StackPanel>
ViewModel Lifecycle Management
Initialization and Disposal
// MepApps.Dash.Ar.Maint.PaymentReversal/ViewModels/ArReversePaymentAddPaymentViewModel.cs (Lines 35-88, 365-378)
public class ArReversePaymentAddPaymentViewModel : BaseRouteableViewModel, IDisposable
{
private bool _disposed = false;
private readonly CompositeDisposable _disposables = new CompositeDisposable();
public ArReversePaymentAddPaymentViewModel(
ILogger<ArReversePaymentAddPaymentViewModel> logger,
IArReversePaymentService service)
{
_logger = logger;
_service = service;
// Subscribe to events (track for disposal)
var subscription = this.PropertyChanged.Subscribe(OnPropertyChanged);
_disposables.Add(subscription);
// Initialize commands
InitializeCommands();
}
public async Task InitializeAsync()
{
try
{
_logger.LogMethodEntry(new { viewModelType = GetType().Name });
// Initialize default values
PaymentDate = DateTime.Now.Date;
SelectedCustomer = null;
LoadedCustomer = null;
// Load initial data
await LoadInitialDataAsync();
// Subscribe to external events
SubscribeToEvents();
_logger.LogMethodExit(new { initialized = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error initializing {ViewModel}", GetType().Name);
throw;
}
}
private void SubscribeToEvents()
{
// Subscribe with weak reference to prevent memory leaks
WeakEventManager<IArReversePaymentService, EventArgs>
.AddHandler(_service, nameof(_service.DataChanged), OnServiceDataChanged);
}
private void OnServiceDataChanged(object sender, EventArgs e)
{
// Refresh data when service signals changes
_ = LoadCustomerPayments();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Dispose managed resources
_disposables?.Dispose();
// Unsubscribe from events
this.PropertyChanged -= ArReversePaymentAddPaymentViewModel_PropertyChanged;
// Clean up collections
if (_customerPayments != null)
{
foreach (var payment in _customerPayments)
{
payment.PropertyChanged -= Payment_PropertyChanged;
}
_customerPayments = null;
}
// Unsubscribe from weak events
WeakEventManager<IArReversePaymentService, EventArgs>
.RemoveHandler(_service, nameof(_service.DataChanged), OnServiceDataChanged);
}
_disposed = true;
}
}
}
ViewModel Communication Patterns
Event Aggregation
public interface IEventAggregator
{
void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class;
void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : class;
void Publish<TEvent>(TEvent eventToPublish) where TEvent : class;
}
public class EventAggregator : IEventAggregator
{
private readonly Dictionary<Type, List<WeakReference>> _eventSubscribers = new();
private readonly object _lock = new object();
public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : class
{
lock (_lock)
{
if (!_eventSubscribers.TryGetValue(typeof(TEvent), out var subscribers))
{
subscribers = new List<WeakReference>();
_eventSubscribers[typeof(TEvent)] = subscribers;
}
subscribers.Add(new WeakReference(handler));
}
}
public void Publish<TEvent>(TEvent eventToPublish) where TEvent : class
{
lock (_lock)
{
if (_eventSubscribers.TryGetValue(typeof(TEvent), out var subscribers))
{
// Clean up dead references and invoke live ones
var deadRefs = new List<WeakReference>();
foreach (var weakRef in subscribers)
{
if (weakRef.Target is Action<TEvent> handler)
{
handler(eventToPublish);
}
else
{
deadRefs.Add(weakRef);
}
}
// Remove dead references
foreach (var deadRef in deadRefs)
{
subscribers.Remove(deadRef);
}
}
}
}
}
// Usage in ViewModels
public class PaymentViewModel : BaseViewModel
{
private readonly IEventAggregator _eventAggregator;
public PaymentViewModel(IEventAggregator eventAggregator)
{
_eventAggregator = eventAggregator;
// Subscribe to events
_eventAggregator.Subscribe<PaymentProcessedEvent>(OnPaymentProcessed);
}
private void OnPaymentProcessed(PaymentProcessedEvent e)
{
// React to payment processed event
RefreshPaymentList();
}
private void PublishPaymentCompleted()
{
_eventAggregator.Publish(new PaymentCompletedEvent
{
PaymentId = CurrentPayment.Id,
Amount = CurrentPayment.Amount
});
}
}
Property Change Notification Optimization
Batch Property Updates
public class OptimizedViewModel : BaseViewModel
{
private bool _suppressNotifications;
private readonly HashSet<string> _pendingNotifications = new();
protected override void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
if (_suppressNotifications)
{
_pendingNotifications.Add(propertyName);
return;
}
base.OnPropertyChanged(propertyName);
}
protected void BatchUpdate(Action updateAction)
{
_suppressNotifications = true;
_pendingNotifications.Clear();
try
{
updateAction();
}
finally
{
_suppressNotifications = false;
// Notify all pending properties
foreach (var propertyName in _pendingNotifications)
{
base.OnPropertyChanged(propertyName);
}
_pendingNotifications.Clear();
}
}
// Usage example
public void UpdateMultipleProperties()
{
BatchUpdate(() =>
{
FirstName = "John";
LastName = "Doe";
Age = 30;
Email = "john.doe@example.com";
// All notifications fired after this block
});
}
}
Property Dependencies
public class DependentPropertiesViewModel : BaseViewModel
{
private readonly Dictionary<string, List<string>> _propertyDependencies = new();
public DependentPropertiesViewModel()
{
// Register property dependencies
RegisterDependency(nameof(FirstName), nameof(FullName));
RegisterDependency(nameof(LastName), nameof(FullName));
RegisterDependency(nameof(Quantity), nameof(TotalPrice));
RegisterDependency(nameof(UnitPrice), nameof(TotalPrice));
}
private void RegisterDependency(string sourceProperty, string dependentProperty)
{
if (!_propertyDependencies.TryGetValue(sourceProperty, out var dependents))
{
dependents = new List<string>();
_propertyDependencies[sourceProperty] = dependents;
}
if (!dependents.Contains(dependentProperty))
{
dependents.Add(dependentProperty);
}
}
protected override void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
base.OnPropertyChanged(propertyName);
// Notify dependent properties
if (_propertyDependencies.TryGetValue(propertyName, out var dependents))
{
foreach (var dependent in dependents)
{
base.OnPropertyChanged(dependent);
}
}
}
private string _firstName;
public string FirstName
{
get => _firstName;
set => SetField(ref _firstName, value);
}
private string _lastName;
public string LastName
{
get => _lastName;
set => SetField(ref _lastName, value);
}
// Computed property - automatically notified when FirstName or LastName changes
public string FullName => $"{FirstName} {LastName}";
private decimal _quantity;
public decimal Quantity
{
get => _quantity;
set => SetField(ref _quantity, value);
}
private decimal _unitPrice;
public decimal UnitPrice
{
get => _unitPrice;
set => SetField(ref _unitPrice, value);
}
// Computed property - automatically notified when Quantity or UnitPrice changes
public decimal TotalPrice => Quantity * UnitPrice;
}
Best Practices
- Use SetField helper method to reduce boilerplate
- Implement IDisposable for ViewModels with event subscriptions
- Use weak events to prevent memory leaks
- Batch property updates when changing multiple properties
- Register property dependencies for computed properties
- Use async commands for long-running operations
- Validate in setters for immediate feedback
Common Pitfalls
- Memory leaks from event subscriptions
- Infinite loops in property setters
- Missing notifications for computed properties
- Blocking UI with synchronous commands
- Not disposing ViewModels properly
Related Documentation
- UI Architecture - Overall UI design
- UI Components - Reusable controls
- Data Presentation - Data binding scenarios
- Navigation Architecture - ViewModel navigation
Summary
The MVVM patterns implemented in the AR Payment Reversal dashboard provide a robust foundation for maintainable, testable user interfaces. Through proper implementation of INotifyPropertyChanged, command patterns, and lifecycle management, the application achieves clean separation of concerns while maintaining responsive and reactive user interfaces.