Design Patterns, Explained Simply · Patterns Inside Frameworks
Model-View-Controller (MVC)
Split an application into the data and its rules (model), how it is shown (view) and the code that turns a request into a response (controller), so each can change without breaking the others.
The problem
An early PHP order page did everything in one file: it read $_GET, ran SQL, calculated the total with the discount rules, and echoed HTML in between. It worked until:
- A designer changed the table markup and broke a query in the middle of it.
- The mobile app needed the same order as JSON, so the SQL and discount rules were copied into a second file, and the two copies drifted apart.
- Nobody could test the discount rules without a web server and a database.
Real-world analogy
A restaurant. The waiter takes your order and brings back the result, but doesn't cook. The kitchen knows the recipes and the rules (no peanuts in this dish, half portions for children) but never talks to customers. Plating decides how food looks: on a plate for dine-in, in a box for takeaway, from the same kitchen.
The waiter is the controller, the kitchen is the model, and plating is the view.
Without the pattern
<?php // order.php: input, data, business rules and HTML in one place
$order = $pdo->query("SELECT * FROM orders WHERE id = " . (int) $_GET['id'])->fetch();
$total = $order['subtotal'] > 10000 ? $order['subtotal'] * 0.9 : $order['subtotal']; // rule mixed with output
echo "<h1>Order {$order['id']}</h1><p>Total: " . number_format($total / 100, 2) . "</p>";
The pattern
- Model: the data and the business rules (an order's total after discounts, whether it can be cancelled), plus loading and saving it. It knows nothing about HTTP or HTML, so it can be tested on its own.
- View: presentation only. It receives data and turns it into HTML, JSON or a PDF. No queries and no business rules.
- Controller: handles input. It reads the request, asks the model to do the work, picks a view and returns the response. It should stay thin.
A web request flows router → controller → model → view → response.
MVC was described by Trygve Reenskaug at Xerox PARC in the late 1970s for Smalltalk desktop apps, where views also listened to the model for changes (Observer). Web frameworks adapted it to the request/response cycle. Related variations include MVP and MVVM, used mostly in desktop and mobile UIs.
The code
One model, two views (HTML and JSON) and a controller that picks the view from the request. The discount rule exists once, in the model.
def money(cents):
return f"${cents // 100}.{cents % 100:02d}"
class Order: # model: data + business rules
def __init__(self, order_id, subtotal_cents):
self.id = order_id
self.subtotal = subtotal_cents
def total(self):
return self.subtotal * 9 // 10 if self.subtotal > 10000 else self.subtotal # 10% off over $100
class OrderRepository: # model: loading data (a database in a real app)
def __init__(self):
self.rows = {1: Order(1, 2500), 2: Order(2, 12000)}
def find(self, order_id):
return self.rows.get(order_id)
def html_view(order): # view: presentation only
return f"<h1>Order {order.id}</h1><p>Total: {money(order.total())}</p>"
def json_view(order):
return f'{{"id":{order.id},"total_cents":{order.total()}}}'
class OrderController: # controller: request in, response out
def __init__(self, orders):
self.orders = orders
def show(self, request):
order = self.orders.find(request["id"])
if order is None:
return 404, "Not found"
view = json_view if request["accept"] == "application/json" else html_view
return 200, view(order)
controller = OrderController(OrderRepository())
print(controller.show({"id": 1, "accept": "text/html"}))
print(controller.show({"id": 2, "accept": "application/json"}))
print(controller.show({"id": 9, "accept": "text/html"}))
Output
(200, '<h1>Order 1</h1><p>Total: $25.00</p>')
(200, '{"id":2,"total_cents":10800}')
(404, 'Not found')const money = (cents: number) => `$${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
class Order { // model: data + business rules
constructor(public id: number, public subtotal: number) {}
total(): number {
return this.subtotal > 10000 ? Math.floor((this.subtotal * 9) / 10) : this.subtotal; // 10% off over $100
}
}
class OrderRepository { // model: loading data (a database in a real app)
private rows = new Map<number, Order>([[1, new Order(1, 2500)], [2, new Order(2, 12000)]]);
find(id: number): Order | undefined {
return this.rows.get(id);
}
}
const htmlView = (order: Order) => `<h1>Order ${order.id}</h1><p>Total: ${money(order.total())}</p>`; // view
const jsonView = (order: Order) => `{"id":${order.id},"total_cents":${order.total()}}`;
class OrderController { // controller: request in, response out
constructor(private orders: OrderRepository) {}
show(request: { id: number; accept: string }): [number, string] {
const order = this.orders.find(request.id);
if (!order) return [404, "Not found"];
const view = request.accept === "application/json" ? jsonView : htmlView;
return [200, view(order)];
}
}
const show = (r: [number, string]) => `(${r[0]}, '${r[1]}')`;
const controller = new OrderController(new OrderRepository());
console.log(show(controller.show({ id: 1, accept: "text/html" })));
console.log(show(controller.show({ id: 2, accept: "application/json" })));
console.log(show(controller.show({ id: 9, accept: "text/html" })));
Output
(200, '<h1>Order 1</h1><p>Total: $25.00</p>')
(200, '{"id":2,"total_cents":10800}')
(404, 'Not found')import java.util.Map;
import java.util.function.Function;
public class Main {
static String money(int cents) { return "$" + (cents / 100) + "." + String.format("%02d", cents % 100); }
record Order(int id, int subtotal) { // model: data + business rules
int total() { return subtotal > 10000 ? subtotal * 9 / 10 : subtotal; } // 10% off over $100
}
static class OrderRepository { // model: loading data (a database in a real app)
private final Map<Integer, Order> rows = Map.of(1, new Order(1, 2500), 2, new Order(2, 12000));
Order find(int id) { return rows.get(id); }
}
static String htmlView(Order o) { return "<h1>Order " + o.id() + "</h1><p>Total: " + money(o.total()) + "</p>"; } // view
static String jsonView(Order o) { return "{\"id\":" + o.id() + ",\"total_cents\":" + o.total() + "}"; }
static class OrderController { // controller: request in, response out
private final OrderRepository orders;
OrderController(OrderRepository orders) { this.orders = orders; }
String show(int id, String accept) {
Order order = orders.find(id);
if (order == null) return "(404, 'Not found')";
Function<Order, String> view = accept.equals("application/json") ? Main::jsonView : Main::htmlView;
return "(200, '" + view.apply(order) + "')";
}
}
public static void main(String[] args) {
OrderController controller = new OrderController(new OrderRepository());
System.out.println(controller.show(1, "text/html"));
System.out.println(controller.show(2, "application/json"));
System.out.println(controller.show(9, "text/html"));
}
}
Output
(200, '<h1>Order 1</h1><p>Total: $25.00</p>')
(200, '{"id":2,"total_cents":10800}')
(404, 'Not found')Run this on your computer — see “Get Started” for setup.
<?php
function money(int $cents): string { return "$" . intdiv($cents, 100) . "." . str_pad((string) ($cents % 100), 2, "0", STR_PAD_LEFT); }
class Order { // model: data + business rules
public function __construct(public int $id, public int $subtotal) {}
public function total(): int { return $this->subtotal > 10000 ? intdiv($this->subtotal * 9, 10) : $this->subtotal; } // 10% off over $100
}
class OrderRepository { // model: loading data (a database in a real app)
private array $rows;
public function __construct() { $this->rows = [1 => new Order(1, 2500), 2 => new Order(2, 12000)]; }
public function find(int $id): ?Order { return $this->rows[$id] ?? null; }
}
function htmlView(Order $o): string { return "<h1>Order {$o->id}</h1><p>Total: " . money($o->total()) . "</p>"; } // view
function jsonView(Order $o): string { return "{\"id\":{$o->id},\"total_cents\":{$o->total()}}"; }
class OrderController { // controller: request in, response out
public function __construct(private OrderRepository $orders) {}
public function show(array $request): array {
$order = $this->orders->find($request["id"]);
if ($order === null) return [404, "Not found"];
$view = $request["accept"] === "application/json" ? "jsonView" : "htmlView";
return [200, $view($order)];
}
}
$show = fn(array $r) => "({$r[0]}, '{$r[1]}')";
$controller = new OrderController(new OrderRepository());
echo $show($controller->show(["id" => 1, "accept" => "text/html"])), "\n";
echo $show($controller->show(["id" => 2, "accept" => "application/json"])), "\n";
echo $show($controller->show(["id" => 9, "accept" => "text/html"])), "\n";
Output
(200, '<h1>Order 1</h1><p>Total: $25.00</p>')
(200, '{"id":2,"total_cents":10800}')
(404, 'Not found')Adding a PDF view, or changing the discount rule, touches exactly one place.
In Laravel the same split is a route, a controller, an Eloquent model and a Blade view:
// routes/web.php
Route::get('/orders/{order}', [OrderController::class, 'show']);
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
public function show(Request $request, Order $order) // route model binding loads the model
{
return $request->wantsJson()
? response()->json(['id' => $order->id, 'total' => $order->total()])
: view('orders.show', ['order' => $order]); // resources/views/orders/show.blade.php
}
}
When to use it
- Server-rendered web apps and APIs: almost every web framework is organised this way.
- The same data must be shown in several formats (HTML, JSON, CSV, email).
- Business rules need unit tests without HTTP or a database.
- Designers, frontend and backend developers work on the same feature at once.
When not to use it
- Fat controllers: queries, rules and formatting piled into controller actions bring the original mess back. Move rules into models or service classes.
- Logic in views: a template that runs queries or decides discounts is a model in disguise.
- Very small scripts or a single-endpoint function, where three layers add ceremony and nothing else.
- Treating "model" as only the database table. Rules that span several models usually belong in a service or action class next to the models.
You've already used it
- Laravel (Eloquent models, Blade views, controllers), Ruby on Rails, Spring MVC, ASP.NET Core MVC, Symfony and CodeIgniter.
- Django calls it MTV: Django "views" act as controllers and "templates" are the views.
- UIKit on iOS (view controllers) and early JavaScript frameworks like Backbone.
Key points
- Model: data and business rules. View: presentation. Controller: input handling that connects them.
- The payoff is change in one place: new formats, new screens or new rules don't ripple through the others.
- Keep controllers thin and views free of logic; put rules that span models in services.
- Further reading: Laravel: Controllers and Martin Fowler: GUI Architectures.
Practice questions
What belongs in the model, the view and the controller?
Answer
The model holds data and business rules (totals, validity, state changes) and loading/saving. The view turns data into a presentation (HTML, JSON) with no queries or rules. The controller reads the request, calls the model and chooses the view and response.
What is a fat controller and how do you fix it?
Answer
A controller action that contains queries, business rules and formatting. It is hard to test and reuse. Move rules into the model or into a service/action class, move formatting into views or API resources, and leave the controller to coordinate.
How does Django's MTV relate to MVC?
Answer
It is the same separation with different names: Django models are models, templates are the views, and Django views (functions or classes handling requests) play the controller's role.
Open in the app to mark lessons complete, take notes and track your progress.