Skip to main content

Model-View-Controller

complexitypopularity

Separate what an application knows (Model) from how it is shown (View) from how input is handled (Controller), so a change to the display never has to touch business logic.

The problem

A thermostat widget that reads input, applies temperature rules, and draws pixels - all in one class - works fine until any one concern needs to change independently of the others. Add a phone-app view, and it duplicates the range-clamping logic. Change the clamp range, and you have to remember every place that copied it, including the code that drew the screen.

The tell is a class that has to change for unrelated reasons: a new UI framework, a new temperature rule, a new physical button layout. Each of those is legitimate; none of them should require touching the same file as the other two.

The solution

Split the class into three roles with one job each. The Model owns state and the rules for changing it, and answers to nobody about how it is displayed. The View reads the Model and renders it, and never writes to it. The Controller is the seam between the outside world and the Model: it takes a raw input event and turns it into a call the Model understands, then lets the View know to refresh.

The View commonly listens for Model changes (often via Observer) so it redraws itself rather than being told to by the Controller directly - either wiring keeps the same separation.

PhysicalButtonThermostatControllerThermostatModelThermostatDisplayonButtonPress(+1)1setTarget(targetTemp + 1)2validateRange(newTarget)3notifyChanged()4read currentTemp, targetTemp5render()6onButtonPress(-1)7setTarget(targetTemp - 1)8
  1. 1Someone taps the "warmer" button. The button knows only that it should call the controller - nothing about temperatures.
  2. 2The controller translates a raw input event into a domain operation on the model.
  3. 3The model enforces its own rules (say, a 10-32C range) - the controller never duplicates that logic.
  4. 4The model announces that its state changed, without knowing or caring who is listening.
  5. 5The view pulls the fresh values it needs directly from the model.
  6. 6The view redraws using only what it just read. It never mutates the model.
  7. 7A "cooler" press arrives later. Same controller method, same path, opposite delta.
  8. 8Business rules and validation live in exactly one place regardless of which button triggered them.

Structure

Notice ThermostatModel has no arrow pointing outward in this diagram - nothing points from Model to Controller or View. Everything flows toward the Model, never away from it.

ThermostatModelcurrentTemptargetTempsetTarget(temp)onChange: list of callbacksMODELThermostatDisplayrender(model)VIEWThermostatControllermodel: ThermostatModelview: ThermostatDisplayonButtonPress(delta)CONTROLLERPhysicalButtononPress: callbackINPUT
uses

Code

Same example three ways: a physical warmer/cooler button driving a thermostat.

// Input handling, business rules, and rendering all fused into one class.
class ThermostatWidget is
field currentTemp
field targetTemp
 
method onWarmerButtonPress() is
targetTemp = targetTemp + 1
if targetTemp > 32 then
targetTemp = 32
// Rendering logic lives right here too:
screen.clear()
screen.drawNumber(targetTemp)
screen.drawLabel("target")
// Swap the display library and this whole method needs a rewrite.
// Three roles, three files, three reasons to change - never the same reason twice.
class ThermostatModel {
setTarget(temp) {
this.targetTemp = Math.max(10, Math.min(32, temp));
this.listeners.forEach(l => l.onModelChanged());
}
}
class ThermostatDisplay {
onModelChanged() { this.render(); } // reads the model, never writes it
}
class ThermostatController {
onButtonPress(delta) {
this.model.setTarget(this.model.targetTemp + delta); // translate input, nothing else
}
}
// Swap ThermostatDisplay for a web UI: Model and Controller do not change a line.
// Model: state and the rules for changing it. Knows nothing about display or input.
class ThermostatModel is
field currentTemp
field targetTemp
field listeners: list
 
method setTarget(temp) is
targetTemp = clamp(temp, 10, 32)
notifyChanged()
 
method subscribe(listener) is
listeners.add(listener)
 
method notifyChanged() is
foreach (listener in listeners) do
listener.onModelChanged()
 
// View: reads the model and draws it. Never mutates it.
class ThermostatDisplay is
field model: ThermostatModel
 
constructor ThermostatDisplay(model) is
this.model = model
model.subscribe(this)
 
method onModelChanged() is
render()
 
method render() is
screen.clear()
screen.drawNumber(model.targetTemp)
screen.drawLabel("target, currently " + model.currentTemp)
 
// Controller: translates raw input into a Model operation.
class ThermostatController is
field model: ThermostatModel
 
constructor ThermostatController(model) is
this.model = model
 
method onButtonPress(delta) is
model.setTarget(model.targetTemp + delta)
 
// Wiring: input source only knows about the controller.
class PhysicalButton is
field onPress: callback
 
method press() is
onPress()
 
// Assembly happens once, outside all three roles.
model = new ThermostatModel()
view = new ThermostatDisplay(model)
controller = new ThermostatController(model)
warmerButton = new PhysicalButton(delta => controller.onButtonPress(delta))

When to use it

  • The same underlying state needs more than one presentation (a physical display and a phone app, a web page and an API response) and duplicating rules across them is already a problem.
  • Input handling, business rules, and rendering currently live in one class and change for three different reasons, tripping over each other in code review.

Pitfalls

  • The fat controller. Business rules that migrate into the Controller "because that's where the input arrives" recreate the exact coupling MVC exists to remove. Rules belong in the Model; the Controller only translates.
  • The talkative view. A View that reaches back and mutates the Model directly (beyond simple, well-understood bindings) blurs the Controller's role and makes the write path hard to trace.
  • Assuming one true MVC. Web MVC, desktop MVC, and the original Smalltalk MVC all draw the lines slightly differently (who owns the Observer wiring, whether the View can write). Match the flavor your framework actually uses instead of arguing textbook purity.

Don't confuse it with

  • Observer. Observer is a plausible implementation detail inside MVC (the View subscribing to the Model), not a substitute for it. MVC names three roles at a coarser grain than any single GoF pattern operates at.
  • Mediator. A Controller can resemble a Mediator hub, but Mediator's job is eliminating mutual dependencies among peer components in general; a Controller has one specific job - turning input into Model operations - and the Model and View are not peers negotiating with each other through it.
  • MVP and MVVM. Both are later variations that move where the "glue" logic lives (Presenter, ViewModel) and change exactly how much the View is allowed to know. They share MVC's core idea - separate state, display, and input-handling - with different wiring.

Check yourself

Question 1 of 5

Which role is allowed to know about both of the other two?