StackPractices
intermediate By Mathias Paulenko

MVC Pattern: Model, View, Controller Explained

Separate an application into Model, View, and Controller layers. When MVC pays off, when it's over-engineering, and how the pieces interact.

Overview

A new team member asks where the user-validation logic goes and the honest answer is “it’s in three places.” MVC exists to give every piece of code exactly one home: the Model owns data and business rules, the View owns presentation, and the Controller owns the translation between user input and model operations. It’s the organizing principle behind Django, Rails, ASP.NET MVC, and every server-side web framework that followed — and the same three-way split shows up in frontend frameworks, just with the pieces renamed.

When to Use

Use the MVC pattern when:

  • Data, presentation, and input handling keep getting tangled in the same files
  • Several views show the same state (an admin dashboard and a public page reading one model)
  • Several developers work in parallel and layer boundaries let them avoid merge hell
  • You need the same model behind different interfaces (web UI, CLI, API

Skip it when:

  • The whole app is a hundred lines: three layers of ceremony for a script buys nothing
  • You’re inside a frontend framework that already imposes its own flavor (Vue/Angular lean toward MVVM, React toward unidirectional flow)
  • The “controller” is just a router: a REST API that validates, calls a service, and serializes barely needs a formal MVC split

Solution

Python

class UserModel:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

class UserView:
    def display(self, user: UserModel):
        print(f"User: {user.name} ({user.email})")

class UserController:
    def __init__(self, model: UserModel, view: UserView):
        self.model = model
        self.view = view

    def update_name(self, name: str):
        self.model.name = name
        self.view.display(self.model)

# Usage
controller = UserController(UserModel("Alice", "alice@example.com"), UserView())
controller.update_name("Alicia")  # -> User: Alicia (alice@example.com)

JavaScript

class UserModel {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

class UserView {
  display(user) {
    console.log(`User: ${user.name} (${user.email})`);
  }
}

class UserController {
  constructor(model, view) {
    this.model = model;
    this.view = view;
  }

  updateName(name) {
    this.model.name = name;
    this.view.display(this.model);
  }
}

const controller = new UserController(
  new UserModel("Alice", "alice@example.com"),
  new UserView()
);
controller.updateName("Alicia");

Java

class UserModel {
    String name;
    String email;
    UserModel(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

class UserView {
    void display(UserModel user) {
        System.out.println("User: " + user.name + " (" + user.email + ")");
    }
}

class UserController {
    private final UserModel model;
    private final UserView view;
    UserController(UserModel model, UserView view) {
        this.model = model;
        this.view = view;
    }
    void updateName(String name) {
        model.name = name;
        view.display(model);
    }
}

Explanation

The flow is always the same shape:

flowchart diagram: User
  • Model: owns the data and the rules about it: validation, transitions, persistence. It knows nothing about how it’s shown.
  • View: renders model state. It reads the model (or an observer notifies it) but never mutates it.
  • Controller: the only piece allowed to touch both sides. It parses input, calls model operations, and picks what the view shows next.

In web frameworks the names blur: Django calls its view layer “template” and the controller “view”; Rails keeps the names straight. What survives in all of them is the rule of three: one place for data, one for rendering, one for orchestration. The observer pattern is how real implementations let the model notify views without knowing they exist.

Variants

VariantCouplingWhen it fits
Classic MVCView reads Model directlyServer-rendered web apps, desktop UIs
MVPView is dumb; Presenter mediates everythingHighly testable UI layers
MVVMViewModel binds to View automaticallyFrontend frameworks with two-way binding

For frontend-specific trade-offs, see MVC in frontend applications.

Advanced Usage

Companion code: the mvc-pattern companion repo has all four implementations ready to run.

A dashboard that actually updates itself

The toy examples above print once and exit. Real MVC matters when the view refreshes without the controller polling. This TypeScript version uses subscriptions so the model pushing changes triggers the render:

interface Metric {
  name: string;
  value: number;
}

// Model: data + subscription, no idea who renders it
class DashboardModel {
  private metrics: Metric[] = [];
  private subscribers: (() => void)[] = [];

  addMetric(metric: Metric) {
    this.metrics.push(metric);
    this.notify();
  }

  getMetrics(): Metric[] {
    return [...this.metrics];
  }

  subscribe(cb: () => void) {
    this.subscribers.push(cb);
  }

  private notify() {
    this.subscribers.forEach((cb) => cb());
  }
}

// View: renders whatever the model hands over
class DashboardView {
  constructor(private container: HTMLElement) {}

  render(metrics: Metric[]) {
    this.container.innerHTML = metrics
      .map((m) => `<div class="ql"><h3>${m.name}</h3><span class="value">${m.value}</span></div>`)
      .join("");
  }
}

// Controller: fetches into the model; the subscription re-renders
class DashboardController {
  constructor(private model: DashboardModel, private view: DashboardView) {
    this.model.subscribe(() => this.view.render(this.model.getMetrics()));
  }

  async loadMetrics() {
    const data: Metric[] = await fetch("/api/metrics").then((r) => r.json());
    data.forEach((m) => this.model.addMetric(m));
  }
}

The earlier version of this example shipped a subtle bug: the map() callback was passed three extra template strings as ignored arguments, so every metric rendered an unclosed, empty <div>. Worth remembering: map takes exactly one callback argument.

PatternView knows Model?CouplingData binding
MVCYes (directly)HighManual
MVPNo (via Presenter)MediumManual
MVVMNo (via ViewModel)LowAutomatic

What Works

  1. Keep the model ignorant of views; it notifies, it doesn’t know
  2. Views read the model, they never write to it; mutations go through the controller
  3. Thin controllers: orchestration in the controller, business rules in the model
  4. Use the observer pattern for model→view updates instead of direct calls
  5. If you find yourself explaining why the view needs a model field, you’ve probably made the wrong boundary: that logic belongs one layer down

Common Mistakes

  1. Fat controllers: validation, calculations, and workflows living in controllers instead of the model: the classic Rails/Django rot
  2. Views that mutate: a template that writes back to the model is a hidden controller nobody reviews
  3. MVC-ing a script: three folders for code that fits in one file adds indirection without payoff
  4. Treating the HTTP layer as the controller: in web frameworks the “controller” is usually a thin adapter; the real coordination belongs one level deeper in services/use-cases
  5. Expecting a pattern to fix coupling: MVC defines the boxes, not what goes in them; a badly factored model is still badly factored

Further Reading

Frequently Asked Questions

Is MVC still relevant with modern frontend frameworks?

Yes — the three-way split survives even when the names change. React's unidirectional flow separates state from rendering the same way MVC separates model from view. Angular uses MVVM, which is a direct descendant. The vocabulary changed; the separation didn't.

What's the real difference between MVC and MVVM?

The ViewModel. MVVM replaces the controller with a ViewModel the view binds to automatically, so you don't call render(), the binding layer keeps the view in sync. MVVM fits frameworks with reactive binding (Vue, Angular); MVC fits server-side rendering and frameworks without binding (Django, Rails, most backends).

Can I use MVC in a serverless architecture?

Yes, loosely: the controller becomes your Lambda handler or API Gateway mapping, the model is your data layer plus domain logic, and the view is the serialized response or rendered template. The pattern holds; the "controller" just got smaller.

How do I stop controllers from getting fat?

Move logic down: anything that survives if the UI changed is model-side, not controller-side. Validation of business rules, calculations, workflow steps — if the CLI version would need it too, it's not controller code. A useful test: can you swap the HTTP layer for a cron job without touching the controller? If not, some of its code belongs in the model.

When is MVC over-engineering?

When the app is small enough that the split costs more than it saves: a single script, a thin CRUD API where every endpoint does one query, a prototype you're about to throw away. Separation earns its keep when pieces actually change independently; if they change together, it's ceremony.