<< Home

Chapter 18: A Small Pattern Driven Project

This chapter walks through a compact yet realistic project that uses a handful of patterns with purpose rather than ceremony. We will sketch a feature slice end to end and show where each pattern pays its rent. The aim is clarity; every pattern must earn its place.

Project Overview

We will build a tiny digital bookstore slice that supports adding items to a cart, choosing a pricing policy, placing an order, and notifying interested parties. The scope includes a domain core, a minimal persistence seam, and a simple service boundary. We will not cover authentication or deployment; focus stays on design choices that shape maintainability.

Functional goals

Users add books to a cart, view totals, apply a promotion, and confirm the order. On confirmation the system charges a payment gateway and emits notifications to subscribers. Failures should not corrupt the cart or order state.

Non-functional goals

Keep business rules testable without infrastructure, allow pricing rules to vary, and isolate the external payment provider so it can be swapped without touching core code.

💡 Keep the slice thin. A small, vertical cut across the UI, service, domain, and infrastructure reveals pattern seams without drowning in scaffolding.

Core Components

The design centers on a handful of focused types with narrow contracts. Names are illustrative rather than canonical. Keep each component small and stable.

Cart and Order

Cart collects line items and exposes totals. Order captures a snapshot of the cart at confirmation time and holds immutable details needed for charging and fulfillment.

PricingStrategy

Different pricing policies implement a shared interface, for example price(…). Strategies swap freely without touching the cart or order code.

PaymentGateway and PaymentAdapter

PaymentGateway defines the contract we want. PaymentAdapter wraps a third-party client and translates between our types and its payloads such as {"cc_no": …} and {"amt": …}.

OrderService and DomainEvents

OrderService orchestrates checkout. DomainEvents provides a tiny observer style bus so listeners can react to order confirmation, for example to send email or to update analytics.

Patterns in the Design

Each pattern maps to a concrete need, not a fashion choice. The table summarizes the role and the tradeoffs to watch.

Pattern Placed In Why It Fits Watch Out For
Strategy PricingStrategy Swap pricing rules without touching cart or order Overkill if only one rule will ever exist
Adapter PaymentAdapter Shield the domain from a vendor API Do not leak vendor types into signatures
Decorator GatewayWithRetry Add retry or logging around a gateway instance Order of wrappers can change behavior
Observer DomainEvents Notify subscribers on order confirmed Unbounded fan-out can cause storms
Facade OrderService Single entry point for checkout workflow Keep it thin; avoid god-class drift

Why not Singleton or Service Locator

Global access hides dependencies and harms testability. Prefer constructor injection on OrderService and explicit wiring in composition roots for clarity.

Minimal Implementation Sketch

The following sketch is compact by design. It shows how the parts fit together without dictating a framework or database. Replace … with your infrastructure of choice.

from dataclasses import dataclass

@dataclass(frozen=True)
class Money:
  cents: int
  currency: str = "USD"
  def __add__(self, other): return Money(self.cents + other.cents, self.currency)

@dataclass
class LineItem:
  sku: str
  qty: int
  unit_cents: int

class Cart:
  def __init__(self): self.items = []
  def add(self, sku, qty, unit_cents): self.items.append(LineItem(sku, qty, unit_cents))
  def subtotal(self): return Money(sum(i.qty * i.unit_cents for i in self.items))

class PricingStrategy:
  def price(self, cart): return cart.subtotal()

class TenOffOverHundred(PricingStrategy):
  def price(self, cart):
    sub = cart.subtotal()
    return Money(sub.cents - 1000, sub.currency) if sub.cents >= 10000 else sub

class PaymentGateway:
  def charge(self, card, amount): raise NotImplementedError

class VendorClient:
  def send(self, payload): return {"ok": True, "id": "tx_…"}

class PaymentAdapter(PaymentGateway):
  def __init__(self, client): self.client = client
  def charge(self, card, amount):
    payload = {"cc_no": card["number"], "amt": amount.cents, "cur": amount.currency}
    r = self.client.send(payload)
    return r["ok"], r.get("id")

class GatewayWithRetry(PaymentGateway):
  def __init__(self, inner, attempts=3): self.inner, self.attempts = inner, attempts
  def charge(self, card, amount):
    last = (False, None)
    for _ in range(self.attempts):
      ok, tx = self.inner.charge(card, amount)
      if ok: return True, tx
      last = (ok, tx)
    return last

class DomainEvents:
  _subs = {}
  @classmethod
  def on(cls, topic, fn): cls._subs.setdefault(topic, []).append(fn)
  @classmethod
  def emit(cls, topic, data):
    for fn in list(cls._subs.get(topic, [])): fn(data)

@dataclass(frozen=True)
class Order:
  id: str
  total: Money
  lines: tuple

class OrderService:
  def __init__(self, pricing: PricingStrategy, gateway: PaymentGateway):
    self.pricing, self.gateway = pricing, gateway
  def checkout(self, cart, card):
    total = self.pricing.price(cart)
    ok, tx = self.gateway.charge(card, total)
    if not ok: raise RuntimeError("payment failed")
    order = Order(id=tx, total=total, lines=tuple(cart.items))
    DomainEvents.emit("order.confirmed", {"id": order.id, "total": order.total.cents})
    return order

# wiring & sample flow
cart = Cart(); cart.add("BOOK-001", 2, 3500); cart.add("BOOK-002", 1, 1200)
pricing = TenOffOverHundred()
gateway = GatewayWithRetry(PaymentAdapter(VendorClient()))
DomainEvents.on("order.confirmed", lambda e: print(f"confirmed {e['id']} for {e['total']}"))
order = OrderService(pricing, gateway).checkout(cart, {"number": "4111 1111 1111 1111"})
⚠️ Bind event handlers at the edge and keep payloads immutable. Mutable shared state across observers invites race conditions and spooky action at a distance.

Reflection on Tradeoffs

Strategy keeps business rules swappable and testable; this is worth it when promotions multiply. Adapter prevents vendor types from leaking into the domain; this reduces migration pain later. Decorator lets reliability concerns like retry or logging wrap a gateway instance without subclass sprawl. Observer decouples side effects such as email from the happy path; this helps throughput yet demands care with ordering and volume control. If the project shrinks or stabilizes, you can inline strategies or drop the bus for direct calls. Patterns serve the story; they do not write it.