MVC and Its Variants

Software Architecture Fundamentals

Chapter 3 · MVC and Its Variants

Chapter 2 said presentation should only ever "own formatting and input." That's true for all three variants in this chapter — MVC, MVP, and MVVM all agree the presentation layer shouldn't contain business rules. What they genuinely disagree on is how data actually flows between the view and everything behind it. This chapter builds all three, verifies the difference is real, and connects one of them directly back to a pattern you already know.

MVC: the View Reads the Model Directly

class TodoModel: def __init__(self): self.items = [] def add_item(self, text): self.items.append(text) class TodoController: def __init__(self, model): self.model = model def handle_add(self, text): self.model.add_item(text) class TodoView: def __init__(self, model): self.model = model # the View holds a direct Model reference def render(self): return f"Todo list: {', '.join(self.model.items)}"
Verified directly — the View reads live state straight from the Model, with no mediator involved
view.model is controller.model confirms True — the exact same object, not a copy. After controller.handle_add('Buy milk'), calling view.render() correctly reports "Todo list: Buy milk", purely because render() reads self.model.items directly at render time. The Controller never told the View anything — the View simply looked.

MVP: the View Never Touches the Model at All

MVP takes coupling the View to the Model away entirely — the View becomes "dumb," exposing only display methods, and a Presenter mediates every single interaction.

class TodoPresenterView: # dumb — no model reference anywhere def __init__(self): self.displayed_text = None def show_items(self, text): self.displayed_text = text class TodoPresenter: def __init__(self, view, model): self.view = view; self.model = model def handle_add(self, text): self.model.add_item(text) self.view.show_items(f"Todo list: {', '.join(self.model.items)}") # explicit push
Verified directly — this View has no way to reach the Model, even if it wanted to
hasattr(view, 'model') returns False — unlike TodoView, TodoPresenterView was never given a reference to any model at all. Calling presenter.handle_add('Buy milk') correctly updates view.displayed_text to "Todo list: Buy milk" — but only because the Presenter explicitly called view.show_items(...) itself.
Verified directly — bypassing the Presenter leaves the View stale, on purpose
Calling model.add_item('Walk the dog') directly — skipping the Presenter entirely — correctly updates model.items to ['Buy milk', 'Walk the dog']. But view.displayed_text stays exactly as it was: "Todo list: Buy milk" — genuinely stale. In MVP, nothing updates the View except the Presenter explicitly telling it to.

MVVM: the View Binds to the ViewModel — and This Is Just Observer

MVVM solves MVP's own staleness problem, but not by adding more explicit push calls — by using exactly the mechanism Design Patterns Chapter 8 already built: Observer. The ViewModel is the subject; the bound view is an observer.

class TodoViewModel: # the subject — same shape as Design Patterns' own Stock def __init__(self): self.items = []; self._observers = [] def attach(self, observer): self._observers.append(observer) def add_item(self, text): self.items.append(text) self._notify() def _notify(self): for observer in self._observers: observer.update(self.items) class TodoBoundView: # the observer — bound once, updates automatically forever def __init__(self): self.displayed_text = None def update(self, items): self.displayed_text = f"Todo list: {', '.join(items)}"
Verified directly — the bound view updates automatically, with no explicit push call anywhere
After view_model.attach(bound_view), calling view_model.add_item('Buy milk') correctly updates bound_view.displayed_text to "Todo list: Buy milk" — and a second call, add_item('Walk the dog'), correctly produces "Todo list: Buy milk, Walk the dog". Inspecting TodoViewModel.add_item's own source confirms it never references bound_view or any specific view type by name — it only calls self._notify(), exactly like Stock.set_price() only ever called observer.update(...) on whatever was attached.
The connection, stated directly
MVVM's own "data binding" isn't a new mechanism this chapter had to invent — it's the Observer pattern, applied specifically to keeping a view in sync with a view-model. This is exactly why MVP's stale-view problem, verified above, doesn't happen in MVVM: the ViewModel doesn't need a Presenter to remember to push an update, because attaching an observer already guarantees it will be notified.

Comparing All Three

MVCMVPMVVM
Can the View read the Model directly?Yes — verified: same object identityNo — verified: hasattr(view, 'model') is FalseNo — the View only ever sees what the ViewModel notifies it with
Who updates the View?The View reads for itself, on demandThe Presenter, explicitly, every timeThe binding mechanism, automatically
What happens if you bypass the mediator?N/A — there's no separate mediator to bypassThe View goes stale — verified aboveImpossible by construction — there's no separate "tell the view" step to skip
Common inClassic Rails/Django-style server-rendered appsOlder desktop GUI frameworks, testable Android (pre-Compose)WPF, and reactive/data-bound web frameworks (Vue, some React state libraries)

Where This Connects

This chapter's findingWhat it connects to
MVVM's binding verified as literally Design Patterns' own Observer, reused unchangedConfirms this course's own Chapter 1 claim directly — a design-level pattern (Observer) is one of the concrete mechanisms an architecture-level decision (MVVM) is built from
MVP's verified stale-view bug when the Presenter is bypassedChapter 2's own "skipping a layer produces a silently wrong result, not a crash" finding — the same shape of bug, one level up
All three variants agreeing presentation owns no business rulesChapter 5's coupling/cohesion criteria — the real difference between MVC/MVP/MVVM is entirely about coupling direction, not about what belongs in which layer

Hands-On Exercises

Exercise 1

Add a remove_item(text) method to this chapter's own MVC TodoModel and TodoController, following the exact shape of add_item/handle_add. Verify view.render() correctly reflects the removal, with no changes to TodoView at all.

📄 View solution
Exercise 2

Add a remove_item(text) method to this chapter's own MVP TodoPresenter (and matching model support). Verify it correctly pushes the update to view.displayed_text, and then verify that calling model's own removal method directly, bypassing the Presenter, leaves the View stale again — exactly like this chapter's own add_item bypass.

📄 View solution
Exercise 3

Attach a second TodoBoundView to this chapter's own TodoViewModel (alongside the first). Verify calling view_model.add_item(...) once updates both bound views correctly, and explain — using this chapter's own comparison table — what the MVP equivalent of adding a second view would have required that MVVM didn't.

📄 View solution

Chapter 3 Quick Reference

  • MVC: the View reads the Model directly — verified: same object identity, no mediator involved
  • MVP: a dumb View, mediated entirely by a Presenter — verified: the View has no Model reference at all, and goes genuinely stale if the Presenter is bypassed
  • MVVM: the View binds to the ViewModel — verified as literally Design Patterns' own Observer pattern, reused directly; updates happen automatically, with no explicit push step to forget
  • Next chapter: Monolith vs. Microservices — a much bigger-scale version of the same "who's allowed to talk to whom" question