Engineering Atlas › Low-Level Design

Model-View-Controller (MVC) architecture

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

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

  1. Business rules, presentation and input methods change at different rates and are owned by different people, so they belong in different modules.
  2. Rules must hold for every entry point, so they live in the one place every entry point passes through: the model.
  3. 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.
  4. Redrawing is expensive relative to state changes, so notifications are coalesced and views re-render from state, not from a stream of deltas.
  5. 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

OptionStrengthsWeaknessesChoose when
No separation (logic in handlers)Fastest to write; one fileRules duplicated per entry point; untestable without UIPrototypes and throwaway scripts
Classic MVC with ObserverMany views stay in sync; model reusableNotification storms; update order subtletiesDesktop and rich client apps with several views of one model
Web MVC (Rails, Django)Simple request loop; huge ecosystemFat controllers and logic in templates if undisciplinedServer-rendered apps and CRUD APIs
MVP / MVVMView is passive and logic is testable; binding removes boilerplateExtra class per screen; binding magic hard to debugLarge UI codebases, mobile, WPF and Android
Unidirectional (Elm, Redux)One state tree; every change is an action you can log and replayBoilerplate; everything global unless split carefullyComplex client state, time-travel debugging, many contributors

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. A template prints order.customer.name for 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.

  4. How is the Elm architecture still MVC?

    Show answer

    The model is the state, view is a pure function of it, and the controller role is split between turning input into messages and the update function that applies them. Changes flow one way, as in MVC.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →