Skip to main content
Clean Architecture 70 XP · 7 min

The Humble Object Pattern

Split hard-to-test from easy-to-test behavior — the Presenter prepares all logic into a testable ViewModel; the View is a humble object that only renders what it receives.

Showing
Ad (728×90)

Why this matters

Some objects are hard to test because they are tightly coupled to the environment: a GUI View requires a running UI framework, a database trigger requires a live database. The Humble Object pattern solves this by splitting such objects into two parts: the humble part that is hard to test (the View, the trigger), and the testable part that is easy to test (the Presenter, the logic).

The idea: move ALL logic into the testable part. The Presenter computes every string, every boolean flag, every sort order, every formatted date, and every conditional label before the View even runs. The View receives a ViewModel that contains only pre-formatted strings and booleans — it renders them directly, with zero conditional logic of its own.

The result: the Presenter can be exhaustively unit-tested as a pure function — no browser, no jsdom, no template engine. The View is so simple it cannot have a bug. If a shipping threshold changes from $100 to $150, you update the Presenter and the test — never the template.

The problem

Templates and components with conditional logic — shipping thresholds, date formatting, status colors — all untestable without rendering the full UI.

Bad

# order_detail.html (Jinja2 template with logic):
#
# {% if order.total > 100 %}
#   <span class="badge green">Free Shipping</span>
# {% else %}
#   <span class="badge red">+ $9.99 Shipping</span>
# {% endif %}
#
# <span class="status {{ 'green' if order.status == 'paid' else 'red' }}">
#   {{ order.status | upper }}
# </span>
# <p>Ordered on: {{ order.created_at.strftime('%d/%m/%Y') }}</p>
# <p>Total: ${{ "%.2f" % order.total }}</p>
#
# All logic — shipping threshold, status color, date/money format —
# is trapped in the template. You cannot unit-test it.
# A bug in the shipping threshold goes undetected until production.
// React component with business logic in JSX:
export function OrderDetail({ order }: { order: Order }) {
  return (
    <div>
      <span className={`badge ${order.total > 100 ? "green" : "red"}`}>
        {order.total > 100 ? "Free Shipping" : "+ $9.99 Shipping"}
      </span>
      <span className={`status ${order.status === "paid" ? "green" : "red"}`}>
        {order.status.toUpperCase()}
      </span>
      <p>{new Date(order.createdAt).toLocaleDateString("en-GB")}</p>
    </div>
  );
}
// Testing requires React Testing Library, jsdom, and rendering.
// Logic hidden in JSX is easy to miss and hard to refactor.

The solution

The Presenter computes a ViewModel with pre-formatted strings and booleans. The template or component is humble — it only renders what it receives, with zero conditional logic.

Good

from dataclasses import dataclass
from decimal import Decimal

@dataclass
class OrderViewModel:
    total_display:  str   # "$127.50"
    shipping_label: str   # "Free Shipping" or "+ $9.99 Shipping"
    date_display:   str   # "15/03/2024"
    status_label:   str   # "PAID"
    status_color:   str   # "green" or "red"

class OrderPresenter:
    THRESHOLD = Decimal("100.00")

    def present(self, order) -> OrderViewModel:
        free = order.total >= self.THRESHOLD
        return OrderViewModel(
            total_display  = f"${order.total:,.2f}",
            shipping_label = "Free Shipping" if free else "+ $9.99 Shipping",
            date_display   = order.created_at.strftime("%d/%m/%Y"),
            status_label   = order.status.upper(),
            status_color   = "green" if order.status == "paid" else "red",
        )

# Template (humble — zero logic):
# <span class="{{ vm.status_color }}">{{ vm.shipping_label }}</span>
# <p>{{ vm.date_display }}</p>

# Unit test — no template rendering:
# vm = OrderPresenter().present(order)
# assert vm.shipping_label == "Free Shipping"
export interface OrderViewModel {
  totalDisplay:   string;   // "$127.50"
  shippingLabel:  string;   // "Free Shipping" | "+ $9.99 Shipping"
  dateDisplay:    string;   // "15/03/2024"
  statusLabel:    string;   // "PAID"
  statusColor:    string;   // "green" | "red"
}

// Presenter — pure function, fully unit-testable with plain Jest:
export function presentOrder(order: Order): OrderViewModel {
  const free = order.total > 100;
  return {
    totalDisplay:  \`$\${order.total.toFixed(2)}\`,
    shippingLabel: free ? "Free Shipping" : "+ $9.99 Shipping",
    dateDisplay:   new Date(order.createdAt).toLocaleDateString("en-GB"),
    statusLabel:   order.status.toUpperCase(),
    statusColor:   order.status === "paid" ? "green" : "red",
  };
}

// Component (humble — just renders the ViewModel, no logic):
export function OrderDetail({ order }: { order: Order }) {
  const vm = presentOrder(order);
  return (
    <div>
      <span className={vm.statusColor}>{vm.shippingLabel}</span>
      <p>{vm.dateDisplay}</p>
      <p>{vm.totalDisplay}</p>
    </div>
  );
}
// Unit test — no React, no jsdom:
// expect(presentOrder(order).shippingLabel).toBe("Free Shipping");

Key takeaway

If your template has an if-statement, it has logic that isn't being tested. Move all conditional logic, formatting, and computed values to the Presenter. Make the View so simple it cannot have a bug — then test the Presenter exhaustively as a pure function, without any UI infrastructure.

Done with this lesson?

Mark it complete to earn XP and track your progress.