Intro

MVC and MVVM are UI architecture patterns that separate data, presentation, and interaction logic to improve testability and maintainability. Both solve the same problem — keeping UI code from tangling with business logic — but they differ in how the View and the logic layer communicate. MVC uses a Controller that handles requests and selects views; MVVM uses a ViewModel that exposes observable state the View binds to directly. In a 120-controller ASP.NET Core API, enforcing thin MVC controllers (under 20 lines per action) that delegate to domain services reduced the average time to add a new endpoint from 2 hours to 30 minutes because developers no longer had to untangle business logic embedded in controller actions.

MVC — Model-View-Controller

Roles:

  • Model — domain data and business rules. Independent of UI.
  • View — renders the UI from the model/view-model data.
  • Controller — handles incoming requests, invokes application logic, selects the response view.

Flow: Request → Controller → Model (read/write) → Controller selects View → View renders with data.

In ASP.NET Core MVC:

public sealed class ProductsController : Controller
{
    private readonly IProductService _service;
 
    public ProductsController(IProductService service) => _service = service;
 
    public async Task<IActionResult> Details(int id)
    {
        var product = await _service.GetByIdAsync(id);
        if (product is null) return NotFound();
 
        var vm = new ProductDetailsVm(product.Id, product.Name, product.Price);
        return View(vm); // Controller selects the view
    }
}

The Controller is the entry point. It is thin — it delegates to services and passes data to the view. Business logic lives in the service/domain layer, not the controller.

MVVM — Model-View-ViewModel

Roles:

  • Model — domain data and business rules. Same as MVC.
  • View — UI that binds to ViewModel properties and commands. No code-behind logic.
  • ViewModel — exposes observable state (INotifyPropertyChanged) and commands (ICommand). The View binds to it; the ViewModel does not reference the View.

Flow: User action → View binding → ViewModel command → Model update → ViewModel notifies View via binding.

In WPF (.NET):

public class ProductDetailsViewModel : INotifyPropertyChanged
{
    private string _name = string.Empty;
    public string Name
    {
        get => _name;
        set { _name = value; OnPropertyChanged(); }
    }
 
    public ICommand LoadCommand { get; }
 
    public ProductDetailsViewModel(IProductService service)
    {
        LoadCommand = new AsyncRelayCommand(async () =>
        {
            var product = await service.GetByIdAsync(42);
            Name = product.Name; // binding updates the View automatically
        });
    }
 
    public event PropertyChangedEventHandler? PropertyChanged;
    private void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

The View (XAML) binds to Name and LoadCommand — no code-behind. The ViewModel is fully testable without a UI.

Comparison

AspectMVCMVVM
CommunicationController pulls data, pushes to ViewView binds to ViewModel; ViewModel notifies View
View couplingController selects View (loose)View binds directly to ViewModel (tight binding)
TestabilityController testable; View requires integration testViewModel fully unit-testable without UI
Data bindingManual (controller passes model to view)Automatic (two-way binding via INotifyPropertyChanged)
Primary platformServer-side web (ASP.NET Core MVC)Desktop/mobile UI (WPF, MAUI, Blazor)
BoilerplateLowHigher (binding infrastructure, ICommand)

Decision Rule

Use MVC for server-rendered web applications (ASP.NET Core MVC, Razor Pages). The request/response model maps naturally to Controller → View. No persistent UI state between requests.

Use MVVM for stateful UI applications (WPF, MAUI, Blazor) where the UI reacts to state changes in real time. Two-way data binding eliminates manual UI update code and makes ViewModels independently testable.

When to switch: if you find yourself writing significant code-behind in a WPF/MAUI app to update UI elements manually, switch to MVVM. If your ASP.NET Core controllers are growing large with UI logic, consider Razor Pages (a simplified MVC variant) or move logic to services.

The Wider Family: MVP and MVU

MVC and MVVM are two points in a family of UI separation patterns:

  • MVP (Model-View-Presenter) — like MVC, but the Presenter holds all presentation logic and updates a passive View through an interface (IView), which makes the View trivially mockable. Common in classic WinForms and Android; the View is dumber than in MVVM (no data binding) and the Presenter pushes values explicitly.
  • MVU / unidirectional flow (Model-View-Update, a.k.a. Elm/Flux/Redux) — state flows one way: an immutable model renders a view, the view emits messages, an update function produces a new model, repeat. There’s no two-way binding to reason about, so state is predictable and time-travel-debuggable. This is the model behind Elm, Redux/React, and increasingly Blazor components and .NET MAUI’s Fabulous/Comet. (So Blazor is better described as component/MVU than classic MVVM, despite supporting binding.)

The trend in modern UI is toward unidirectional data flow because two-way binding, while convenient, makes “who changed this state?” hard to trace in complex screens.

Pitfalls

Massive Controllers (MVC)

What goes wrong: the Controller accumulates business logic — validation, orchestration, data transformation — instead of delegating to services. A 500-line controller is a sign that the Controller is doing the Model’s job. In one production codebase, a PaymentsController grew to 1,200 lines with inline Stripe API calls, retry logic, and email sending — a bug in the retry logic caused double charges for 340 customers over a weekend, and it took 3 developers 2 days to trace the issue because the payment flow was spread across 8 controller actions instead of a single PaymentService.

Why it happens: it is the path of least resistance. The controller already has access to the request, the response, and the DI container.

Mitigation: keep controllers thin. A controller action should: validate input, call one service method, map the result to a view model, and return a response. All business logic belongs in the service or domain layer.

Fat ViewModels (MVVM)

What goes wrong: the ViewModel accumulates business logic, data access, and navigation logic. It becomes a second controller.

Why it happens: the ViewModel is the natural place to put ‘everything the View needs,’ and that scope creeps.

Mitigation: ViewModels should expose observable state and commands. Business logic belongs in services injected into the ViewModel. Navigation belongs in a navigation service, not the ViewModel itself.

Questions

References