DEV Community

Spyros Ponaris
Spyros Ponaris

Posted on • Edited on

MVVM is Not Just for WPF — Why It Still Matters in 2025 (Even in Blazor and WinForms)

💡 Introduction

The Model-View-ViewModel (MVVM) pattern has been around since the early days of WPF, and yet, decades later, many developers still misunderstand its real purpose. It's not a WPF-exclusive magic trick. MVVM is a UI architecture pattern — and like all patterns, it's portable.

Source code :
GitHub Repo: MVVM-WPF-BLAZOR-WINFORMS

Source code :
GitHub Repo: WPF Clean Architecture - Master Detail App

This is a fully working WPF Master-Details application built with CommunityToolkit.Mvvm. It features:

✅ An editable DataGrid
✅ Clean MVVM architecture
✅ Repository pattern for data access
✅ Async-ready CRUD operations (New, Save, Delete)
✅ Selection handling with detail binding
✅ ViewModel-first approach, testable and scalable

This solution also contains the foundation for future extensions in Blazor and WinForms, following the same MVVM logic.

In the next session, we’ll walk through the entire application step by step — from project setup to advanced interactions like validation, nested ViewModels, and UI enhancements.

In this article, we’ll:

Clarify what MVVM really is.

  • Show why MVVM still makes sense in modern projects.
  • Debunk myths that MVVM only applies to WPF.
  • See how MVVM is useful in Blazor and even WinForms.

📀 What is MVVM (Really)?

MVVM separates your application into three distinct layers:

  • Model – The business logic and data.
  • View – The UI (buttons, textboxes, grids).
  • ViewModel – A binding-friendly abstraction that connects the View with the Model.

The key feature? Binding and separation of concerns.

🧐 Why Do People Still Think MVVM = WPF?

Because WPF made MVVM famous. With its powerful data binding and command system, WPF was a natural fit. Frameworks like Prism, Caliburn.Micro, and MVVM Light reinforced that.

But now we’re seeing new UI platforms — like Blazor — and old ones like WinForms still in use. And here’s the truth:

MVVM is not tied to any UI platform. It’s a way of thinking.

🎲 MVVM in WPF — The Classic Example

Let’s build a basic counter app using MVVM in WPF.

ViewModel

public class CounterViewModel : INotifyPropertyChanged
{
    private int _count;
    public int Count
    {
        get => _count;
        set { _count = value; OnPropertyChanged(); }
    }

    public ICommand IncrementCommand { get; }

    public CounterViewModel()
    {
        IncrementCommand = new RelayCommand(_ => Count++);
    }

    public event PropertyChangedEventHandler? PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string propertyName = "")
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
Enter fullscreen mode Exit fullscreen mode

RelayCommand Helper

public class RelayCommand : ICommand
{
    private readonly Action<object?> _execute;
    private readonly Func<object?, bool>? _canExecute;

    public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
    public void Execute(object? parameter) => _execute(parameter);
    public event EventHandler? CanExecuteChanged;
}
Enter fullscreen mode Exit fullscreen mode

View (XAML)

<Window x:Class="WpfAppMVVM.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfAppMVVM"
        mc:Ignorable="d"
        DataContext="CounterViewModel"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
            <TextBlock Text="{Binding Count}" FontSize="24" HorizontalAlignment="Center" />
            <Button Content="Increment"
                    Command="{Binding IncrementCommand}"
                    Margin="0,10,0,0"
                    Width="100" />
        </StackPanel>
    </Grid>
</Window>
Enter fullscreen mode Exit fullscreen mode

🎲 MVVM in WPF — using CommunityToolkit.Mvvm

✅ 3. App.xaml.cs – HostBuilder with DI

public partial class App : Application
{
    public static  IHost AppHost { get; private set; } = null!;

    public App()
    {
        AppHost = Host.CreateDefaultBuilder()
                       .ConfigureServices((context, services) =>
                       {
                           // Register services
                           services.AddSingleton<ICustomerRepository, InMemoryCustomerRepository>();

                           // Register viewmodels
                           services.AddTransient<CustomerGridViewModel>();

                           // Register windows
                           services.AddTransient<MainWindow>();
                       })
                       .Build();
    }

    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        AppHost.Start();

        // Resolve and show the main window
        var mainWindow = AppHost.Services.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }

    protected override void OnExit(ExitEventArgs e)
    {
        base.OnExit(e);
        AppHost.StopAsync().GetAwaiter().GetResult();
        AppHost.Dispose();
    }
Enter fullscreen mode Exit fullscreen mode

✅ 4. MainWindow.xaml.cs

public partial class MainWindow : Window
{
    public MainWindow(CustomerGridViewModel vm)
    {
        InitializeComponent();
        DataContext = vm;
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ 5. MainWindow.xaml

<Window x:Class="WpfAppCommunityMVVM.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Customers" Height="400" Width="600">
    <DockPanel Margin="10">
        <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,10">
            <Button Content="New" Command="{Binding NewCommand}" Margin="0,0,5,0" />
            <Button Content="Save" Command="{Binding SaveCommand}" Margin="0,0,5,0" />
            <Button Content="Delete" Command="{Binding DeleteCommand}" />
        </StackPanel>

        <DataGrid ItemsSource="{Binding Customers}"
                  SelectedItem="{Binding SelectedCustomer, Mode=TwoWay}"
                  AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*" />
                <DataGridTextColumn Header="Email" Binding="{Binding Email}" Width="*" />
            </DataGrid.Columns>
        </DataGrid>
    </DockPanel>
</Window>
Enter fullscreen mode Exit fullscreen mode

✅ 6. ViewModel

public partial class CustomerGridViewModel : ObservableObject
{
    private readonly ICustomerRepository _repository;

    public CustomerGridViewModel(ICustomerRepository repository)
    {
        _repository = repository;
        Customers = new();
        LoadCommand.Execute(null);
    }

    [ObservableProperty]
    private ObservableCollection<Customer> customers;

    [ObservableProperty]
    private Customer? selectedCustomer;

    [RelayCommand]
    private async Task LoadAsync()
    {
        var list = await _repository.GetAllAsync();
        Customers = new ObservableCollection<Customer>(list);
    }

    [RelayCommand]
    private void New()
    {
        var newCustomer = new Customer();
        Customers.Add(newCustomer);
        SelectedCustomer = newCustomer;
    }

    [RelayCommand]
    private async Task SaveAsync()
    {
        if (SelectedCustomer == null) return;

        if (SelectedCustomer.Id == 0)
            await _repository.AddAsync(SelectedCustomer);
        else
            await _repository.UpdateAsync(SelectedCustomer);
    }

    [RelayCommand]
    private async Task DeleteAsync()
    {
        if (SelectedCustomer == null || SelectedCustomer.Id == 0) return;

        await _repository.DeleteAsync(SelectedCustomer.Id);
        Customers.Remove(SelectedCustomer);
        SelectedCustomer = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

🚄 MVVM in WinForms

While WinForms lacks strong data binding support compared to WPF, MVVM is still possible. Here's a simplified version using a BindingSource.

ViewModel

public class CounterViewModel : INotifyPropertyChanged
{
    private int _count;
    public int Count
    {
        get => _count;
        set { _count = value; OnPropertyChanged(); }
    }

    public void Increment() => Count++;

    public event PropertyChangedEventHandler? PropertyChanged;
    private void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
Enter fullscreen mode Exit fullscreen mode

WinForms View Code (Form1.cs)

public partial class Form1 : Form
{
    private readonly CounterViewModel _viewModel = new();

    public Form1()
    {
        InitializeComponent();
        var bindingSource = new BindingSource { DataSource = _viewModel };
        labelCount.DataBindings.Add("Text", bindingSource, "Count", true, DataSourceUpdateMode.OnPropertyChanged);
        buttonIncrement.Click += (_, __) => _viewModel.Increment();
    }
}
Enter fullscreen mode Exit fullscreen mode

🛠️ MVVM in Blazor

Blazor supports data binding and component-based UI, which makes MVVM a natural fit.

ViewModel

public class CounterViewModel : INotifyPropertyChanged
{
    private int _count;
    public int Count
    {
        get => _count;
        set { _count = value; OnPropertyChanged(); }
    }

    public void Increment() => Count++;

    public event PropertyChangedEventHandler? PropertyChanged;
    private void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
Enter fullscreen mode Exit fullscreen mode

Razor Component

@inject CounterViewModel VM

<h3>Count: @VM.Count</h3>
<button @onclick="VM.Increment">Increment</button>
Enter fullscreen mode Exit fullscreen mode

Add CounterViewModel as a scoped service in Program.cs:

builder.Services.AddScoped<CounterViewModel>();
Enter fullscreen mode Exit fullscreen mode

✅ CommunityToolkit.Mvvm absolutely works with Blazor!

Even though it’s more commonly used in WPF, MAUI, or WinUI, it works perfectly in Blazor too. You can use:

  • ObservableObject
  • ObservableProperty
  • RelayCommand

and even source generators like [RelayCommand] or [ObservableProperty]

✅ Example: Blazor + CommunityToolkit.Mvvm

  1. Install the NuGet Package
dotnet add package CommunityToolkit.Mvvm
Enter fullscreen mode Exit fullscreen mode
  1. Create a ViewModel
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class CounterViewModel : ObservableObject
{
    [ObservableProperty]
    private int count;

    [RelayCommand]
    private void Increment() => Count++;
}
Enter fullscreen mode Exit fullscreen mode
  1. Use it in a Blazor Component
@page "/counter"
@inject CounterViewModel ViewModel

<h3>Counter</h3>

<p>Current count: @ViewModel.Count</p>
<button class="btn btn-primary" @onclick="ViewModel.IncrementCommand.Execute">Increment</button>
Enter fullscreen mode Exit fullscreen mode

📄 Blazor + CommunityToolkit.Mvvm Login Example

Let’s build a more advanced login form in Blazor Server using CommunityToolkit.Mvvm, validation, and clean MVVM separation

LoginViewModel.cs

/// <summary>
/// ViewModel for the login form. Uses CommunityToolkit.Mvvm to expose observable properties and commands.
/// Inherits from ObservableValidator to enable DataAnnotations validation.
/// </summary>
public partial class LoginViewModel : ObservableValidator
{
    [ObservableProperty]
    [Required(ErrorMessage = "Username is required")]
    private string username = string.Empty;

    [ObservableProperty]
    [Required(ErrorMessage = "Password is required")]
    private string password = string.Empty;

    [ObservableProperty]
    private string message;

    /// <summary>
    /// Command bound to the login button. Validates the form and performs mock login logic.
    /// </summary>
    [RelayCommand]
    private void Login()
    {
        ValidateAllProperties();

        if (HasErrors)
        {
            Message = "Please correct the errors above.";
            return;
        }

        if (Username == "admin" && Password == "1234")
        {
            Message = "Login successful!";
        }
        else
        {
            Message = "Invalid credentials.";
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

🧩 Register ViewModel in Program.cs

builder.Services.AddScoped<LoginViewModel>();
Enter fullscreen mode Exit fullscreen mode

🖼 Login.razor

@page "/login"
@inject LoginViewModel Vm
@implements IDisposable

<h3>Login</h3>

<EditForm Model="Vm" OnValidSubmit="Vm.LoginCommand.Execute">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="mb-3">
        <label>Username</label>
        <InputText class="form-control" @bind-Value="Vm.Username" />
        <ValidationMessage For="@(() => Vm.Username)" />
    </div>

    <div class="mb-3">
        <label>Password</label>
        <InputText class="form-control" type="password" @bind-Value="Vm.Password" />
        <ValidationMessage For="@(() => Vm.Password)" />
    </div>

    <button type="submit" class="btn btn-primary">Login</button>
</EditForm>

@if (!string.IsNullOrWhiteSpace(Vm.Message))
{
    <div class="alert alert-info mt-3">@Vm.Message</div>
}

@code {
    protected override void OnInitialized()
    {
        Vm.PropertyChanged += OnViewModelPropertyChanged;
    }

    private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        InvokeAsync(StateHasChanged);
    }

    public void Dispose()
    {
        Vm.PropertyChanged -= OnViewModelPropertyChanged;
    }
}

Enter fullscreen mode Exit fullscreen mode

Image description

🪨 The Real Benefits

  • Testability: ViewModels can be unit tested without the UI.
  • Reusability: Business logic and UI state live outside the View.
  • Maintainability: Smaller, cleaner Views. Focused ViewModels.
  • Scalability: Especially in component-based systems like Blazor.

📚 References

John Gossman. Introduction to Model-View-ViewModel pattern for WPF

Microsoft Docs. Data binding overview (WPF)

Microsoft Docs. Commanding Overview (WPF)

Steve Sanderson. Blazor: Reusable web UI with C#

Microsoft Docs. Data Binding in Windows Forms

🙏 Special Thanks

A heartfelt thank you to my former colleague Andreas Meyer for his invaluable guidance and patience. His deep knowledge of WPF and MVVM helped me move beyond the basics and truly understand how powerful and elegant this pattern can be when applied correctly.

✅ Final Thoughts

MVVM is alive and well in 2025 — not because it’s trendy, but because it still solves real problems. Whether you're building a Blazor dashboard, maintaining a WinForms enterprise app, or architecting a WPF monster, MVVM keeps your code testable, modular, and clean.

So next time someone tells you “MVVM is just for WPF,” remind them:

"MVVM is a pattern, not a prison."

Top comments (4)

Collapse
 
tim_tsamis profile image
Timoleon Tsamis

It’s inspiring to see the relevance of MVVM even in modern frameworks like Blazor and WinForms. The clear separation of concerns and testability it offers are timeless benefits. I particularly appreciated the point about adapting MVVM patterns to work seamlessly in web and desktop applications.

Collapse
 
stevsharp profile image
Spyros Ponaris

Glad you like it.. appreciate it.

Collapse
 
chami profile image
Mohammed Chami

I used both the WinForms framework and WPF. Ultimately, I ended up with Avalonia UI, which is 99% WPF but adds cross-platform capabilities to it.
For Blazor, when I wanted to try it, I wanted to use MVVM as I am already comfortable with it, I did a quick search online found this:

"The MVVM Pattern can be implemented in Blazor, though it's not as straightforward as in WPF. You can create ViewModels as separate classes and inject them into components; however, Blazor's component model often makes this approach feel less natural than in desktop applications."

Also, the tutorials that I found are most in not all of them using the Component-Based Architecture.

Collapse
 
stevsharp profile image
Spyros Ponaris

The main point of the article isn't to argue which platform offers better support for MVVM, but rather to highlight how design patterns , like MVVM—can help us write cleaner, more maintainable code regardless of the platform.

I’ve worked with WinForms and WPF, and eventually transitioned to Avalonia UI, which mirrors WPF in many ways but brings cross-platform support. When exploring Blazor, I naturally looked for a way to apply MVVM, since I was already comfortable with it.

While it’s true that MVVM isn’t as seamless in Blazor as in WPF, it’s still entirely possible. You can separate logic into ViewModels and use dependency injection—though most Blazor tutorials lean toward component-based patterns instead.

What surprised me the most, however, was how many experienced developers still seem unfamiliar with the basics of dependency injection or dismiss MVVM outside of desktop apps. Design patterns aren’t tied to frameworks , they’re tools we can use to improve code quality across the board.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.