The MVP Pattern: Passive View, Supervising Controller and a Testable Presenter
In MVP the view retreats behind an interface and all screen logic moves into the presenter, so the presenter can be tested in milliseconds with a fake view and no DOM. We build the order form in TypeScript with an OrderFormView interface, a DOM implementation and Vitest tests, and look at the difference between Passive View and Supervising Controller and at how to keep the presenter from bloating.
Version note: The examples in this post were tested with Angular 22 and TypeScript.
Guess First
The test for the Smart UI (UI: User Interface) in the first post mounted a DOM (Document Object Model), wrote into an input, fired an event and read the text of a paragraph. In the MVC (Model-View-Controller) version in the second post the domain rules became testable, but view logic such as "disable the button if the quantity is invalid" and "block double clicks while saving" was still scattered between the controller and the view.
You want to test these behaviors: when the page opens the books are listed; typing 3 for Emma gives a total of 25.65; typing "abc" disables the button; pressing the button twice quickly sends only one request to the order API (Application Programming Interface); if the API returns an error the user sees an error message. How many of these five tests need a browser or jsdom? With the right design, the answer is zero.
The View Interface and the Presenter
The MVP (Model-View-Presenter) idea in one sentence: hide the view behind an interface and give every screen-related decision to the presenter. The presenter knows the view only through this interface; it knows nothing about the DOM, Angular or WinForms. The order form's view interface:
export interface BookOption {
readonly id: number;
readonly title: string;
}
export interface OrderFormView {
readonly selectedBookId: number;
readonly quantityText: string;
showBooks(books: readonly BookOption[]): void;
showTotal(text: string): void;
showError(message: string): void;
setPlaceOrderEnabled(enabled: boolean): void;
onBookChanged(handler: () => void): void;
onQuantityChanged(handler: () => void): void;
onPlaceOrder(handler: () => void): void;
}
The interface carries two kinds of members. The getters and show... methods let the presenter read the view and show something on it. The on... methods pass the user's actions to the presenter; the view only says "something happened" and does not decide what it means. This means the controller's job of interpreting input in Smalltalk MVC also moves to the presenter. The TypeScript side of working with interfaces and implements is in the "Classes in TypeScript: Access Modifiers, abstract, implements and override" post of the TypeScript series.
The presenter uses pricing.ts from the first post and receives the order API as an interface too:
import { catalog, parseQuantity, priceOrder } from '../domain/pricing';
import type { OrderFormView } from './order-form-view';
export interface OrderApi {
placeOrder(bookId: number, quantity: number): Promise<{ orderId: number }>;
}
export class OrderFormPresenter {
#saving = false;
constructor(private readonly view: OrderFormView, private readonly api: OrderApi) {
view.showBooks(catalog);
view.onBookChanged(() => this.refresh());
view.onQuantityChanged(() => this.refresh());
view.onPlaceOrder(() => this.placeOrder());
this.refresh();
}
private refresh(): void {
const check = parseQuantity(this.view.quantityText);
if (!check.ok) {
this.view.showError(check.error);
this.view.showTotal('');
this.view.setPlaceOrderEnabled(false);
return;
}
const book = catalog.find(b => b.id === this.view.selectedBookId)!;
this.view.showError('');
this.view.showTotal(`Total: ${priceOrder(book, check.quantity).toFixed(2)}`);
this.view.setPlaceOrderEnabled(!this.#saving);
}
private async placeOrder(): Promise<void> {
const check = parseQuantity(this.view.quantityText);
if (!check.ok || this.#saving) return;
this.#saving = true;
this.view.setPlaceOrderEnabled(false);
try {
const { orderId } = await this.api.placeOrder(this.view.selectedBookId, check.quantity);
this.view.showTotal(`Order #${orderId} placed`);
} catch {
this.view.showError('Something went wrong, please try again');
} finally {
this.#saving = false;
this.view.setPlaceOrderEnabled(parseQuantity(this.view.quantityText).ok);
}
}
}
There is not a single document, HTMLElement or selector in the presenter. The #saving flag is pure view state: information the domain does not know and that only means something while the screen is open. In classic MVC this information had no home; in MVP its home is the presenter.
Humble View: The DOM Implementation
The real view is a thin class implementing the interface with the DOM:
import type { BookOption, OrderFormView } from './order-form-view';
export class DomOrderFormView implements OrderFormView {
readonly #book: HTMLSelectElement;
readonly #quantity: HTMLInputElement;
readonly #total: HTMLElement;
readonly #error: HTMLElement;
readonly #placeOrder: HTMLButtonElement;
constructor(root: HTMLElement) {
root.innerHTML = `
<select id="book"></select>
<input id="quantity" value="1">
<p id="total"></p>
<p id="error"></p>
<button id="place-order">Place order</button>`;
this.#book = root.querySelector('#book')!;
this.#quantity = root.querySelector('#quantity')!;
this.#total = root.querySelector('#total')!;
this.#error = root.querySelector('#error')!;
this.#placeOrder = root.querySelector('#place-order')!;
}
get selectedBookId(): number { return Number(this.#book.value); }
get quantityText(): string { return this.#quantity.value; }
showBooks(books: readonly BookOption[]): void {
this.#book.replaceChildren(...books.map(b => new Option(b.title, String(b.id))));
}
showTotal(text: string): void { this.#total.textContent = text; }
showError(message: string): void { this.#error.textContent = message; }
setPlaceOrderEnabled(enabled: boolean): void { this.#placeOrder.disabled = !enabled; }
onBookChanged(handler: () => void): void { this.#book.addEventListener('change', handler); }
onQuantityChanged(handler: () => void): void { this.#quantity.addEventListener('input', handler); }
onPlaceOrder(handler: () => void): void { this.#placeOrder.addEventListener('click', handler); }
}
There is not a single if in this class. Every method is one line and its job is mechanical: read a value, write a value or add a listener. This is the finished form of the Humble View idea from the first post; the part that is hard to test is dumb enough not to need testing.
Testing the Presenter Without the View
For the tests we write a fake view that implements the interface in memory. Unlike the real view, it records everything it shows in a field and offers a few helper methods so the test can act like a user:
import type { BookOption, OrderFormView } from './order-form-view';
export class FakeOrderFormView implements OrderFormView {
selectedBookId = 1;
quantityText = '1';
books: readonly BookOption[] = [];
total = '';
error = '';
placeOrderEnabled = false;
#bookChanged = () => {};
#quantityChanged = () => {};
#placeOrder = () => {};
showBooks(books: readonly BookOption[]): void { this.books = books; }
showTotal(text: string): void { this.total = text; }
showError(message: string): void { this.error = message; }
setPlaceOrderEnabled(enabled: boolean): void { this.placeOrderEnabled = enabled; }
onBookChanged(handler: () => void): void { this.#bookChanged = handler; }
onQuantityChanged(handler: () => void): void { this.#quantityChanged = handler; }
onPlaceOrder(handler: () => void): void { this.#placeOrder = handler; }
// lets the test act like a user
typeQuantity(text: string): void {
this.quantityText = text;
this.#quantityChanged();
}
selectBook(id: number): void {
this.selectedBookId = id;
this.#bookChanged();
}
clickPlaceOrder(): void { this.#placeOrder(); }
}
The tests for the five behaviors from Guess First:
import { describe, expect, it, vi } from 'vitest';
import { OrderFormPresenter, type OrderApi } from './order-form-presenter';
import { FakeOrderFormView } from './fake-order-form-view';
function setup(api: OrderApi = { placeOrder: vi.fn().mockResolvedValue({ orderId: 42 }) }) {
const view = new FakeOrderFormView();
new OrderFormPresenter(view, api);
return { view, api };
}
describe('OrderFormPresenter', () => {
it('shows the books and the initial total', () => {
const { view } = setup();
expect(view.books.map(b => b.title)).toEqual(['The Hobbit', 'Emma']);
expect(view.total).toBe('Total: 14.00');
expect(view.placeOrderEnabled).toBe(true);
});
it('applies the discount from 3 copies', () => {
const { view } = setup();
view.selectBook(2);
view.typeQuantity('3');
expect(view.total).toBe('Total: 25.65');
});
it('disables the button for invalid quantity', () => {
const { view } = setup();
view.typeQuantity('abc');
expect(view.error).toBe('Quantity must be between 1 and 10');
expect(view.placeOrderEnabled).toBe(false);
});
it('places the order once and shows its number', async () => {
const { view, api } = setup();
view.typeQuantity('2');
view.clickPlaceOrder();
view.clickPlaceOrder(); // double click
await vi.waitFor(() => expect(view.total).toBe('Order #42 placed'));
expect(api.placeOrder).toHaveBeenCalledTimes(1);
expect(api.placeOrder).toHaveBeenCalledWith(1, 2);
});
it('shows an error when the API fails', async () => {
const { view } = setup({ placeOrder: vi.fn().mockRejectedValue(new Error('503')) });
view.clickPlaceOrder();
await vi.waitFor(() => expect(view.error).toBe('Something went wrong, please try again'));
expect(view.placeOrderEnabled).toBe(true);
});
});
One integration test is enough to see that the real DOM view wires up correctly with the same presenter:
import { expect, test, vi } from 'vitest';
import { OrderFormPresenter } from './order-form-presenter';
import { DomOrderFormView } from './dom-order-form-view';
test('the real DOM view wires up with the same presenter', () => {
const root = document.createElement('div');
new OrderFormPresenter(new DomOrderFormView(root), { placeOrder: vi.fn() });
const quantity = root.querySelector<HTMLInputElement>('#quantity')!;
quantity.value = '3';
quantity.dispatchEvent(new Event('input'));
expect(root.querySelector('#total')!.textContent).toBe('Total: 37.80');
});
✓ src/p03/supervising.spec.ts > view binds simple fields, presenter handles validation 5ms
✓ src/p03/dom-view.spec.ts > the real DOM view wires up with the same presenter 6ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > shows the books and the initial total 1ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > applies the discount from 3 copies 0ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > disables the button for invalid quantity 0ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > places the order once and shows its number 52ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > shows an error when the API fails 50ms
Test Files 3 passed (3)
Tests 7 passed (7)
The presenter tests have no DOM, no selectors, no querySelector tied to a text format. The double click test proves the #saving flag works, the error test proves the button comes back after an error. The roughly 50 milliseconds in the two async tests is the default polling interval of vi.waitFor; the presenter itself does not wait. When the designer changes the "Total" text, only one integration test is affected.
Passive View and Supervising Controller
The version above is called Passive View. The view carries no logic and does not even keep its own state in sync; the presenter tells it everything one by one. Fowler started using this name while studying MVP in 2006 and split the MVP label into two separate patterns. He also notes that Passive View was not part of Potel's original MVP description and emerged as people pushed for testability.
The second variant is Supervising Controller. Here the view does the simple, declarative mappings (showing a field's value on screen, for example) itself, usually with a binding mechanism; the presenter steps in only for complex cases. A version that uses the observable OrderModel from the second post:
import { parseQuantity } from '../domain/pricing';
import { OrderModel } from '../p02/order-model';
export interface SupervisedOrderFormView {
readonly quantityText: string;
bind(model: OrderModel): void; // the view does the simple mappings itself
showError(message: string): void;
setPlaceOrderEnabled(enabled: boolean): void;
onQuantityChanged(handler: () => void): void;
}
export class SupervisingOrderFormPresenter {
constructor(private readonly view: SupervisedOrderFormView, private readonly model: OrderModel) {
view.bind(model);
view.onQuantityChanged(() => this.onQuantityChanged());
}
private onQuantityChanged(): void {
const check = parseQuantity(this.view.quantityText);
this.view.showError(check.ok ? '' : check.error);
this.view.setPlaceOrderEnabled(check.ok);
if (check.ok) this.model.changeQuantity(check.quantity);
}
}
Inside bind, the view reads the title and the total from the model itself:
bind(model: OrderModel): void {
const render = () => {
this.#el('#title').textContent = model.book.title;
this.#el('#total').textContent = `Total: ${model.total.toFixed(2)}`;
};
model.subscribe(render);
render();
}
The trade-off is clear. With Supervising Controller the view interface is small and the presenter short; you write no presenter lines for simple mappings. The price is that the mappings inside bind stay outside the presenter tests. In the words of Fowler's Humble View discussion: Passive View makes the view fully humble but needs a fake view; Supervising Controller leaves a small risk in the simple mappings. If you are on a platform with strong binding, Supervising Controller; where binding is missing or unreliable, Passive View is the natural choice.
Flow Synchronization: Who Does the Syncing?
In the second post we saw that Smalltalk MVC solves synchronization with Observer: the model changes and the views redraw on their own. Passive View is different. When something changes, the presenter knows which fields are affected and tells the view one by one: the refresh method updates the error message, the total and the button state together. Fowler calls this approach Flow Synchronization: synchronization happens explicitly at specific points in the application flow.
The good side is readability. Someone reading refresh can see from the code what changes on screen and when; there is no subscriber list built at runtime as with Observer. The bad side is the risk of forgetting. If tomorrow a "shipping fee" field is added to the form and you forget to put it into refresh, the field keeps its old value and nothing fails. Presenter tests are the cure for this risk: as long as you write the "when this input changes, this output updates" test for every new field, forgetting gets caught in a test. MVVM's (Model-View-ViewModel) binding hands the job of keeping this list by hand to the framework; that hand-over is exactly the topic of the next post.
Where MVP Came From and Where It Makes Sense Today
MVP became known through Mike Potel's 1996 paper describing Taligent's programming model for C++ and Java; Fowler writes that the idea first appeared at IBM (International Business Machines) and Taligent in the 1990s. Potel generalized Smalltalk MVC and put the presenter in place of the controller. In 2000 Andy Bower and Blair McGlashan made MVP the basis of their UI framework in "Twisting the Triad", the paper they wrote for Dolphin Smalltalk.
Its real spread happened in the .NET and Android worlds. WinForms had limited data binding support and a form's code-behind file easily turned into a Smart UI; that is why guidance such as the Smart Client Software Factory from Microsoft's patterns & practices team recommended MVP. On Android, until Architecture Components (ViewModel and LiveData) arrived in 2017, Activities were classes that did everything, and MVP was the most recommended solution, even in Google's own sample projects. In both worlds the move from MVP to MVVM happened as soon as the platform seriously supported binding.
Where MVP makes sense today follows from this history. Environments without binding: a framework-free widget, the UI of a game engine, a terminal interface, a WinForms application still under maintenance. Cases where the same screen logic has to work with more than one view: a desktop and a web view, or an automation view next to the real one. And anywhere setting up the UI framework in a test environment is expensive. In a framework with strong binding such as Angular, MVP's hand-written view interface is usually needless ceremony; we will see why in the next post.
Common Pitfalls
The presenter deciding every pixel of the view. As a color, a tooltip, a font weight gets added to the screen, the interface grows:
// when the presenter decides every pixel
export interface BloatedOrderFormView {
setQuantityBorderColor(color: string): void;
setQuantityTooltip(text: string): void;
setTotalFontWeight(weight: 'normal' | 'bold'): void;
setBadgeVisible(visible: boolean): void;
setBadgeText(text: string): void;
setBadgeColor(color: string): void;
focusQuantity(): void;
}
// the presenter states intent, the look stays in the view
export interface IntentOrderFormView {
showQuantityState(state: 'valid' | 'invalid'): void;
showDiscount(applied: boolean): void;
}
With the first interface the presenter turns into a design tool and every visual change touches the presenter, the interface and the fake view. With the second the presenter states intent ("quantity invalid", "discount applied"); whether that becomes a red border, a tooltip or both is the view's decision. The test: if method names in the interface mention colors, pixels or CSS (Cascading Style Sheets) concepts, the presenter knows too much.
Making the view interface a mirror of the DOM. A member such as getQuantityInput(): HTMLInputElement ties the presenter back to the DOM and makes writing a fake view impossible. The interface should contain only strings, numbers, booleans and your own types.
Writing domain rules in the presenter. If you write the discount rule inside the presenter instead of priceOrder, you have undone Separated Presentation; the rule again lives only on this screen. The presenter calls the domain, it does not replace it.
A presenter for every screen, an interface for every presenter, a fake view for every interface. For a one-field confirmation dialog, this trio produces more code than the logic it tests.
When Not to Use It
If the platform has a strong binding mechanism, as WPF (Windows Presentation Foundation), .NET MAUI (Multi-platform App UI), Angular or Blazor do, MVP's view interface and show... methods amount to rewriting by hand what binding already does. There you get the same testability with MVVM, without writing an interface. If the screen is very simple (two fields, one button, no conditions), there is no logic for the presenter to test; a Smart UI or a thin component is enough. And if the rest of the team does not know the pattern, a half-applied MVP (one with a presenter but a view that still makes decisions) combines the bad sides of both worlds.
A Small Experiment
★☆☆ Add a new rule to the presenter: if the selected book is Emma and the quantity is 10, show the error "Only 9 copies in stock". Write the test with FakeOrderFormView first, then change the presenter. Did you have to touch DomOrderFormView?
★★☆ Add IntentOrderFormView to OrderFormView and show the "invalid" state with a CSS class in the DOM implementation. In the presenter test, verify only the intent (showQuantityState('invalid')).
★★★ Write a second view for the same OrderFormPresenter: a terminal view that reads input from process.stdin and writes the result with console.log. Could you run both views without changing a single line of the presenter?
In MVP the presenter does the synchronization by hand: showTotal, showError, setPlaceOrderEnabled calls on every change. In the next post we move to Presentation Model and MVVM, which leave this job to binding; we will write the same order form in C# with CommunityToolkit.Mvvm and in Angular with signals.