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.
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"})
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.