# MVP — Model View Presenter

## The Untestable Screen

I inherited an Android-style codebase (this was a React Native project with no state management) where the component file for the order screen was 800 lines long. The component fetched data directly, managed loading and error state, applied business rules, and rendered JSX. There was no way to test any of the business logic without mounting the whole component with a mocked network.

MVP solves exactly this. The **Presenter** contains all logic and is a plain class — no DOM, no component lifecycle, no framework dependencies. It is easy to test. The **View** is made deliberately passive: it has no decision-making power; it only calls the presenter when something happens and renders what the presenter tells it to.

## Table of Contents

* [The Three Roles](#the-three-roles)
* [Passive View Contract](#passive-view-contract)
* [How MVP Differs from MVC](#how-mvp-differs-from-mvc)
* [TypeScript Example: Order Summary Screen](#typescript-example-order-summary-screen)
* [Testing the Presenter Without a DOM](#testing-the-presenter-without-a-dom)
* [Python Example: CLI Presenter](#python-example-cli-presenter)
* [When to Use MVP](#when-to-use-mvp)
* [Lessons Learned](#lessons-learned)

***

## The Three Roles

```
┌──────────────────────────────────────────────────────────┐
│                          View                            │
│  - Renders what it is told                               │
│  - Calls presenter methods on user events                │
│  - NO business logic, NO data fetching                   │
└──────────────┬───────────────────┬───────────────────────┘
               │ events            │ render commands
               ▼                   ▲
┌──────────────────────────────────────────────────────────┐
│                        Presenter                         │
│  - One plain class (no framework dependencies)           │
│  - Handles events from view                              │
│  - Calls model / service                                 │
│  - Calls view methods to update display                  │
└──────────────────────────┬───────────────────────────────┘
                           │ data requests
                           ▼
┌──────────────────────────────────────────────────────────┐
│                          Model                           │
│  - Data, domain rules, persistence                       │
│  - No knowledge of UI                                    │
└──────────────────────────────────────────────────────────┘
```

The critical difference from MVC: **the Presenter holds a reference to the View interface, not vice versa**. The View knows the Presenter exists (it calls presenter methods), but the Presenter only depends on a View **interface** — making it mockable.

***

## Passive View Contract

The view implements an interface that the presenter drives:

```typescript
// The view interface — what the presenter can command
interface OrderListView {
  showLoading(): void
  hideLoading(): void
  displayOrders(orders: OrderSummary[]): void
  showError(message: string): void
  showEmptyState(): void
}
```

The presenter calls methods on this interface. In production, the React component implements it. In tests, a mock class implements it. The presenter never changes — only the view implementation swaps.

***

## How MVP Differs from MVC

| Aspect                           | MVC                                | MVP                                             |
| -------------------------------- | ---------------------------------- | ----------------------------------------------- |
| View updates                     | Controller picks view after action | Presenter explicitly calls view methods         |
| View has model reference?        | Often yes                          | No — view only knows presenter                  |
| Presenter/Controller testability | Needs HTTP context                 | Presenter is a plain class, fully unit-testable |
| View logic                       | May contain some                   | None — view is passive                          |
| Two-way communication            | Controller → View (indirect)       | Presenter ↔ View (via interface)                |

***

## TypeScript Example: Order Summary Screen

```typescript
// models/orderSummary.ts

export interface OrderSummary {
  id: number
  status: string
  itemCount: number
  total: number
  createdAt: string
}
```

```typescript
// services/orderApiService.ts

import { OrderSummary } from "../models/orderSummary"

export class OrderApiService {
  async fetchOrders(tenantId: string): Promise<OrderSummary[]> {
    const response = await fetch(`/api/orders?tenantId=${encodeURIComponent(tenantId)}`)
    if (!response.ok) throw new Error("Failed to load orders")
    return response.json()
  }
}
```

```typescript
// presenters/orderListPresenter.ts

import { OrderApiService } from "../services/orderApiService"
import { OrderSummary } from "../models/orderSummary"

// The view interface — the presenter only depends on this, not on any React/DOM types
export interface OrderListView {
  showLoading(): void
  hideLoading(): void
  displayOrders(orders: OrderSummary[]): void
  showError(message: string): void
  showEmptyState(): void
}

export class OrderListPresenter {
  private view: OrderListView
  private service: OrderApiService

  constructor(view: OrderListView, service: OrderApiService) {
    this.view = view
    this.service = service
  }

  async loadOrders(tenantId: string): Promise<void> {
    if (!tenantId) {
      this.view.showError("Tenant ID is required")
      return
    }

    this.view.showLoading()
    try {
      const orders = await this.service.fetchOrders(tenantId)
      if (orders.length === 0) {
        this.view.showEmptyState()
      } else {
        this.view.displayOrders(orders)
      }
    } catch (err) {
      this.view.showError("Could not load orders. Please try again.")
    } finally {
      this.view.hideLoading()
    }
  }
}
```

```tsx
// components/OrderListScreen.tsx — the passive view

import React, { useEffect, useRef, useState } from "react"
import { OrderListPresenter, OrderListView } from "../presenters/orderListPresenter"
import { OrderApiService } from "../services/orderApiService"
import { OrderSummary } from "../models/orderSummary"

type ScreenState =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "data"; orders: OrderSummary[] }
  | { kind: "empty" }
  | { kind: "error"; message: string }

export function OrderListScreen({ tenantId }: { tenantId: string }) {
  const [state, setState] = useState<ScreenState>({ kind: "idle" })

  // The view implementation — all methods delegated to setState
  const view: OrderListView = {
    showLoading:           () => setState({ kind: "loading" }),
    hideLoading:           () => {},
    displayOrders: (orders) => setState({ kind: "data", orders }),
    showEmptyState:        () => setState({ kind: "empty" }),
    showError:    (message) => setState({ kind: "error", message }),
  }

  useEffect(() => {
    const presenter = new OrderListPresenter(view, new OrderApiService())
    presenter.loadOrders(tenantId)
  }, [tenantId])

  if (state.kind === "loading") return <div>Loading...</div>
  if (state.kind === "error")   return <div className="error">{state.message}</div>
  if (state.kind === "empty")   return <div>No orders found</div>
  if (state.kind === "data")    return (
    <ul>
      {state.orders.map(o => (
        <li key={o.id}>Order #{o.id} — {o.status} — {o.total} THB</li>
      ))}
    </ul>
  )
  return null
}
```

***

## Testing the Presenter Without a DOM

Because the presenter depends only on the `OrderListView` interface, tests use a plain mock without any rendering library:

```typescript
// presenters/orderListPresenter.test.ts

import { OrderListPresenter, OrderListView } from "./orderListPresenter"
import { OrderApiService } from "../services/orderApiService"
import { OrderSummary } from "../models/orderSummary"

// Mock view — records what was called
class MockView implements OrderListView {
  calls: string[] = []
  lastOrders: OrderSummary[] = []
  lastError: string = ""

  showLoading()                       { this.calls.push("showLoading") }
  hideLoading()                       { this.calls.push("hideLoading") }
  showEmptyState()                    { this.calls.push("showEmptyState") }
  displayOrders(orders: OrderSummary[]) { this.calls.push("displayOrders"); this.lastOrders = orders }
  showError(message: string)          { this.calls.push("showError"); this.lastError = message }
}

// Mock service
const makeMockService = (orders: OrderSummary[]) =>
  ({ fetchOrders: jest.fn().mockResolvedValue(orders) }) as unknown as OrderApiService

test("displays orders when service returns data", async () => {
  const view = new MockView()
  const orders: OrderSummary[] = [{ id: 1, status: "confirmed", itemCount: 3, total: 450, createdAt: "2024-01-01" }]
  const presenter = new OrderListPresenter(view, makeMockService(orders))

  await presenter.loadOrders("tenant_001")

  expect(view.calls).toContain("showLoading")
  expect(view.calls).toContain("displayOrders")
  expect(view.calls).toContain("hideLoading")
  expect(view.lastOrders).toHaveLength(1)
})

test("shows empty state when no orders returned", async () => {
  const view = new MockView()
  const presenter = new OrderListPresenter(view, makeMockService([]))

  await presenter.loadOrders("tenant_001")

  expect(view.calls).toContain("showEmptyState")
  expect(view.calls).not.toContain("displayOrders")
})

test("shows error when tenantId is missing", async () => {
  const view = new MockView()
  const presenter = new OrderListPresenter(view, makeMockService([]))

  await presenter.loadOrders("")

  expect(view.calls).toContain("showError")
  expect(view.lastError).toMatch(/required/i)
})
```

No mounting, no DOM, no network. All business logic tested in milliseconds.

***

## Python Example: CLI Presenter

MVP works equally well in terminal applications where the "view" is stdout:

```python
# presenters/report_presenter.py

from abc import ABC, abstractmethod

class ReportView(ABC):
    @abstractmethod
    def show_loading(self): ...
    @abstractmethod
    def display_report(self, lines: list[str]): ...
    @abstractmethod
    def show_error(self, message: str): ...

class SalesReportPresenter:
    def __init__(self, view: ReportView, service):
        self._view = view
        self._service = service

    def generate(self, tenant_id: str, date: str):
        self._view.show_loading()
        try:
            data = self._service.get_daily_sales(tenant_id, date)
            lines = [f"Total: {data['total']} THB", f"Orders: {data['order_count']}"]
            self._view.display_report(lines)
        except Exception as e:
            self._view.show_error(str(e))

class CliReportView(ReportView):
    def show_loading(self):
        print("Generating report…")
    def display_report(self, lines):
        for line in lines:
            print(line)
    def show_error(self, message):
        print(f"ERROR: {message}")
```

***

## When to Use MVP

MVP is a good fit when:

* Presenter logic needs to be **unit-tested without a rendering framework**
* The view is likely to **change** (switching from React to React Native, CLI to web) while the logic stays the same
* Working with inexperienced contributors — the passive view constraint prevents logic from sneaking back in

Consider MVVM instead when:

* The framework supports reactive data bindings natively (React, Vue, SwiftUI)
* The team is more comfortable with observables than with explicit presenter-to-view method calls

***

## Lessons Learned

* **The View interface is the contract.** Keep it small — one method per distinct visual state change. Fat view interfaces signal that the presenter is doing too much.
* **Presenters should have no framework imports.** If a `react` import appears in a presenter file, the abstraction has leaked.
* **One presenter per screen, not per component.** Sharing a presenter across components introduces hidden coupling between UI parts.
