Model-View-Controller (MVC) architecture
Open in the interactive app: practice, quiz, mock interview →In one minute
- Model-View-Controller splits an interactive program into three roles: the model holds state and the rules that change it, the view turns state into something a person sees, and the controller turns input (clicks, keystrokes, HTTP requests) into operations on the model.
- The one rule that matters most: the model knows nothing about views or controllers. It can be tested, reused behind a CLI, a web page and an API, and changed without touching presentation.
- There are two quite different "MVCs". Desktop and browser MVC keeps a live model that notifies its views through Observer. Server-side "web MVC" (Rails, Django, Spring MVC) has no live notification: a request comes in, the controller loads the model, picks a view and renders it once.
- MVP, MVVM and unidirectional data flow (Elm, Redux) are descendants that move the same three responsibilities around. Learn the responsibilities, not the letters.
- The common failure is the fat controller: business rules written inside request handlers or UI event callbacks, where nothing else can reuse or test them. Agent-generated code drifts there by default.
How it works
The problem
A to-do app starts with everything in the click handler: read the text box, reject blanks, push to an array, rebuild the list, update the "3 left" counter. Then come a keyboard shortcut, a mobile layout and a public API, and each copies the validation and the counter logic. Within a month the API accepts blank titles the UI rejects, and the counter is wrong after bulk delete.
Three concerns with different rates of change are tangled. The rules of a valid to-do change rarely, the look changes every sprint, and the ways people interact keep multiplying. MVC gives each concern a home.
The three roles
The model is the state plus the operations allowed on it: add(title), toggle(id), remove(id), each enforcing invariants such as "titles are not blank" and "ids are unique". It exposes queries (items()) and a way to learn about changes (subscribe). It imports nothing from the UI.
The view is a function from model state (plus UI state, such as the current filter) to output: DOM nodes, an HTML string, terminal lines, a JSON document. A good view holds no business logic. If a view decides whether an order is refundable, that decision will be missing from the API.
The controller receives raw input, interprets it, and calls the model. It parses "done 3" into toggle(3), maps a form post to add(title), and decides which screen comes next. It also owns presentation state that is not part of the domain: which filter is selected, which row is focused, what error message to show.
Classic MVC: views observe the model
Trygve Reenskaug described MVC in 1979 while working on Smalltalk at Xerox PARC, and Smalltalk-80 shipped it. The model kept a list of dependents; any change called self changed: #aspect, which sent update: #aspect to every dependent view. Each view then pulled what it needed from the model and redrew. This is the Observer pattern doing the wiring, and it is what lets two views of the same model (a chart and a table of the same spreadsheet range) stay consistent without knowing about each other.
The data flow is a loop: input goes to the controller, the controller mutates the model, the model notifies, views re-render. Controllers never draw, and views never mutate.
Web MVC: a request is the loop
Rails, Django and Spring MVC adopted the names for request/response servers, where there is no long-lived view to notify. The router maps POST /todos to TodosController#create; the action builds a model object, asks it to validate and save, then either redirects or renders a template with the model's data. The "notification" is the next HTTP request. Django's documentation is candid that its "views" are what other frameworks call controllers and that its templates are the views, which is why it is often described as MTV.
The lesson for web MVC is the same: keep validation and business rules in the model or in a service layer under it, keep controllers thin (parse, call, choose a response), and keep templates free of queries and decisions.
The descendants
- MVP (Model-View-Presenter) makes the view passive: it forwards events to a presenter and exposes setters such as
showItems(list). All logic sits in the presenter, which can be tested without a UI toolkit. - MVVM (Model-View-ViewModel), introduced by Microsoft for WPF in 2005, adds a view model that exposes display-ready properties and commands; a binding engine keeps the view in sync automatically.
- Unidirectional flow (Flux, Redux, the Elm architecture) replaces many small model mutations with one rule: an action goes into a pure
update(state, action)function, which returns new state, and the view is re-rendered from it. It is MVC with the controller split into "turn input into an action" and "reduce the action into state", and with observation reduced to one subscription.
Reviewing agent-written code
Ask an agent to "add a bulk complete button" and it will usually write the loop, the validation and the counter update inside the button handler, because that is where the request lands. It will also happily call the database from a template helper. Steer it with three review questions. Is every rule enforced in the model, so the API and the UI cannot disagree? Does the view only read state? Does the controller only parse input, call the model and choose a response? A useful instruction for an agent is: "new interaction = new controller code that calls existing model operations; new rule = model change plus a model test".
The same shape elsewhere
MVC is a special case of a more general shape: one source of truth, many derived projections, and changes that flow in one direction. Kubernetes uses the same vocabulary for the same idea at cluster scale: desired state is the model (objects stored in etcd), controllers watch it and act to reconcile the world, and kubectl get and dashboards are views. A database with materialized views is another instance, as is CQRS, which separates the write model from read models. In each case, bugs appear when a projection is written to directly or when two components both believe they own the truth.
Down the stack
Layer 1The contract
The model promises that every public operation either leaves state valid and notifies observers, or fails with an error and leaves state unchanged with no notification. Notifications arrive after the state is consistent, never halfway through an operation, so a view that reads the model during update sees a coherent snapshot. A view promises to be a function of its inputs: the same model state and UI state give the same output. The controller promises that every input either becomes a model operation or produces a visible error. These are testable: count notifications, compare renders, and feed bad input.
Layer 2The mechanism
In interactive MVC the mechanism is a subscriber list on the model and a dirty flag in the UI. Smalltalk-80 kept a plain object's dependents in a class-level dictionary keyed by the object, while the Model class held its own dependents collection. Modern UI libraries go further: React's setState marks a component dirty and schedules a re-render, and since React 18 all updates triggered in the same tick are batched into one render. That batching is the in-process version of a model's batch operation: three changes, one notification, one redraw. Virtual DOM diffing then turns "re-render everything" into a minimal set of DOM mutations.
In web MVC the mechanism is a dispatch table and a template engine. Rails' router resolves POST /todos to a controller class and action; the action sets instance variables that ActionView exposes to an ERB template. Go's html/template parses templates once into a tree, then on each request walks it with the data and applies context-aware escaping (HTML body, attribute, URL, JavaScript), a security property you keep only if data flows into views through template parameters.
Layer 3The machine
On a 60 Hz display a frame is 16.7 ms, and the browser needs part of that for style, layout and paint. A model that notifies on every one of 500 changes in a bulk action can trigger 500 re-renders, and if each view reads layout properties (offsetHeight) after writing to the DOM, every read forces a synchronous layout. That is layout thrashing, and it turns a 2 ms update into 200 ms of jank. Batching notifications and having views read state, then write the DOM once, keeps the work to one layout per frame.
On the server the cost hides in the view. A template that walks posts and prints post.author.name makes a lazy-loading ORM issue one query per row: 100 rows at 0.5 ms per round trip is 50 ms added to a request that should take 5. That is the N+1 query problem, and it exists because the view reached into the model's persistence layer. The fix (includes(:author) in Rails, select_related in Django) belongs in the controller or model, which is the MVC rule enforcing itself in performance terms. Observer dispatch itself is cheap: an indirect call costs a few nanoseconds, so notification cost is dominated by what observers do, never by the loop.
Why it's built this way
- Business rules, presentation and input methods change at different rates and are owned by different people, so they belong in different modules.
- Rules must hold for every entry point, so they live in the one place every entry point passes through: the model.
- A model that imported its views could not be reused or tested headless, so the dependency points one way and change is announced through Observer.
- Redrawing is expensive relative to state changes, so notifications are coalesced and views re-render from state, not from a stream of deltas.
- Servers have no persistent screen to update, so web MVC collapses the loop into one request, keeping the separation but dropping the observation.
Diagram
sequenceDiagram participant U as User participant C as Controller participant M as Model participant V as View U->>C: "clear" (remove done items) C->>M: batch(remove 2, remove 5) M->>M: validate, update state M-->>C: changed (one notification) C->>V: render(items, filter) V->>M: items() V-->>U: new screen U->>C: "done 9" C->>M: toggle(9) M-->>C: error "no item: 9" C->>V: render with error line V-->>U: screen with error
Worked example
A logistics company has an internal dispatch board: a model of 2,000 shipments, a table view, a map view and a counters strip. Dispatchers select 300 shipments and click "assign to driver". The original code updated each shipment through the model's assign method, and every call notified all three views. That was 900 renders, each one reading the full model: about 300 x 3 x 2,000 = 1.8 million row visits, and the browser froze for 4.2 seconds.
The team made two changes. assign_many(ids, driver) runs inside model.batch, so the model sends one notification after all 300 updates, and the table view renders only the visible 40 rows. The bulk action now costs about 4,040 row visits and completes in 35 ms. assign_many was then exposed through the public API with no new validation code, because the rules (driver on shift, shipment not delivered) were already in the model.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| No separation (logic in handlers) | Fastest to write; one file | Rules duplicated per entry point; untestable without UI | Prototypes and throwaway scripts |
| Classic MVC with Observer | Many views stay in sync; model reusable | Notification storms; update order subtleties | Desktop and rich client apps with several views of one model |
| Web MVC (Rails, Django) | Simple request loop; huge ecosystem | Fat controllers and logic in templates if undisciplined | Server-rendered apps and CRUD APIs |
| MVP / MVVM | View is passive and logic is testable; binding removes boilerplate | Extra class per screen; binding magic hard to debug | Large UI codebases, mobile, WPF and Android |
| Unidirectional (Elm, Redux) | One state tree; every change is an action you can log and replay | Boilerplate; everything global unless split carefully | Complex client state, time-travel debugging, many contributors |
Pitfalls in production
- Fat controllers: validation and pricing logic in request handlers, so the batch job and the API disagree. Fix: move rules into the model or a service the controllers call, and test them there.
- Views that query: templates or components that lazy-load associations and cause N+1 queries. Fix: load everything the view needs in the controller, and make lazy loading raise in tests (Rails
strict_loading). - Notification storms: one user action fires hundreds of change events and redraws. Fix: batch mutations and notify once, or schedule rendering per frame.
- Re-entrant updates: a view's
updatehandler mutates the model, which notifies again mid-delivery. Fix: views only read; send user intents through the controller. - UI state stored in the model: the selected tab or the sort order saved alongside business data, which then leaks into the API and other users' sessions. Fix: keep presentation state in the controller or view model.
Leadership lens
- Draw the dependency arrows in design review: the model must not import UI or framework code. This is the cheapest architectural rule to check automatically, with an import linter in CI.
- Count the entry points. If a rule is enforced in the web controller but the app also has an API, a CLI and a batch job, the rule is enforced in one place out of four. Put it in the model.
- Pick one variant per codebase and write it down, and assert render counts and query counts per request in CI; both regress silently.
- When delegating UI work to agents, specify which layer each change belongs in. "Add a button" without that instruction produces business logic in a click handler.
Check yourself
Why does classic MVC need Observer, while Rails does not?
Show answer
A desktop view lives across many changes and must learn when to redraw, so the model announces changes. A Rails view is rendered once per request from freshly loaded data, so the next request plays the role of the notification.
A to-do app stores "current filter = active" in the model. What goes wrong?
Show answer
The filter is presentation state for one screen; in the model it is shared by every view and user of that model, leaks into persistence and APIs, and changing it triggers model notifications that have nothing to do with the domain.
A template prints
order.customer.namefor 200 orders and the page takes 120 ms. What is the likely cause and which layer should fix it?Show answer
N+1 lazy loads, one query per order. The controller (or a model query method) should eager-load customers so the view only reads in-memory data.
How is the Elm architecture still MVC?
Show answer
The model is the state,
viewis a pure function of it, and the controller role is split between turning input into messages and theupdatefunction that applies them. Changes flow one way, as in MVC.
Watch
- MVC Explained in 4 Minutes (Web Dev Simplified): a quick tour of the three roles in a web application.
- Hacker Way: Rethinking Web App Development at Facebook (Meta Developers): the talk that introduced Flux and React's unidirectional answer to observer-heavy MVC.
Free resources
- GUI Architectures (Martin Fowler): the history of Smalltalk MVC, MVP and their variants, with a worked example.
- Presentation Model (Martin Fowler): the idea that became MVVM, explained with its trade-offs.
- Model-view-controller (Wikipedia): background, the 1979 origin at Xerox PARC, and the main variants.
- Django FAQ: Django appears to be a MVC framework: Django's own account of how its views and templates map to MVC.
- Action Controller Overview (Rails Guides): what a thin web controller does in a mainstream framework.
- The Elm Architecture (Elm guide): model, view and update as three pure pieces with one-way data flow.