MVVM Pattern
An architecture pattern that connects a View and ViewModel through data binding, so screen state and actions can be handled apart from UI controls.
· Last updated: 2026-10-26 · 26 min read
Overview
Suppose a cart screen has a quantity field, a total and a checkout button. A common first implementation puts everything into the screen controls' event handlers, or code-behind. When the quantity changes, a handler calculates the total, writes it to totalLabel.Text, and directly toggles checkoutButton.IsEnabled. With total-calculation rules and button-enabling conditions embedded in control code, checking those rules requires creating a window and manipulating its controls. Showing the same state on another screen requires rewriting the handlers.
MVVM (Model-View-ViewModel) divides this code into three parts. The Model contains business data, such as quantities and prices, and their rules. The View contains screen elements such as buttons and labels. The ViewModel exposes properties for the state the screen displays, such as TotalText, and the actions the screen can perform, such as CheckoutCommand. Because it references no screen-control types, it can be created and tested without a window.
MVVM delegates transferring ViewModel values to the screen to data binding. A binding declares a connection between a screen-element property and a ViewModel property, for example, "this label's Text comes from the ViewModel's TotalText". The framework's binding engine transfers the value. To tell the engine when to read it again, the ViewModel must announce changes. In .NET, the INotifyPropertyChanged interface's PropertyChanged event provides this notification.1 Buttons connect through ICommand. Its Execute defines the action, CanExecute reports whether it can run now, and CanExecuteChanged announces that this decision has changed.2
These examples are console programs without a binding engine. A custom implementation subscribes to PropertyChanged and CanExecuteChanged and updates screen values. This small Binder class stands in for the engine. In WPF or .NET MAUI, the framework reads XAML {Binding ...} declarations and does this work.
Structure
CartViewModel implements INotifyPropertyChanged and holds RelayCommand and Cart. There is no line from CartViewModel to ConsoleView or Binder. Only the binder knows both View and ViewModel; even it accesses the ViewModel only through INotifyPropertyChanged and ICommand.
Clicks reach the command through the binder. The ViewModel changes the Model and sends notifications; all calls that change screen values come from the binder. The diagram omits the binder's property reads and its new calls to CanExecute after a notification.
History
In his July 19, 2004 article, Martin Fowler described Presentation Model as representing a screen's state and behavior independently of GUI controls. The View renders the Presentation Model's state, requiring synchronization code to keep them aligned. Fowler called this synchronization perhaps the most annoying part of the pattern and hoped that technologies such as .NET data binding would eventually handle it. The same article notes that VisualWorks Smalltalk users knew the pattern as Application Model.3
Fowler distinguishes two arrangements by where synchronization code lives. When Presentation Model references the View, the code is in Presentation Model; when the View references Presentation Model, it is in the View. MVVM follows the latter direction: the View knows the ViewModel, while the binding engine handles synchronization. A later note says the pattern is increasingly known as MVVM, whose ViewModel corresponds to the Presentation Model element.4
Microsoft's John Gossman introduced Model/View/ViewModel in an October 8, 2005 blog post. He described it as a variation of MVC for environments where designers, rather than developers, build Views using declarative languages such as XAML and WYSIWYG tools, relying on a general data-binding mechanism. ViewModel means "Model of a View". It holds conversions from Model types to types the View uses, and Commands through which the View interacts with the Model. It also holds presentation state such as selection and modes. Gossman noted that interaction modes such as "view mode" and "edit mode" cannot always be expressed in XAML, using Sparkle UI's editing panels as examples.5
In 2006, Gossman identified easier unit testing than code-behind or event-driven code as an advantage of ViewModels. He also listed limitations: MVVM can be excessive for a simple UI; designing a suitably general ViewModel in advance is difficult for large screens; declarative data binding is harder to debug than imperative code with breakpoints; and many bindings consume memory for bookkeeping.6
In his February 2009 MSDN Magazine article, Josh Smith treated MVVM as a specialization of Presentation Model for WPF and Silverlight. He wrote that while developing the WPF platform, Microsoft built WPF applications such as Microsoft Expression Blend using MVVM. His RelayCommand takes delegates for the action and its execution condition and implements ICommand. Smith described it as a simplified variation of Composite Application Library's DelegateCommand. Smith's RelayCommand passes CanExecuteChanged subscriptions to WPF's CommandManager.RequerySuggested. The examples here use the same class name, but CanExecuteChanged is raised directly by the ViewModel rather than delegated to CommandManager.7
The name also appears outside .NET. The JavaScript library Knockout documents MVVM as a design pattern for building UI and describes a view model as a plain JavaScript object with no knowledge of HTML.8 Android Jetpack's ViewModel class is documented as a business-logic or screen-level state holder. To avoid memory leaks, it should not reference lifecycle-related APIs such as Context. That documentation does not itself use the name MVVM.9
Timeline
Year | Event | Source |
|---|---|---|
2004 | Martin Fowler published Presentation Model on July 19. | |
2005 | John Gossman introduced Model/View/ViewModel in a blog post on October 8. | |
2006 | Gossman published a discussion of M-V-VM's advantages and disadvantages. | |
2009 | Josh Smith's WPF MVVM article appeared in MSDN Magazine's February issue, introducing |
- Year
2004
- Event
Martin Fowler published Presentation Model on July 19.
- Year
2005
- Event
John Gossman introduced Model/View/ViewModel in a blog post on October 8.
- Year
2006
- Event
Gossman published a discussion of M-V-VM's advantages and disadvantages.
- Year
2009
- Event
Josh Smith's WPF MVVM article appeared in MSDN Magazine's February issue, introducing
RelayCommandandViewModelBase.- Source
Why use it?
Presentation rules reside in the ViewModel and can be tested without a window. In the example, total calculation and the checkout button's enabling condition can be checked simply by creating CartViewModel. Microsoft's .NET MAUI documentation also lists unit tests for view models and models without a view as a benefit. It adds that, when the view is implemented entirely in XAML or C#, the UI can be redesigned without changing view-model or model code. The same documentation recommends binding controls to view-model properties instead of enabling and disabling them in code-behind.10
A binding engine reduces repetitive synchronization between screen and ViewModel. Without one, the ViewModel remains independently testable, but synchronization must be written by hand; this article's Binder is that code. Even with an engine, the ViewModel must notify each changed property. The MAUI documentation also requires PropertyChanged for calculated properties affected by other properties, and advises against sending it when the value has not changed.11 A missing notification can leave the screen showing an old value without an exception.
Bindings often locate properties through string paths, so naming mistakes may appear only at runtime. Unresolved bindings in WPF and Silverlight are logged to the debug output window at runtime, and the control does not display the expected value.12 Some frameworks can detect this earlier. .NET MAUI's compiled bindings (x:DataType) report invalid binding expressions as build errors.13 As Gossman's limitations suggest, the three roles and their binding configuration may outweigh a few handler lines on a simple screen.
In .NET, CommunityToolkit.Mvvm supplies repetitive code such as INotifyPropertyChanged implementations and RelayCommand. The package includes types such as ObservableObject, ObservableValidator, RelayCommand and targets .NET Standard, so it is not tied to UI frameworks such as WinUI 3, WPF or WinForms.14 These examples implement the same interfaces directly without the package.
Example
In a cart, changing one quantity changes both the displayed total and whether checkout is available. This example follows the binder as it transfers the ViewModel's calculated presentation state and command condition into screen elements. The binder connects a ViewModel exposing quantity, calculated total and a checkout command to a console View. Lines beginning with > represent user actions; indented lines show values the binder writes to screen elements.
The initial quantity is 0, so the total is 0 and the checkout button is disabled. Changing quantity to 3 at a unit price of 1200 should change the displayed total to 3,600 and enable the button. After checkout returns the quantity to 0, all three displays should return together. A cart makes it easy to distinguish property-change notifications from command-state notifications because one input affects several displayed values.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows.Input;
var viewModel = new CartViewModel(new Cart(unitPrice: 1200));
var view = new ConsoleView();
// Simulate a XAML declaration such as {Binding TotalText}, referring to properties only by string name.
var binder = new Binder(viewModel, view);
binder.OneWay("Quantity", "quantityLabel");
binder.OneWay("TotalText", "totalLabel");
binder.Command(viewModel.CheckoutCommand, "checkoutButton");
Console.WriteLine("> set Quantity = 3");
viewModel.Quantity = 3; // With two-way binding, the input box performs this assignment.
view.Click("checkoutButton");
view.Click("checkoutButton");
// Model: business data and rules with no knowledge of the screen.
sealed class Cart(int unitPrice)
{
public int Quantity { get; set; }
public int Total => unitPrice * Quantity;
public void Checkout() => Quantity = 0;
}
// ICommand implementation taking delegates for the action and its execution condition.
sealed class RelayCommand(Action execute, Func<bool> canExecute) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => canExecute();
public void Execute(object? parameter) => execute();
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
// ViewModel: exposes presentation state and commands without referencing View types.
sealed class CartViewModel : INotifyPropertyChanged
{
private readonly Cart cart;
private readonly RelayCommand checkout;
public CartViewModel(Cart cart)
{
this.cart = cart;
checkout = new RelayCommand(Checkout, () => cart.Quantity > 0);
}
public event PropertyChangedEventHandler? PropertyChanged;
public ICommand CheckoutCommand => checkout;
public int Quantity
{
get => cart.Quantity;
set
{
if (value == cart.Quantity) return;
cart.Quantity = value;
QuantityChanged();
}
}
// Calculated property: derive the value from Model each time instead of storing it separately.
public string TotalText => cart.Total.ToString("N0", CultureInfo.InvariantCulture);
private void Checkout()
{
cart.Checkout();
QuantityChanged();
}
// Calculated properties and command state depending on Quantity also need explicit notifications.
private void QuantityChanged()
{
Raise(nameof(Quantity));
Raise(nameof(TotalText));
checkout.RaiseCanExecuteChanged();
}
private void Raise([CallerMemberName] string name = "") =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
// View: writes screen-element values and emits click events, without knowing the ViewModel.
sealed class ConsoleView
{
public event Action<string>? Clicked;
public void SetText(string control, string text) => Console.WriteLine($" {control}.Text = \"{text}\"");
public void SetEnabled(string control, bool enabled) => Console.WriteLine($" {control}.IsEnabled = {enabled}");
public void Click(string control)
{
Console.WriteLine($"> click {control}");
Clicked?.Invoke(control);
}
}
// A small binder standing in for the framework's binding engine.
// Subscribe to PropertyChanged and CanExecuteChanged to update screen values.
sealed class Binder
{
private readonly INotifyPropertyChanged source;
private readonly ConsoleView view;
private readonly Dictionary<string, string> targets = new();
public Binder(INotifyPropertyChanged source, ConsoleView view)
{
this.source = source;
this.view = view;
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is { } name && targets.ContainsKey(name)) Push(name);
};
}
public void OneWay(string property, string control)
{
targets[property] = control;
Push(property);
}
public void Command(ICommand command, string control)
{
view.SetEnabled(control, command.CanExecute(null));
command.CanExecuteChanged += (_, _) => view.SetEnabled(control, command.CanExecute(null));
// A click on a disabled button does not execute its command.
view.Clicked += clicked =>
{
if (clicked == control && command.CanExecute(null)) command.Execute(null);
};
}
private void Push(string property)
{
var value = source.GetType().GetProperty(property)!.GetValue(source);
view.SetText(targets[property], Convert.ToString(value, CultureInfo.InvariantCulture) ?? "");
}
} quantityLabel.Text = "0"
totalLabel.Text = "0"
checkoutButton.IsEnabled = False
> set Quantity = 3
quantityLabel.Text = "3"
totalLabel.Text = "3,600"
checkoutButton.IsEnabled = True
> click checkoutButton
quantityLabel.Text = "0"
totalLabel.Text = "0"
checkoutButton.IsEnabled = False
> click checkoutButtonQuantity alone changed, but the ViewModel notified Quantity, TotalText, and command state separately, so the binder updated three screen elements. The second click did not execute the command because the button was disabled.
Extended example
Input-string validation and a stock check let us compare presentation rules with business rules. A real input box supplies a string, not a number. This ViewModel receives and validates it as QuantityText, exposing the result through INotifyDataErrorInfo. INotifyDataErrorInfo is a .NET interface that returns property-specific validation errors through GetErrors and sends ErrorsChanged when those errors change.15 The ViewModel immediately checks numeric format and the per-order limit of 10 items. The Model checks stock when the order is placed, returning failure as a value. One binding deliberately misspells a property name.
The inputs abc, 12, 7, and 5 exercise different decisions. abc cannot be parsed as an integer, and 12 exceeds the screen's per-order limit. 7 passes format and limit validation but exceeds stock of 5, so the Model rejects checkout. The final 5 permits the order. Following these cart changes in order shows why immediate field-error feedback is separate from the final rule that accepts an order.
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows.Input;
var viewModel = new CartViewModel(new Cart(unitPrice: 1200, stock: 5));
var view = new ConsoleView();
var binder = new Binder(viewModel, view);
binder.TwoWay("QuantityText", "quantityBox");
binder.OneWay("TotalTxt", "totalLabel"); // Misspelled property name.
binder.OneWay("TotalText", "totalLabel");
binder.OneWay("Status", "statusLabel");
binder.Command(viewModel.CheckoutCommand, "checkoutButton");
view.Type("quantityBox", "abc");
view.Click("checkoutButton");
view.Type("quantityBox", "12");
view.Type("quantityBox", "7");
view.Click("checkoutButton");
view.Type("quantityBox", "5");
view.Click("checkoutButton");
// Order result: failures are returned as values instead of thrown exceptions.
sealed record CheckoutResult(bool Ok, string Message);
// Model: a business object with stock rules, without knowledge of screen strings or input formats.
sealed class Cart(int unitPrice, int stock)
{
public int Total(int quantity) => unitPrice * quantity;
public CheckoutResult Checkout(int quantity)
{
if (quantity > stock) return new CheckoutResult(false, $"only {stock} in stock");
stock -= quantity;
return new CheckoutResult(true, $"ordered {quantity}, {stock} left");
}
}
sealed class RelayCommand(Action execute, Func<bool> canExecute) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => canExecute();
public void Execute(object? parameter) => execute();
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
// ViewModel: receives and validates input strings, exposing errors and command state together.
sealed class CartViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
{
private const int MaxPerOrder = 10;
private readonly Cart cart;
private readonly RelayCommand checkout;
private string quantityText = "";
private int? quantity = 0;
private string? quantityError;
private string status = "ready";
public CartViewModel(Cart cart)
{
this.cart = cart;
checkout = new RelayCommand(Checkout, () => quantity is > 0 && !HasErrors);
}
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
public ICommand CheckoutCommand => checkout;
public string QuantityText
{
get => quantityText;
set
{
if (value == quantityText) return;
quantityText = value;
Validate();
Raise();
Raise(nameof(TotalText));
checkout.RaiseCanExecuteChanged();
}
}
// Calculated property: display "-" instead of a total when input is invalid.
public string TotalText =>
quantity is { } q ? cart.Total(q).ToString("N0", CultureInfo.InvariantCulture) : "-";
public string Status
{
get => status;
private set
{
if (value == status) return;
status = value;
Raise();
}
}
public bool HasErrors => quantityError is not null;
public IEnumerable GetErrors(string? propertyName) =>
propertyName == nameof(QuantityText) && quantityError is { } e ? new[] { e } : Array.Empty<string>();
// ViewModel immediately checks format and the per-order limit; Model checks stock at checkout.
private void Validate()
{
string? error = null;
quantity = null;
if (quantityText.Length == 0) quantity = 0;
else if (!int.TryParse(quantityText, NumberStyles.None, CultureInfo.InvariantCulture, out var q))
error = "enter a whole number";
else if (q < 1 || q > MaxPerOrder) error = $"enter 1 to {MaxPerOrder}";
else quantity = q;
if (error == quantityError) return;
quantityError = error;
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(QuantityText)));
}
private void Checkout()
{
var result = cart.Checkout(quantity ?? 0);
Status = result.Ok ? result.Message : "checkout failed: " + result.Message;
if (result.Ok) QuantityText = "";
}
private void Raise([CallerMemberName] string name = "") =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
sealed class ConsoleView
{
public event Action<string, string>? Edited;
public event Action<string>? Clicked;
public void SetText(string control, string text) => Console.WriteLine($" {control}.Text = \"{text}\"");
public void SetEnabled(string control, bool enabled) => Console.WriteLine($" {control}.IsEnabled = {enabled}");
public void SetErrors(string control, IEnumerable<string> errors) =>
Console.WriteLine($" {control}.Errors = [{string.Join(", ", errors)}]");
public void Type(string control, string text)
{
Console.WriteLine($"> type {control} \"{text}\"");
Edited?.Invoke(control, text);
}
public void Click(string control)
{
Console.WriteLine($"> click {control}");
Clicked?.Invoke(control);
}
}
// A small binder standing in for the framework's binding engine.
sealed class Binder
{
private readonly INotifyPropertyChanged source;
private readonly ConsoleView view;
private readonly Dictionary<string, string> targets = new();
public Binder(INotifyPropertyChanged source, ConsoleView view)
{
this.source = source;
this.view = view;
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is { } name && targets.ContainsKey(name)) Push(name);
};
if (source is INotifyDataErrorInfo errors)
errors.ErrorsChanged += (_, e) =>
{
if (e.PropertyName is { } name && targets.TryGetValue(name, out var control))
view.SetErrors(control, errors.GetErrors(name).Cast<string>());
};
}
public void OneWay(string property, string control)
{
// A wrong name is logged without throwing. No value reaches the screen.
if (source.GetType().GetProperty(property) is null)
{
Console.WriteLine($" binding error: '{property}' not found on {source.GetType().Name}");
return;
}
targets[property] = control;
Push(property);
}
public void TwoWay(string property, string control)
{
OneWay(property, control);
view.Edited += (edited, text) =>
{
if (edited == control) source.GetType().GetProperty(property)!.SetValue(source, text);
};
}
public void Command(ICommand command, string control)
{
view.SetEnabled(control, command.CanExecute(null));
command.CanExecuteChanged += (_, _) => view.SetEnabled(control, command.CanExecute(null));
view.Clicked += clicked =>
{
if (clicked != control) return;
if (!command.CanExecute(null))
{
Console.WriteLine($" {control} is disabled, command not executed");
return;
}
command.Execute(null);
};
}
private void Push(string property)
{
var value = source.GetType().GetProperty(property)!.GetValue(source);
view.SetText(targets[property], Convert.ToString(value, CultureInfo.InvariantCulture) ?? "");
}
} quantityBox.Text = ""
binding error: 'TotalTxt' not found on CartViewModel
totalLabel.Text = "0"
statusLabel.Text = "ready"
checkoutButton.IsEnabled = False
> type quantityBox "abc"
quantityBox.Errors = [enter a whole number]
quantityBox.Text = "abc"
totalLabel.Text = "-"
checkoutButton.IsEnabled = False
> click checkoutButton
checkoutButton is disabled, command not executed
> type quantityBox "12"
quantityBox.Errors = [enter 1 to 10]
quantityBox.Text = "12"
totalLabel.Text = "-"
checkoutButton.IsEnabled = False
> type quantityBox "7"
quantityBox.Errors = []
quantityBox.Text = "7"
totalLabel.Text = "8,400"
checkoutButton.IsEnabled = True
> click checkoutButton
statusLabel.Text = "checkout failed: only 5 in stock"
> type quantityBox "5"
quantityBox.Text = "5"
totalLabel.Text = "6,000"
checkoutButton.IsEnabled = True
> click checkoutButton
statusLabel.Text = "ordered 5, 0 left"
quantityBox.Text = ""
totalLabel.Text = "0"
checkoutButton.IsEnabled = FalseTotalTxt logs one binding error and transfers no value. The value in totalLabel comes from the correctly named binding on the next line. This binder reproduces only part of a binding engine. It handles all notifications immediately on the same thread and never unsubscribes from events. If the ViewModel outlives the View, its events keep both binder and View alive. There are no value converters, nested paths or collection-change notifications (INotifyCollectionChanged), and error display is connected only to QuantityText. Even valid input can fail checkout if stock is insufficient. ViewModel validation handles rules that the screen can determine immediately; the final order decision remains with the Model.
TypeScript example
TypeScript has no INotifyPropertyChanged, so an Observable base class holds subscribers and sends notifications. Commands supply the same three operations as ICommand. The C# binder resolves property-name strings at runtime; here a mapped type accepts only the names of string properties on the ViewModel, rejecting typos such as "totalTxt" at compile time. This serves the same purpose as .NET MAUI's compiled-binding checks in XAML.
Alongside the same validation and checkout flow, TypeScript lets us compare restricting binding property names through types. This example does not repeat every action in the extended C# program: the over-limit input and misspelled binding are excluded. Follow the notifications caused by input changes separately from the point where type checking rejects a misspelled string-property name.
type Listener = (property: string) => void;
// Minimal observable object used in place of INotifyPropertyChanged.
class Observable {
readonly #listeners: Listener[] = [];
subscribe(listener: Listener): void { this.#listeners.push(listener); }
protected notify(property: string): void {
for (const listener of this.#listeners) listener(property);
}
}
// Same operations as ICommand: execute, can execute, and notification when that condition changes.
class RelayCommand {
readonly #changed: Array<() => void> = [];
constructor(private readonly run: () => void, private readonly can: () => boolean) {}
canExecute(): boolean { return this.can(); }
execute(): void { this.run(); }
onCanExecuteChanged(listener: () => void): void { this.#changed.push(listener); }
raiseCanExecuteChanged(): void { for (const listener of this.#changed) listener(); }
}
type CheckoutResult = { ok: boolean; message: string };
// Model: knows only the stock rules.
class Cart {
constructor(private readonly unitPrice: number, private stock: number) {}
total(quantity: number): number { return this.unitPrice * quantity; }
checkout(quantity: number): CheckoutResult {
if (quantity > this.stock) return { ok: false, message: `only ${this.stock} in stock` };
this.stock -= quantity;
return { ok: true, message: `ordered ${quantity}, ${this.stock} left` };
}
}
const maxPerOrder = 10;
const format = new Intl.NumberFormat("en-US");
// ViewModel: exposes the input string, calculated total, errors and command, without knowing View.
class CartViewModel extends Observable {
#quantityText = "";
#quantity: number | null = 0;
#error: string | null = null;
#status = "ready";
readonly checkoutCommand: RelayCommand;
constructor(private readonly cart: Cart) {
super();
this.checkoutCommand = new RelayCommand(
() => this.#checkout(),
() => this.#quantity !== null && this.#quantity > 0 && this.#error === null);
}
get quantityText(): string { return this.#quantityText; }
set quantityText(value: string) {
if (value === this.#quantityText) return;
this.#quantityText = value;
this.#validate();
this.notify("quantityText");
this.notify("totalText");
this.checkoutCommand.raiseCanExecuteChanged();
}
get totalText(): string {
return this.#quantity === null ? "-" : format.format(this.cart.total(this.#quantity));
}
get status(): string { return this.#status; }
get errors(): string[] { return this.#error === null ? [] : [this.#error]; }
#validate(): void {
let error: string | null = null;
this.#quantity = null;
if (this.#quantityText.length === 0) this.#quantity = 0;
else if (!/^[0-9]+$/.test(this.#quantityText)) error = "enter a whole number";
else {
const q = Number(this.#quantityText);
if (q < 1 || q > maxPerOrder) error = `enter 1 to ${maxPerOrder}`;
else this.#quantity = q;
}
if (error === this.#error) return;
this.#error = error;
this.notify("errors");
}
#checkout(): void {
const result = this.cart.checkout(this.#quantity ?? 0);
this.#status = result.ok ? result.message : "checkout failed: " + result.message;
this.notify("status");
if (result.ok) this.quantityText = "";
}
}
// View: writes screen-element values and reports input and clicks.
class ConsoleView {
readonly edited: Array<(control: string, text: string) => void> = [];
readonly clicked: Array<(control: string) => void> = [];
setText(control: string, text: string): void { console.log(` ${control}.Text = "${text}"`); }
setEnabled(control: string, enabled: boolean): void {
console.log(` ${control}.IsEnabled = ${enabled ? "True" : "False"}`);
}
setErrors(control: string, errors: string[]): void {
console.log(` ${control}.Errors = [${errors.join(", ")}]`);
}
type(control: string, text: string): void {
console.log(`> type ${control} "${text}"`);
for (const listener of this.edited) listener(control, text);
}
click(control: string): void {
console.log(`> click ${control}`);
for (const listener of this.clicked) listener(control);
}
}
// Names of public CartViewModel properties whose values are strings.
type TextProperty = {
[K in keyof CartViewModel]: CartViewModel[K] extends string ? K : never
}[keyof CartViewModel];
// A small replacement for the binding engine. Typed property names make typos compile errors.
class Binder {
readonly #targets = new Map<string, string>();
constructor(private readonly source: CartViewModel, private readonly view: ConsoleView) {
source.subscribe((property) => {
const control = this.#targets.get(property);
if (control !== undefined) this.#push(property, control);
if (property === "errors") {
const box = this.#targets.get("quantityText");
if (box !== undefined) view.setErrors(box, source.errors);
}
});
}
oneWay(property: TextProperty, control: string): void {
this.#targets.set(property, control);
this.#push(property, control);
}
twoWay(property: "quantityText", control: string): void {
this.oneWay(property, control);
this.view.edited.push((edited, text) => { if (edited === control) this.source[property] = text; });
}
command(command: RelayCommand, control: string): void {
this.view.setEnabled(control, command.canExecute());
command.onCanExecuteChanged(() => this.view.setEnabled(control, command.canExecute()));
this.view.clicked.push((clicked) => {
if (clicked !== control) return;
if (!command.canExecute()) {
console.log(` ${control} is disabled, command not executed`);
return;
}
command.execute();
});
}
#push(property: string, control: string): void {
this.view.setText(control, this.source[property as TextProperty]);
}
}
const viewModel = new CartViewModel(new Cart(1200, 5));
const view = new ConsoleView();
const binder = new Binder(viewModel, view);
binder.twoWay("quantityText", "quantityBox");
binder.oneWay("totalText", "totalLabel");
binder.oneWay("status", "statusLabel");
binder.command(viewModel.checkoutCommand, "checkoutButton");
view.type("quantityBox", "abc");
view.click("checkoutButton");
view.type("quantityBox", "7");
view.click("checkoutButton");
view.type("quantityBox", "5");
view.click("checkoutButton"); quantityBox.Text = ""
totalLabel.Text = "0"
statusLabel.Text = "ready"
checkoutButton.IsEnabled = False
> type quantityBox "abc"
quantityBox.Errors = [enter a whole number]
quantityBox.Text = "abc"
totalLabel.Text = "-"
checkoutButton.IsEnabled = False
> click checkoutButton
checkoutButton is disabled, command not executed
> type quantityBox "7"
quantityBox.Errors = []
quantityBox.Text = "7"
totalLabel.Text = "8,400"
checkoutButton.IsEnabled = True
> click checkoutButton
statusLabel.Text = "checkout failed: only 5 in stock"
> type quantityBox "5"
quantityBox.Text = "5"
totalLabel.Text = "6,000"
checkoutButton.IsEnabled = True
> click checkoutButton
statusLabel.Text = "ordered 5, 0 left"
quantityBox.Text = ""
totalLabel.Text = "0"
checkoutButton.IsEnabled = FalseThe output follows the extended C# example, excluding the over-limit input ("12") and the misspelled binding. This binder also targets only CartViewModel and does not cover unsubscription or asynchronous updates.
See also
Each C# example is an independent .NET 10 console program.
The TypeScript example runs in Node.js after compilation to JavaScript targeting ES2022 or later.
Footnotes
- Microsoft Learn. INotifyPropertyChanged Interface; Data binding overview (WPF). The Remarks for INotifyPropertyChanged; the explanation that OneWay and TwoWay bindings need notifications such as INotifyPropertyChanged to detect changes in the source. ↩
- Microsoft Learn. ICommand Interface. Methods, Events. ↩
- Martin Fowler. Presentation Model (2004). Introduction; How it Works. ↩
- Martin Fowler. Presentation Model (2004). Also Known as; the introductory Note; How it Works. ↩
- John Gossman. Introduction to Model/View/ViewModel pattern for building WPF apps (2005). Full article. ↩
- John Gossman. Advantages and disadvantages of M-V-VM (2006). Full article. ↩
- Josh Smith. WPF Apps With The Model-View-ViewModel Design Pattern. MSDN Magazine, February 2009. The Evolution of Model-View-ViewModel, Why WPF Developers Love MVVM, Relaying Command Logic. ↩
- Knockout. Observables. MVVM and View Models. ↩
- Android Developers. ViewModel overview. Introduction, Best practices. ↩
- Microsoft Learn. Model-View-ViewModel, Enterprise Application Patterns Using .NET MAUI. The MVVM pattern, View. ↩
- Microsoft Learn. Model-View-ViewModel, Enterprise Application Patterns Using .NET MAUI. Updating views in response to changes in the underlying view model or model. ↩
- Karl Shifflett. Debugging Data Bindings in a WPF or Silverlight Application (2010). Introduction, View the Debug Output Window. ↩
- Microsoft Learn. Compiled bindings (.NET MAUI). Compiled bindings in XAML. ↩
- Microsoft Learn. Introduction to the MVVM Toolkit. When should I use this package? ↩
- Microsoft Learn. INotifyDataErrorInfo Interface. Remarks, Properties, Methods, Events. ↩