Design Patterns (GoF and modern) Module 10 – Anti-patterns and refactoring to patterns
Anti-patterns: god objects and golden hammers
Spot god objects, golden hammers, lava flow and other anti-patterns by their symptoms, measure a god class with scripts, and split it safely.
What you will learn
- Recognise common design anti-patterns from their symptoms in code and in its change history
- Explain why each anti-pattern happens and which refactoring or pattern removes it
- Measure a class for size, mixed responsibilities and change hotspots with small scripts
- Distinguish a legitimate trade-off from an anti-pattern in a code review
Before you start
What you’ll learn · Before you start
What you will learn
- Recognise common design anti-patterns from their symptoms in code and in its change history
- Explain why each anti-pattern happens and which refactoring or pattern removes it
- Measure a class for size, mixed responsibilities and change hotspots with small scripts
- Distinguish a legitimate trade-off from an anti-pattern in a code review
Before you start
On this page
An anti-pattern is a common answer to a recurring problem that looks sensible when it is chosen and costs more than it saves later on, described together with a known way out. Two of them are famous enough to stand for the rest. A god object is a class that has taken over most of a program’s work: prices, stock, messages and reports all live in it, so nearly every change touches it and nobody can change it with confidence. A golden hammer is a familiar tool or technique applied to every problem, whether it fits or not, such as splitting every data file on commas or answering every new requirement with the team’s favourite pattern. Both begin as reasonable choices made under time pressure, which is exactly why they are so common.
This lesson measures a god class of 250 lines with a small script, splits it behind a facade with a check that compares 2,210 answers before and after the split, and then works through the other anti-patterns you are most likely to meet in a code review, each with its symptoms, its cause and its way out.
Why code drifts into a big ball of mud
Brian Foote and Joseph Yoder wrote the classic description of the structure most real systems end up with, in a paper first presented at the PLoP ’97 patterns conference:
A BIG BALL OF MUD is haphazardly structured, sprawling, sloppy, duct-tape and bailing wire, spaghetti code jungle.
Nobody plans one. A prototype that works is shipped instead of rewritten. A feature goes into whichever class already has the data it needs, and the next feature follows it there. The structure that would make the next change cheap is invisible to the people who decide what to pay for, while the deadline is very visible. Each step is a reasonable local decision, and the mud is their sum. The paper names the forces behind it, among them time, cost, experience, skill, visibility, change and scale, and it is careful not to blame the developers who work in such systems: the same pressures act on every team.
That matters for how you respond. Calling code “terrible” in a review does not move it; naming the force that shaped it does, because it points at the fix. If time pressure produced it, the fix has to fit into normal feature work, a small step at a time. If missing skill produced it, the fix includes teaching. The paper’s own advice runs the same way: keep the system working while you improve it piece by piece, fence off what you cannot fix yet, and rebuild from scratch only when repair has stopped paying.
The god object, measured
The class below, OrderManager, runs a small online shop. It is an invented example, but every part of it is
typical: comments that group its fields by job, one long method that does everything for a new order, money
formatting copied into several places, and flags that switch behaviour on and off. Next to it is an invented change
history of 60 commits in the shape that git log lists them, each line naming the methods a commit changed.
The script reads the class with Python’s ast module and answers three questions. How big is it? Does a proposed
split, the GROUPS at the top of the script, cut it cleanly, or do methods reach across groups? And where did the
changes happen?
Python · orders/measure.py
"""Measures a class with Python's ast module: its size, a proposed split of its fields, and its change history."""
import ast
from collections import Counter
from pathlib import Path
SOURCE, CLASS, HISTORY = "order_manager.py", "OrderManager", "history.txt"
# A proposed split: which fields belong together. Edit it and run the script again to test another split.
GROUPS = {
"pricing": ["products", "coupons", "tax_rate_bps", "free_delivery_from", "delivery_fee", "express_fee"],
"stock": ["stock", "held", "low_stock_level"],
"orders": ["orders", "next_order_id"],
"messages": ["outbox", "sms_enabled", "shop_name"],
"invoices": ["invoices", "next_invoice_no", "sales_by_day"],
}
tree = ast.parse(Path(SOURCE).read_text())
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == CLASS)
methods = [n for n in cls.body if isinstance(n, ast.FunctionDef)]
init = next(m for m in methods if m.name == "__init__")
fields = [t.attr for n in ast.walk(init) if isinstance(n, ast.Assign) for t in n.targets
if isinstance(t, ast.Attribute) and isinstance(t.value, ast.Name) and t.value.id == "self"]
modules = sorted(a.name for n in tree.body if isinstance(n, ast.Import) for a in n.names)
group_of = {f: g for g, members in GROUPS.items() for f in members}
missing = [f for f in fields if f not in group_of]
assert not missing, f"put these fields in a group: {missing}"
def length(node):
return node.end_lineno - node.lineno + 1
def self_attributes(node):
return {n.attr for n in ast.walk(node)
if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name) and n.value.id == "self"}
longest = max(methods, key=length)
print(f"{CLASS}: {length(cls)} lines, {len(methods)} methods, {len(fields)} fields, "
f"imports {', '.join(modules)}")
print(f"Longest method: {longest.name}, {length(longest)} lines\n")
# Which groups each method reaches through the fields it reads or writes.
belongs, spans = {g: [] for g in GROUPS}, []
for m in methods:
if m.name == "__init__":
continue
groups = sorted({group_of[a] for a in self_attributes(m) if a in group_of}, key=list(GROUPS).index)
if len(groups) == 1:
belongs[groups[0]].append(m)
else:
spans.append((m, groups))
print("The proposed split, method by method:")
for g, ms in belongs.items():
print(f" {g:<9}{len(GROUPS[g])} fields, {len(ms):>2} methods, {sum(map(length, ms)):>3} lines")
print(f" {len(spans)} methods use fields of several groups:")
for m, groups in sorted(spans, key=lambda s: -len(s[1])):
print(f" {m.name:<20}{len(groups)} groups: {', '.join(groups)}")
# The history: one commit per line, "hash | message | the methods it changed".
commits = [line.split(" | ") for line in Path(HISTORY).read_text().splitlines() if line.strip()]
by_name = {m.name: m for m in methods}
touched = [[name.strip() for name in changed.split(",")] for _, _, changed in commits]
per_method = Counter(name for names in touched for name in names if name in by_name)
per_group = Counter(g for names in touched
for g in {next((gr for gr, ms in belongs.items() if by_name[n] in ms), "a cross-group method")
for n in names if n in by_name})
print(f"\nChange history: {len(commits)} commits, {sum(1 for t in touched if any(n in by_name for n in t))} "
f"of them changed {CLASS}")
print(" commits that changed each part: " + ", ".join(f"{g} {c}" for g, c in per_group.most_common()))
print(" hotspots (changes x lines):")
for name, count in sorted(per_method.items(), key=lambda kv: (-kv[1] * length(by_name[kv[0]]), kv[0]))[:5]:
print(f" {name:<20}{count:>2} x {length(by_name[name]):>2} = {count * length(by_name[name])}")
quiet = max((m for m in methods if per_method[m.name] <= 1 and m.name != "__init__"), key=length)
print(f" largest method changed only once: {quiet.name}, {length(quiet)} lines")
print("\nAfter the split, in order_service.py:")
for c in (n for n in ast.parse(Path("order_service.py").read_text()).body if isinstance(n, ast.ClassDef)):
ms = [n for n in c.body if isinstance(n, ast.FunctionDef)]
top = max((m for m in ms if m.name != "__init__"), key=length)
print(f" {c.name:<13}{length(c):>4} lines, {len(ms):>2} methods, longest {top.name} ({length(top)} lines)") Output
OrderManager: 250 lines, 30 methods, 17 fields, imports json, re
Longest method: place_order, 39 lines
The proposed split, method by method:
pricing 6 fields, 8 methods, 55 lines
stock 3 fields, 2 methods, 4 lines
orders 2 fields, 4 methods, 10 lines
messages 3 fields, 3 methods, 8 lines
invoices 3 fields, 3 methods, 10 lines
9 methods use fields of several groups:
place_order 5 groups: pricing, stock, orders, messages, invoices
cancel_order 4 groups: stock, orders, messages, invoices
ship_order 3 groups: stock, orders, messages
resend_invoice 3 groups: orders, messages, invoices
remove_product 2 groups: pricing, stock
restock 2 groups: pricing, stock
low_stock_report 2 groups: pricing, stock
best_sellers 2 groups: pricing, orders
export_report_json 2 groups: orders, invoices
Change history: 60 commits, 56 of them changed OrderManager
commits that changed each part: a cross-group method 36, pricing 23, invoices 5, orders 4, messages 3, stock 2
hotspots (changes x lines):
place_order 18 x 39 = 702
quote 17 x 22 = 374
cancel_order 10 x 19 = 190
_invoice_text 4 x 14 = 56
ship_order 5 x 10 = 50
largest method changed only once: export_report_json, 10 lines
After the split, in order_service.py:
Pricing 66 lines, 12 methods, longest quote (13 lines)
Stock 38 lines, 10 methods, longest hold (4 lines)
Messages 25 lines, 6 methods, longest clear (4 lines)
Invoices 34 lines, 6 methods, longest issue (14 lines)
OrderService 166 lines, 29 methods, longest place_order (29 lines)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure.py
orders/order_manager.py
"""A small online shop's whole order process in one class: an invented example of a god object.
Amounts are whole paise (100 paise make a rupee), so no rounding of floats creeps in.
"""
import json
import re
class OrderManager:
"""Prices, stock, orders, messages, invoices and reports, all in one place."""
def __init__(self, tax_rate_bps=1800, free_delivery_from=49_900, express_fee=9_900):
# pricing
self.products = {} # sku -> {"name": ..., "price": paise}
self.coupons = {} # code -> {"kind": "percent" or "flat", "value": ..., "min_spend": paise}
self.tax_rate_bps = tax_rate_bps # 1800 basis points is 18 %
self.free_delivery_from = free_delivery_from
self.delivery_fee = 4_000
self.express_fee = express_fee
# stock
self.stock = {} # sku -> units on the shelf
self.held = {} # order id -> [(sku, units)]
self.low_stock_level = 3
# orders
self.orders = {} # order id -> dict
self.next_order_id = 1001
# messages
self.outbox = [] # (channel, to, text)
self.sms_enabled = True
self.shop_name = "Paper Plane"
# invoices and reports
self.invoices = {} # invoice number -> text
self.next_invoice_no = 1
self.sales_by_day = {} # day number -> paise
# ---- catalogue ----------------------------------------------------------------------------
def add_product(self, sku, name, price):
if price <= 0:
raise ValueError(f"price of {sku} must be positive")
self.products[sku] = {"name": name, "price": price}
def set_price(self, sku, price):
if sku not in self.products:
raise KeyError(sku)
self.products[sku]["price"] = price
def remove_product(self, sku):
self.products.pop(sku, None)
self.stock.pop(sku, None)
def price_of(self, sku):
return self.products[sku]["price"]
def add_coupon(self, code, kind, value, min_spend=0):
if kind not in ("percent", "flat"):
raise ValueError(f"unknown coupon kind {kind}")
self.coupons[code.upper()] = {"kind": kind, "value": value, "min_spend": min_spend}
def expire_coupon(self, code):
self.coupons.pop(code.upper(), None)
def coupon_is_valid(self, code, subtotal):
coupon = self.coupons.get(code.upper())
return coupon is not None and subtotal >= coupon["min_spend"]
def quote(self, items, coupon=None, express=False):
subtotal = 0
for sku, units in items:
subtotal += self.products[sku]["price"] * units
discount = 0
if coupon:
c = self.coupons.get(coupon.upper())
if c and subtotal >= c["min_spend"]:
if c["kind"] == "percent":
discount = subtotal * c["value"] // 100
else:
discount = min(c["value"], subtotal)
taxed = subtotal - discount
tax = (taxed * self.tax_rate_bps + 5_000) // 10_000
if express:
delivery = self.express_fee
elif taxed >= self.free_delivery_from:
delivery = 0
else:
delivery = self.delivery_fee
return {"subtotal": subtotal, "discount": discount, "tax": tax, "delivery": delivery,
"total": taxed + tax + delivery}
# ---- stock --------------------------------------------------------------------------------
def restock(self, sku, units):
if sku not in self.products:
raise KeyError(sku)
self.stock[sku] = self.stock.get(sku, 0) + units
def available(self, sku):
return self.stock.get(sku, 0)
def units_held(self):
return sum(units for lines in self.held.values() for _, units in lines)
def low_stock_report(self):
lines = []
for sku in sorted(self.stock):
if self.stock[sku] <= self.low_stock_level:
lines.append(f"{sku} {self.products[sku]['name']}: {self.stock[sku]} left")
return lines
# ---- orders -------------------------------------------------------------------------------
def place_order(self, customer, email, phone, items, coupon=None, express=False, day=1):
if not items:
raise ValueError("an order needs at least one item")
if not re.fullmatch(r"\+?\d{10,13}", phone):
raise ValueError(f"bad phone number {phone}")
for sku, units in items:
if sku not in self.products:
raise ValueError(f"unknown product {sku}")
if units <= 0:
raise ValueError(f"bad quantity {units} for {sku}")
if self.stock.get(sku, 0) < units:
raise ValueError(f"only {self.stock.get(sku, 0)} of {sku} left")
order_id = self.next_order_id
self.next_order_id += 1
for sku, units in items:
self.stock[sku] -= units
self.held[order_id] = list(items)
prices = self.quote(items, coupon, express)
self.orders[order_id] = {
"id": order_id, "customer": customer, "email": email, "phone": phone,
"items": list(items), "coupon": coupon, "express": express, "day": day,
"status": "placed", "prices": prices, "tracking": None,
}
self.sales_by_day[day] = self.sales_by_day.get(day, 0) + prices["total"]
total = prices["total"]
rupees = f"{total // 100:,}.{total % 100:02d}"
if self.sms_enabled:
self.outbox.append(("sms", phone, f"{self.shop_name}: order {order_id} placed, {rupees} to pay."))
body = f"Hello {customer},\nwe have your order {order_id}.\n"
for sku, units in items:
line = self.products[sku]["price"] * units
body += f" {units} x {self.products[sku]['name']}: {line // 100:,}.{line % 100:02d}\n"
body += f"Total {rupees}" + (" (express delivery)" if express else "") + "\n"
self.outbox.append(("email", email, f"Your order {order_id}\n{body}"))
number = self.next_invoice_no
self.next_invoice_no += 1
self.invoices[number] = self._invoice_text(number, self.orders[order_id])
self.orders[order_id]["invoice"] = number
return order_id
def cancel_order(self, order_id, reason, day=1):
order = self.orders.get(order_id)
if order is None:
raise KeyError(order_id)
if order["status"] == "shipped":
raise ValueError(f"order {order_id} has shipped and cannot be cancelled")
if order["status"] == "cancelled":
return False
for sku, units in self.held.pop(order_id, []):
self.stock[sku] = self.stock.get(sku, 0) + units
order["status"] = "cancelled"
order["reason"] = reason
self.sales_by_day[day] = self.sales_by_day.get(day, 0) - order["prices"]["total"]
total = order["prices"]["total"]
rupees = f"{total // 100:,}.{total % 100:02d}"
if self.sms_enabled:
self.outbox.append(("sms", order["phone"], f"{self.shop_name}: order {order_id} cancelled, {rupees} refunded."))
self.outbox.append(("email", order["email"], f"Order {order_id} cancelled\nReason: {reason}\nRefund {rupees}\n"))
return True
def ship_order(self, order_id, tracking):
order = self.orders[order_id]
if order["status"] != "placed":
raise ValueError(f"order {order_id} is {order['status']}")
order["status"] = "shipped"
order["tracking"] = tracking
self.held.pop(order_id, None)
if self.sms_enabled:
self.outbox.append(("sms", order["phone"], f"{self.shop_name}: order {order_id} shipped, tracking {tracking}."))
self.outbox.append(("email", order["email"], f"Order {order_id} is on its way\nTracking: {tracking}\n"))
def status_of(self, order_id):
return self.orders[order_id]["status"]
def orders_of(self, customer):
return [o["id"] for o in self.orders.values() if o["customer"] == customer]
def customer_spend(self, customer):
return sum(o["prices"]["total"] for o in self.orders.values()
if o["customer"] == customer and o["status"] != "cancelled")
def refund_amount(self, order_id):
order = self.orders[order_id]
return order["prices"]["total"] if order["status"] == "cancelled" else 0
# ---- messages -----------------------------------------------------------------------------
def set_sms(self, enabled):
self.sms_enabled = enabled
def messages_for(self, to):
return [text for channel, address, text in self.outbox if address == to]
def clear_outbox(self):
sent = len(self.outbox)
self.outbox = []
return sent
def resend_invoice(self, order_id):
order = self.orders[order_id]
self.outbox.append(("email", order["email"], self.invoices[order["invoice"]]))
# ---- invoices and reports -----------------------------------------------------------------
def _invoice_text(self, number, order):
p = order["prices"]
def money(paise):
return f"{paise // 100:,}.{paise % 100:02d}"
rows = [f"Invoice {number} for order {order['id']}, {order['customer']}"]
for sku, units in order["items"]:
rows.append(f" {sku:<12}{units:>3} x {money(self.products[sku]['price']):>10}")
rows.append(f" Subtotal {money(p['subtotal']):>18}")
if p["discount"]:
rows.append(f" Discount {money(-p['discount']):>18}")
rows.append(f" Tax {money(p['tax']):>18}")
rows.append(f" Delivery {money(p['delivery']):>18}")
rows.append(f" Total {money(p['total']):>18}")
return "\n".join(rows)
def invoice_text(self, number):
return self.invoices[number]
def sales_report(self):
lines = []
for day in sorted(self.sales_by_day):
paise = self.sales_by_day[day]
lines.append(f"day {day}: {paise // 100:,}.{paise % 100:02d}")
return lines
def day_total(self, day):
return self.sales_by_day.get(day, 0)
def best_sellers(self, n=3):
units = {}
for order in self.orders.values():
if order["status"] == "cancelled":
continue
for sku, count in order["items"]:
units[sku] = units.get(sku, 0) + count
ranked = sorted(units.items(), key=lambda kv: (-kv[1], kv[0]))
return [f"{self.products[sku]['name']}: {count}" for sku, count in ranked[:n]]
def export_report_json(self):
report = {
"orders": len(self.orders),
"cancelled": sum(1 for o in self.orders.values() if o["status"] == "cancelled"),
"shipped": sum(1 for o in self.orders.values() if o["status"] == "shipped"),
"units_held": self.units_held(),
"sales": {str(day): paise for day, paise in sorted(self.sales_by_day.items())},
"low_stock": self.low_stock_report(),
}
return json.dumps(report, sort_keys=True) orders/history.txt
3f2a9c1 | first version of the shop | add_product, price_of, quote, restock, available, place_order, status_of
8b41d07 | send an SMS when an order is placed | place_order, set_sms
c07e5aa | percent coupons | add_coupon, quote, place_order
1d9e3b2 | flat coupons and a minimum spend | add_coupon, coupon_is_valid, quote
e5c8f40 | cancel orders and refund | cancel_order, refund_amount
42aa1f9 | fix: refund sent twice when cancelling twice | cancel_order
9e06b3d | invoices | place_order, _invoice_text, invoice_text
71f0c2e | free delivery above 499 rupees | quote
b38d5a4 | shipping with tracking numbers | ship_order, status_of
0c2e7f1 | update the readme | (other files)
5a7b9e0 | tax rounding to the nearest paisa | quote
f61c3d8 | fix: coupon codes are case-sensitive | add_coupon, quote, coupon_is_valid
2e9a40b | low stock report | low_stock_report
aa03c6f | express delivery | quote, place_order
6d5e1b9 | show express delivery in the order email | place_order
97c2f3e | customer history page | orders_of, customer_spend
c8b6a12 | daily sales report | sales_report, place_order
3b7d0e4 | fix: cancelled orders counted in sales | cancel_order, sales_report
e1f4a83 | resend an invoice from the help desk | resend_invoice
54c9b7f | lint configuration | (other files)
0f8e2d6 | price changes from the catalogue screen | set_price
d2a6c40 | discount line on invoices | _invoice_text
8c1f5e9 | fix: negative discount on flat coupons above the subtotal | quote
19b0d7a | shop name in every SMS | place_order, cancel_order, ship_order
b4e7c31 | best sellers on the home page | best_sellers
7a3d9f2 | fix: best sellers counted cancelled orders | best_sellers
e0c5b18 | report as JSON for the dashboard | export_report_json
63f8a2d | wording of the cancellation email | cancel_order
d9b1e07 | coupon expiry | expire_coupon
2c4f6a3 | phone number check | place_order
f7a0c95 | fix: phone numbers with a leading plus sign | place_order
4e2b8d1 | tax rate from the settings screen | quote
a1d6f08 | remove discontinued products | remove_product
8f3c2b7 | upgrade the test runner | (other files)
5d0e9a6 | wording of the order email | place_order
c3a7e52 | holds released when an order ships | ship_order, units_held
91e4b0c | fix: stock not returned on cancel | cancel_order
0b6f3d8 | delivery fee raised to 40 rupees | quote
e8a2c17 | invoice layout for the accountant | _invoice_text
36d9f4a | festival coupon: 15 percent off | quote
fa5c0b2 | fix: express fee charged on free delivery | quote
72b1e9d | messages screen for the help desk | messages_for, clear_outbox
cd4e8a5 | SMS can be switched off | set_sms, place_order, cancel_order, ship_order
1e7f3c9 | refund line in the cancellation SMS | cancel_order
b9c0d4e | invoice numbers start again at 1 for the audit | place_order
4a8e2f7 | fix: rounding of the percent discount | quote
e6d3b10 | show prices with thousands separators | place_order, cancel_order, _invoice_text, sales_report
2f9c7a8 | update dependencies | (other files)
8d5b1e3 | minimum spend for the festival coupon | quote, coupon_is_valid
c1a4f69 | cancellation reason in the email | cancel_order
5e0d8b2 | restock checks the product exists | restock
a7f2c3d | free delivery threshold back to 499 | quote
3c6e9b1 | express fee lowered to 99 rupees | quote, place_order
d0b8f47 | fix: email total ignored the coupon | place_order
94e1a6c | day totals for the finance team | day_total
6b3f0d9 | fix: orders with no items | place_order
e2c7a5b | tracking number in the shipping SMS | ship_order
7f9d2e0 | quantity check on each line | place_order
b5a0e3f | flat coupons capped at the subtotal | quote
0e4c8b6 | fix: low stock report crashed on removed products | low_stock_report, remove_product Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The numbers tell a story that reading the file top to bottom hides:
- Size. 250 lines, 30 methods and 17 fields. Real god classes can run to thousands of lines, but the symptoms
are already here: one method,
place_order, is 39 lines long. - The split. 20 methods sit cleanly in one group each, but 9 methods use fields of several groups, and
place_orderuses all five. The stock group owns only 2 tiny methods because the stock logic is written inline inplace_order,cancel_orderandship_order. Those cross-group methods are the coordinators, and in the split they become the facade. - The history. 56 of the 60 commits changed
OrderManager, and 36 of them changed a cross-group method. The hotspot score, changes multiplied by lines, putsplace_orderfirst at 702, andquotesecond at 374. That is where to start. - What not to touch.
export_report_jsonis 10 lines long but changed only once. A big method that nobody changes costs little; a medium one that changes every week costs a lot.
Metrics are hints, not verdicts. The script puts _invoice_text in the pricing group, because the invoice reads
product names and prices from the catalogue. Taken literally, that says the invoice belongs with pricing. The better
reading is that the invoice should receive the lines with the prices they were sold at, so it no longer needs the
catalogue at all, which is what the split does. Edit GROUPS and run the script again to test another split: a group
that leaves few methods crossing into others is a good candidate for its own class.
On a real repository, git log --format= --name-only lists the files of every commit, so counting the lines it prints
gives the changes per file, and git log -L :place_order:shop/orders.py shows every change to one function. Size and
change counts together point to the hotspots; neither is enough on its own.
Splitting it behind a facade
The split follows the groups. Pricing, Stock, Messages and Invoices each take the fields and the logic of one
job, and OrderService keeps every public method of the old class, so no caller has to change in the same step. It is
a facade: each method hands its work to the class that owns the job, and only the coordinators, such as
place_order, still do more than one thing. Its constructor accepts the four parts as parameters, which creates seams:
a test can pass in a different Pricing without editing the service.
Splitting a god object: a facade and one class per job
Text description of the diagram
The diagram reads from top to bottom.
- The first box is the class before the split: OrderManager, with 30 methods and 17 fields that cover prices, stock, orders, messages and invoices.
- An arrow labelled "split, with the tests green at every step" leads to the class after the split: OrderService, a facade with the same public methods.
- An arrow labelled "delegates to" leads from the facade to a group named "One job each", with four boxes in two columns: Pricing (prices, coupons and tax), Stock (units on the shelf and units held for orders), Messages (SMS and email) and Invoices (invoices and the sales of each day).
A split like this is only safe if the behaviour stays the same, and “the code looks equivalent” is not evidence. The check below runs 40 seeded scenarios through both versions. Each scenario stocks the shop, then places orders with valid and invalid items, invalid phone numbers, coupons above and below their minimum spend, express delivery, cancellations, shipments, price changes and text messages switched off, and records every answer the shop gives. Then it compares the two records, result by result. A second run does the same against a split with one deliberate slip: the minimum spend of a coupon was left behind in the old class.
Python · orders/split_check.py
"""Characterization check: the same seeded scenarios run through the god object and the split, compared result by result."""
import random
from order_manager import OrderManager
from order_service import OrderService, Pricing
PRODUCTS = [("PEN-BLUE", "Blue gel pen", 2_500), ("NOTE-A5", "A5 notebook", 12_000), ("INK-BLK", "Black ink", 18_000),
("BAG-CNV", "Canvas bag", 34_900), ("DESK-LMP", "Desk lamp", 129_900)]
PEOPLE = [("Asha", "asha@example.com", "+919800000001"), ("Ben", "ben@example.com", "07700900002"),
("Chen", "chen@example.com", "+8613800000003"), ("Dee", "dee@example.com", "12-34")]
def scenario(shop, seed):
"""Runs one seeded day of business and returns everything the shop answered, in order."""
rnd = random.Random(seed)
results = []
def call(name, *args):
try:
results.append((name, args, repr(getattr(shop, name)(*args))))
except (KeyError, ValueError) as error:
results.append((name, args, f"raises {type(error).__name__}: {error}"))
for sku, name, price in PRODUCTS:
call("add_product", sku, name, price)
call("restock", sku, rnd.randint(1, 8))
call("add_coupon", "save10", "percent", 10, 50_000)
call("add_coupon", "FLAT200", "flat", 20_000)
placed = []
for step in range(14):
action = rnd.choice(["place", "place", "place", "cancel", "ship", "restock", "price", "sms"])
if action == "place":
customer, email, phone = rnd.choice(PEOPLE)
skus = rnd.sample([p[0] for p in PRODUCTS] + ["GHOST-1"], rnd.randint(1, 3))
items = [(sku, rnd.randint(0, 4)) for sku in skus]
coupon = rnd.choice([None, None, "SAVE10", "save10", "FLAT200", "OLD5"])
express = rnd.random() < 0.25
call("quote", items, coupon, express)
before = len(results)
call("place_order", customer, email, phone, items, coupon, express, 1 + step // 5)
if not results[before][2].startswith("raises"):
placed.append(int(results[before][2]))
elif action == "cancel" and placed:
call("cancel_order", rnd.choice(placed + [9999]), "changed my mind", 1 + step // 5)
elif action == "ship" and placed:
call("ship_order", rnd.choice(placed), f"TRK{seed:03d}{step:02d}")
elif action == "restock":
call("restock", rnd.choice(PRODUCTS)[0], rnd.randint(1, 5))
elif action == "price":
call("set_price", rnd.choice(PRODUCTS)[0], rnd.randint(10, 200) * 100)
elif action == "sms":
call("set_sms", rnd.random() < 0.5)
for order_id in placed:
call("status_of", order_id)
call("refund_amount", order_id)
call("resend_invoice", order_id)
for customer, email, phone in PEOPLE:
call("orders_of", customer)
call("customer_spend", customer)
call("messages_for", email)
call("messages_for", phone)
for number in range(1, len(placed) + 1):
call("invoice_text", number)
for name in ("low_stock_report", "sales_report", "best_sellers", "units_held", "export_report_json"):
call(name)
return results
def compare(make_new, label, seeds=range(40)):
differing, values, first = 0, 0, None
for seed in seeds:
old, new = scenario(OrderManager(), seed), scenario(make_new(), seed)
values += len(old)
if old != new:
differing += 1
if first is None:
i = next(i for i, (a, b) in enumerate(zip(old, new)) if a != b)
first = (seed, old[i], new[i])
print(f"{label}: {differing} of {len(seeds)} scenarios differ ({values} results compared)")
if first:
seed, (name, args, was), (_, _, now) = first
print(f" first difference, scenario {seed}: {name}{args}")
print(f" god object: {was}")
print(f" split: {now}")
class ForgotMinimumSpend(Pricing):
"""The kind of slip a split invites: the coupon's minimum spend was left behind in the old class."""
def discount(self, code, subtotal):
coupon = self.coupons.get(code.upper()) if code else None
if not coupon:
return 0
return subtotal * coupon["value"] // 100 if coupon["kind"] == "percent" else min(coupon["value"], subtotal)
compare(OrderService, "OrderService")
compare(lambda: OrderService(pricing=ForgotMinimumSpend()), "OrderService with one slip") Output
OrderService: 0 of 40 scenarios differ (2210 results compared)
OrderService with one slip: 17 of 40 scenarios differ (2210 results compared)
first difference, scenario 0: quote([('PEN-BLUE', 4)], 'SAVE10', False)
god object: {'subtotal': 10000, 'discount': 0, 'tax': 1800, 'delivery': 4000, 'total': 15800}
split: {'subtotal': 10000, 'discount': 1000, 'tax': 1620, 'delivery': 4000, 'total': 14620}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_check.py
orders/order_service.py
"""The same shop after the split: four classes with one job each, and a facade that keeps the old public methods."""
import json
import re
def money(paise):
return f"{paise // 100:,}.{paise % 100:02d}"
class Pricing:
"""The catalogue's prices and the coupons, tax and delivery rules."""
def __init__(self, tax_rate_bps=1800, free_delivery_from=49_900, express_fee=9_900):
self.products = {}
self.coupons = {}
self.tax_rate_bps = tax_rate_bps
self.free_delivery_from = free_delivery_from
self.delivery_fee = 4_000
self.express_fee = express_fee
def add_product(self, sku, name, price):
if price <= 0:
raise ValueError(f"price of {sku} must be positive")
self.products[sku] = {"name": name, "price": price}
def set_price(self, sku, price):
if sku not in self.products:
raise KeyError(sku)
self.products[sku]["price"] = price
def remove_product(self, sku):
self.products.pop(sku, None)
def has(self, sku):
return sku in self.products
def name_of(self, sku):
return self.products[sku]["name"]
def price_of(self, sku):
return self.products[sku]["price"]
def add_coupon(self, code, kind, value, min_spend=0):
if kind not in ("percent", "flat"):
raise ValueError(f"unknown coupon kind {kind}")
self.coupons[code.upper()] = {"kind": kind, "value": value, "min_spend": min_spend}
def expire_coupon(self, code):
self.coupons.pop(code.upper(), None)
def coupon_is_valid(self, code, subtotal):
coupon = self.coupons.get(code.upper())
return coupon is not None and subtotal >= coupon["min_spend"]
def discount(self, code, subtotal):
coupon = self.coupons.get(code.upper()) if code else None
if not coupon or subtotal < coupon["min_spend"]:
return 0
if coupon["kind"] == "percent":
return subtotal * coupon["value"] // 100
return min(coupon["value"], subtotal)
def quote(self, items, coupon=None, express=False):
subtotal = sum(self.price_of(sku) * units for sku, units in items)
discount = self.discount(coupon, subtotal)
taxed = subtotal - discount
tax = (taxed * self.tax_rate_bps + 5_000) // 10_000
if express:
delivery = self.express_fee
elif taxed >= self.free_delivery_from:
delivery = 0
else:
delivery = self.delivery_fee
return {"subtotal": subtotal, "discount": discount, "tax": tax, "delivery": delivery,
"total": taxed + tax + delivery}
class Stock:
"""Units on the shelf, and the units held for orders that have not shipped."""
def __init__(self, low_stock_level=3):
self.units = {}
self.held = {}
self.low_stock_level = low_stock_level
def restock(self, sku, units):
self.units[sku] = self.units.get(sku, 0) + units
def available(self, sku):
return self.units.get(sku, 0)
def forget(self, sku):
self.units.pop(sku, None)
def check(self, sku, units):
if self.available(sku) < units:
raise ValueError(f"only {self.available(sku)} of {sku} left")
def hold(self, order_id, items):
for sku, units in items:
self.units[sku] -= units
self.held[order_id] = list(items)
def release(self, order_id):
for sku, units in self.held.pop(order_id, []):
self.units[sku] = self.units.get(sku, 0) + units
def settle(self, order_id):
self.held.pop(order_id, None)
def units_held(self):
return sum(units for lines in self.held.values() for _, units in lines)
def low(self):
return [(sku, self.units[sku]) for sku in sorted(self.units) if self.units[sku] <= self.low_stock_level]
class Messages:
"""The outbox of text messages and emails."""
def __init__(self, shop_name="Paper Plane"):
self.outbox = []
self.sms_enabled = True
self.shop_name = shop_name
def sms(self, phone, text):
if self.sms_enabled:
self.outbox.append(("sms", phone, f"{self.shop_name}: {text}"))
def email(self, address, text):
self.outbox.append(("email", address, text))
def set_sms(self, enabled):
self.sms_enabled = enabled
def messages_for(self, to):
return [text for channel, address, text in self.outbox if address == to]
def clear(self):
sent = len(self.outbox)
self.outbox = []
return sent
class Invoices:
"""Invoice texts and the sales of each day."""
def __init__(self):
self.texts = {}
self.next_number = 1
self.sales_by_day = {}
def issue(self, order_id, customer, lines, prices):
number = self.next_number
self.next_number += 1
rows = [f"Invoice {number} for order {order_id}, {customer}"]
for sku, units, price in lines:
rows.append(f" {sku:<12}{units:>3} x {money(price):>10}")
rows.append(f" Subtotal {money(prices['subtotal']):>18}")
if prices["discount"]:
rows.append(f" Discount {money(-prices['discount']):>18}")
rows.append(f" Tax {money(prices['tax']):>18}")
rows.append(f" Delivery {money(prices['delivery']):>18}")
rows.append(f" Total {money(prices['total']):>18}")
self.texts[number] = "\n".join(rows)
return number
def text_of(self, number):
return self.texts[number]
def record(self, day, amount):
self.sales_by_day[day] = self.sales_by_day.get(day, 0) + amount
def report(self):
return [f"day {day}: {money(self.sales_by_day[day])}" for day in sorted(self.sales_by_day)]
def day_total(self, day):
return self.sales_by_day.get(day, 0)
class OrderService:
"""A facade: the old public methods, each handing its work to the class that owns that job."""
def __init__(self, pricing=None, stock=None, messages=None, invoices=None):
self.pricing = pricing or Pricing()
self.stock = stock or Stock()
self.messages = messages or Messages()
self.invoices = invoices or Invoices()
self.orders = {}
self.next_order_id = 1001
# ---- catalogue and stock ------------------------------------------------------------------
def add_product(self, sku, name, price):
self.pricing.add_product(sku, name, price)
def set_price(self, sku, price):
self.pricing.set_price(sku, price)
def remove_product(self, sku):
self.pricing.remove_product(sku)
self.stock.forget(sku)
def price_of(self, sku):
return self.pricing.price_of(sku)
def add_coupon(self, code, kind, value, min_spend=0):
self.pricing.add_coupon(code, kind, value, min_spend)
def expire_coupon(self, code):
self.pricing.expire_coupon(code)
def coupon_is_valid(self, code, subtotal):
return self.pricing.coupon_is_valid(code, subtotal)
def quote(self, items, coupon=None, express=False):
return self.pricing.quote(items, coupon, express)
def restock(self, sku, units):
if not self.pricing.has(sku):
raise KeyError(sku)
self.stock.restock(sku, units)
def available(self, sku):
return self.stock.available(sku)
def units_held(self):
return self.stock.units_held()
def low_stock_report(self):
return [f"{sku} {self.pricing.name_of(sku)}: {units} left" for sku, units in self.stock.low()]
# ---- orders -------------------------------------------------------------------------------
def place_order(self, customer, email, phone, items, coupon=None, express=False, day=1):
if not items:
raise ValueError("an order needs at least one item")
if not re.fullmatch(r"\+?\d{10,13}", phone):
raise ValueError(f"bad phone number {phone}")
for sku, units in items:
if not self.pricing.has(sku):
raise ValueError(f"unknown product {sku}")
if units <= 0:
raise ValueError(f"bad quantity {units} for {sku}")
self.stock.check(sku, units)
order_id = self.next_order_id
self.next_order_id += 1
self.stock.hold(order_id, items)
prices = self.pricing.quote(items, coupon, express)
order = {"id": order_id, "customer": customer, "email": email, "phone": phone, "items": list(items),
"coupon": coupon, "express": express, "day": day, "status": "placed", "prices": prices,
"tracking": None}
self.orders[order_id] = order
self.invoices.record(day, prices["total"])
self.messages.sms(phone, f"order {order_id} placed, {money(prices['total'])} to pay.")
lines = [(sku, units, self.pricing.price_of(sku)) for sku, units in items]
body = f"Hello {customer},\nwe have your order {order_id}.\n"
for sku, units, price in lines:
body += f" {units} x {self.pricing.name_of(sku)}: {money(price * units)}\n"
body += f"Total {money(prices['total'])}" + (" (express delivery)" if express else "") + "\n"
self.messages.email(email, f"Your order {order_id}\n{body}")
order["invoice"] = self.invoices.issue(order_id, customer, lines, prices)
return order_id
def cancel_order(self, order_id, reason, day=1):
order = self.orders.get(order_id)
if order is None:
raise KeyError(order_id)
if order["status"] == "shipped":
raise ValueError(f"order {order_id} has shipped and cannot be cancelled")
if order["status"] == "cancelled":
return False
self.stock.release(order_id)
order["status"] = "cancelled"
order["reason"] = reason
total = order["prices"]["total"]
self.invoices.record(day, -total)
self.messages.sms(order["phone"], f"order {order_id} cancelled, {money(total)} refunded.")
self.messages.email(order["email"], f"Order {order_id} cancelled\nReason: {reason}\nRefund {money(total)}\n")
return True
def ship_order(self, order_id, tracking):
order = self.orders[order_id]
if order["status"] != "placed":
raise ValueError(f"order {order_id} is {order['status']}")
order["status"] = "shipped"
order["tracking"] = tracking
self.stock.settle(order_id)
self.messages.sms(order["phone"], f"order {order_id} shipped, tracking {tracking}.")
self.messages.email(order["email"], f"Order {order_id} is on its way\nTracking: {tracking}\n")
def status_of(self, order_id):
return self.orders[order_id]["status"]
def orders_of(self, customer):
return [o["id"] for o in self.orders.values() if o["customer"] == customer]
def customer_spend(self, customer):
return sum(o["prices"]["total"] for o in self.orders.values()
if o["customer"] == customer and o["status"] != "cancelled")
def refund_amount(self, order_id):
order = self.orders[order_id]
return order["prices"]["total"] if order["status"] == "cancelled" else 0
# ---- messages, invoices and reports -------------------------------------------------------
def set_sms(self, enabled):
self.messages.set_sms(enabled)
def messages_for(self, to):
return self.messages.messages_for(to)
def clear_outbox(self):
return self.messages.clear()
def resend_invoice(self, order_id):
order = self.orders[order_id]
self.messages.email(order["email"], self.invoices.text_of(order["invoice"]))
def invoice_text(self, number):
return self.invoices.text_of(number)
def sales_report(self):
return self.invoices.report()
def day_total(self, day):
return self.invoices.day_total(day)
def best_sellers(self, n=3):
units = {}
for order in self.orders.values():
if order["status"] == "cancelled":
continue
for sku, count in order["items"]:
units[sku] = units.get(sku, 0) + count
ranked = sorted(units.items(), key=lambda kv: (-kv[1], kv[0]))
return [f"{self.pricing.name_of(sku)}: {count}" for sku, count in ranked[:n]]
def export_report_json(self):
report = {
"orders": len(self.orders),
"cancelled": sum(1 for o in self.orders.values() if o["status"] == "cancelled"),
"shipped": sum(1 for o in self.orders.values() if o["status"] == "shipped"),
"units_held": self.units_held(),
"sales": {str(day): paise for day, paise in sorted(self.invoices.sales_by_day.items())},
"low_stock": self.low_stock_report(),
}
return json.dumps(report, sort_keys=True) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The split gave the same 2,210 results as the god object in all 40 scenarios. The slipped version differed in 17 of
them, and the first difference is easy to read: four pens cost 100.00, below the 500.00 minimum of the SAVE10
coupon, so the old class gave no discount, and the slipped split took 10.00 off. A check that can fail, and fails on a
mistake this small, is what lets you move code with confidence. The measuring script’s last lines show the result of
the split: Pricing has 12 methods and 66 lines, and the facade still has 29 methods because it keeps the old public
interface, but most of them are a single line. Moving the callers to the four classes directly can shrink it later.
Do the split one class at a time, with the check run after each step and a commit after each green run: pricing first, because it changes most, then stock, then messages and invoices. The next lessons in this module show how to write such checks for code you do not know yet, and how to take the steps safely.
The golden hammer
A golden hammer is a tool someone knows well and therefore uses everywhere. The investment in learning it was real, it solved the first problems well, and reaching for it again feels efficient. The cost appears when the problem changes shape and the tool does not. Splitting each line of a data file on commas is a typical example; it works until a value contains a comma:
"""A golden hammer: str.split(","), the tool the team reaches for every time, against the csv module on the same lines."""
import csv
import io
LINES = [
"PEN-BLUE,Blue gel pen,25.00",
'NOTE-A5,"Notebook, A5, ruled",120.00',
'BAG-CNV,"Canvas bag, ""Tote"" print",349.00',
]
print('line.split(","):')
for line in LINES:
fields = line.split(",")
print(f" {len(fields)} fields, price read as {fields[2]!r}")
print("csv.reader:")
for row in csv.reader(io.StringIO("\n".join(LINES))):
print(f" {len(row)} fields, name {row[1]!r}, price {row[2]!r}") Output
line.split(","):
3 fields, price read as '25.00'
5 fields, price read as ' A5'
4 fields, price read as ' ""Tote"" print"'
csv.reader:
3 fields, name 'Blue gel pen', price '25.00'
3 fields, name 'Notebook, A5, ruled', price '120.00'
3 fields, name 'Canvas bag, "Tote" print', price '349.00'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 csv_split.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Splitting on commas read the first line correctly and the other two wrongly: the quoted notebook name became three
fields, so the price came out as ' A5', and the escaped quotes in the bag’s name left a fragment of text where the
price should be. The csv module follows the quoting rules and returns three fields for every line. Nothing in the
comma version was careless; it was the familiar tool, used one problem too far.
In design work the hammer is usually a structure rather than a function: every variation answered with a subclass, every call between two modules routed through an event bus, every shared object made a Singleton, every new feature a separate service. Each is right for some problems. The anti-pattern is choosing it before looking at the problem. The way out is a habit: name the problem’s shape first, list at least two ways to solve it, and try the standard tool for that shape before the favourite one.
A catalogue of the others
The book AntiPatterns by William Brown, Raphael Malveau, Hays McCormick and Thomas Mowbray gave many of these names wide currency. It calls the god object the Blob; most teams say god object or god class. Each entry below gives the symptoms you can spot, why it happens and the usual way out.
Leftovers: lava flow, poltergeists, copy-paste and spaghetti code
- Lava flow. Code from an earlier design or an abandoned experiment stays in place because nobody knows whether anything still uses it. Symptoms: functions nobody calls on purpose, comments such as “do not remove”, and branches for cases that no longer occur. It happens when the people who knew why the code exists have moved on. The way out is to find the callers (search, call graphs, logs in production), cover the live paths with tests and delete the rest; version control keeps every deleted line.
- Poltergeists. Small classes with no state of their own appear briefly only to call methods on other objects: a
ReportStarterthat creates a report, callsrun()and is thrown away. They add a name and a hop without adding a decision. The way out is to inline them and move their behaviour to the class that owns the data. - Copy-paste programming. A block is reused by copying it, so a fix must be made in every copy, and sooner or later
one copy is missed.
OrderManagerformats money in five places. The way out is to extract the shared part into a function once a third copy appears, and to make the copies identical first so that the extraction changes nothing. - Spaghetti code. Control flow so tangled that a reader cannot follow one case through it: deep nesting, flags set in one place and tested far away, early exits in the middle of long methods. The way out is small extractions, each with a name that says what the extracted part decides, made under the protection of tests.
Objects without behaviour: the anemic domain model
Martin Fowler describes an anemic domain model as one that looks like a real domain model, with classes named after
the business’s concepts, but whose objects hold data and almost no behaviour, while service classes do all the work.
His objection is that it pays the costs of a domain model, such as mapping the objects to a database, without any of
the benefits, because the rules that belong to an order or an account live elsewhere and are repeated wherever they are
needed. The way out is to move behaviour next to the data it uses: order.cancel(reason) instead of
OrderService.cancel(order, reason) reaching into the order’s fields. Plain data objects are right in some places,
though: values sent across an API boundary or written to a file are supposed to be data, and a thin service layer
that coordinates domain objects is part of the design Fowler recommends.
Inheritance that needs a reminder: call super
Fowler calls this a minor smell of object-oriented frameworks: a base class works only if every subclass that overrides a method remembers to call the parent’s version first. Anything that must be remembered every time will eventually be forgotten:
"""Call Super: a base class that relies on every subclass remembering one line, then a Template Method that remembers it."""
ROWS = [("Blue gel pen", 41), ("A5 notebook", 12)]
class Report:
"""Subclasses must start render() with super().render(rows), or the audit trail misses them."""
def __init__(self, audit):
self.audit = audit
def render(self, rows):
self.audit.append(f"{type(self).__name__}: {len(rows)} rows")
return ""
class SalesReport(Report):
def render(self, rows):
super().render(rows)
return "\n".join(f"{name}: {units} sold" for name, units in rows)
class StockReport(Report):
def render(self, rows): # the call to super().render(rows) was forgotten
return "\n".join(f"{name}: {units} left" for name, units in rows)
class AuditedReport:
"""A Template Method: render() always writes the audit line, then calls the hook that subclasses fill in."""
def __init__(self, audit):
self.audit = audit
def render(self, rows):
self.audit.append(f"{type(self).__name__}: {len(rows)} rows")
return self.body(rows)
def body(self, rows):
raise NotImplementedError
class SalesBody(AuditedReport):
def body(self, rows):
return "\n".join(f"{name}: {units} sold" for name, units in rows)
class StockBody(AuditedReport):
def body(self, rows):
return "\n".join(f"{name}: {units} left" for name, units in rows)
for title, classes in (("Call Super", (SalesReport, StockReport)), ("Template Method", (SalesBody, StockBody))):
audit = []
for cls in classes:
cls(audit).render(ROWS)
print(f"{title}: {len(audit)} of {len(classes)} reports audited {audit}") Output
Call Super: 1 of 2 reports audited ['SalesReport: 2 rows'] Template Method: 2 of 2 reports audited ['SalesBody: 2 rows', 'StockBody: 2 rows']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 call_super.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
StockReport forgot its super().render(rows), so only 1 of the 2 reports reached the audit trail, and nothing
failed to tell anyone. The fix moves the reminder into the base class: render() becomes a Template Method that always
writes the audit line and then calls body(), a hook that subclasses override, so both reports were audited.
Hidden switches: flag arguments
A flag argument is a boolean parameter that makes a function do one of two different things, as in
render(rows, html=True). At the call site, render(rows, True) says nothing about which of the two the caller wants.
Fowler’s advice is to prefer two functions, render_text() and render_html(), and, when their code is tangled, to keep
the flagged version as a private helper behind them. There is a legitimate case too: when the value comes straight
from data, such as the express-delivery box a customer ticks at checkout, passing it through as a flag is simpler than
an if that chooses between two functions. OrderManager.place_order(..., express=...) is that case; a boolean that
picked between two unrelated pricing rules would not be.
Hidden dependencies: Singleton abuse and Service Locator
A Singleton that holds changing state, such as the current user or a shared cart, is a global variable with a class
around it. Every function can reach it, so no signature tells you what a function depends on, and tests that change it
leak into each other. The way out is to create the object once at the start of the program and pass it to whoever
needs it, through constructor parameters as OrderService does.
A Service Locator is a registry that classes ask for their collaborators: Locator.get("pricing") instead of a
pricing parameter. It is not an anti-pattern by itself. Fowler compares it with dependency injection and finds the
two roughly equivalent inside one application; the difference he points out is that every class using a locator
depends on the locator, and to see what a class needs you have to search its code for calls to it. For components that
other teams’ applications will use, he prefers injection. So the question for a review is whether that hidden list
of dependencies is a problem here, not whether a locator appears at all.
Trade-off or anti-pattern? Questions for a code review
The same construct can be a sound decision in one place and an anti-pattern in another. What separates them is whether the cost was chosen on purpose, for a reason that still holds:
- A very large class is a trade-off when it is generated code, such as a parser, or a lookup table that nobody edits by hand. It is an anti-pattern when it is hand-written and most commits change it.
- A Singleton is a trade-off for one read-only configuration, loaded at start-up. It is an anti-pattern when it holds changing state, such as the current user, shared by everything.
- A flag argument is a trade-off when it passes a value straight through from a form or a file. It is an anti-pattern when the boolean picks one of two unrelated behaviours.
- Data-only objects are a trade-off for messages at an API boundary or rows written to a file. They are an anti-pattern when they are domain objects whose rules live in services.
- A service locator is a trade-off as the plugin registry of a host application. It is an anti-pattern when application classes ask a global locator for everything.
In a review, three questions sort most cases. Is the reason written down, in the code, the commit or the design notes? Does the reason still hold, or did the second variant, the test double or the plugin it was built for never arrive? And is the cost growing: is this the class that every pull request touches? A decision with a reason that still holds is a trade-off, even if it looks odd. A construct nobody can justify, whose cost grows with every change, is an anti-pattern, however familiar it looks.
Practise: split a booking desk
A bus company’s BookingDesk holds fares, seats and tickets in one class. Split it into a Fares class and a
SeatMap class behind the desk as a facade, so that every answer the desk gives stays the same. The first two sample
tests are characterization tests that pin those answers down; the other two check the shape of the split.
Exercise · Medium · Python
Split a booking desk into two classes and a facade
booking.py holds a bus company's whole booking desk in one class: the fare rules, the free seats of each route and the tickets. Split it the way the lesson split OrderManager, without changing anything the desk answers.
- Move the fare rules into a class
Fares, created asFares(base_fares), with a methodprice(route, child=False, weekend=False)and the two percentages as its own fields. - Move the free seats into a class
SeatMap, created asSeatMap(routes, seats_per_bus), withtake(route)(it returns the lowest free seat, or raises the "full" error),left(route)andgive_back(route, seat). - Keep
BookingDeskas a facade: the same constructor and the same five public methods, each handing its work to aFaresor aSeatMapthat the desk holds. The tickets stay in the desk.
The first two sample tests are characterization tests: they pin down what the desk answers today, so run them before you change anything, and after every step. The other two check the structure of the split.
Starter code · booking.py
"""A bus company's booking desk: fares, seats and tickets, all in one class."""
class BookingDesk:
def __init__(self, base_fares, seats_per_bus):
# fares
self.base_fares = dict(base_fares) # route -> paise
self.weekend_surcharge_pct = 10
self.child_discount_pct = 50
# seats
self.free_seats = {route: set(range(1, seats_per_bus + 1)) for route in self.base_fares}
# tickets
self.tickets = {} # ticket number -> (route, seat, passenger, fare)
self.next_ticket = 1
def fare(self, route, child=False, weekend=False):
price = self.base_fares[route]
if weekend:
price += price * self.weekend_surcharge_pct // 100
if child:
price -= price * self.child_discount_pct // 100
return price
def seats_left(self, route):
return len(self.free_seats[route])
def book(self, route, passenger, child=False, weekend=False):
if not self.free_seats[route]:
raise ValueError(f"route {route} is full")
seat = min(self.free_seats[route])
self.free_seats[route].remove(seat)
number = self.next_ticket
self.next_ticket += 1
self.tickets[number] = (route, seat, passenger, self.fare(route, child, weekend))
return number
def cancel(self, number):
route, seat, _, fare = self.tickets.pop(number)
self.free_seats[route].add(seat)
return fare
def ticket(self, number):
route, seat, passenger, fare = self.tickets[number]
return f"Ticket {number}: {passenger}, {route}, seat {seat}, {fare // 100}.{fare % 100:02d}" The sample tests · test_booking.py
import inspect
import booking
from booking import BookingDesk
ROUTES = {"PUNE-GOA": 89_900, "PUNE-NASHIK": 45_075}
def new_desk():
return BookingDesk(ROUTES, 3)
# Characterization tests: what the desk answers today, written down before anything moves.
def test_fares_today():
"""the weekend surcharge comes first, then the child discount, in whole paise"""
desk = new_desk()
assert desk.fare("PUNE-GOA") == 89_900
assert desk.fare("PUNE-GOA", weekend=True) == 98_890
assert desk.fare("PUNE-GOA", child=True) == 44_950
assert desk.fare("PUNE-GOA", child=True, weekend=True) == 49_445
assert desk.fare("PUNE-NASHIK", child=True, weekend=True) == 24_791
def test_seats_and_tickets_today():
"""seats go lowest first, a cancelled seat is reused, and a full bus refuses"""
desk = new_desk()
assert (desk.book("PUNE-GOA", "Asha"), desk.book("PUNE-GOA", "Ben", child=True)) == (1, 2)
assert desk.seats_left("PUNE-GOA") == 1
assert desk.ticket(2) == "Ticket 2: Ben, PUNE-GOA, seat 2, 449.50"
assert desk.cancel(1) == 89_900
assert desk.seats_left("PUNE-GOA") == 2
third = desk.book("PUNE-GOA", "Chen", weekend=True)
assert desk.ticket(third) == "Ticket 3: Chen, PUNE-GOA, seat 1, 988.90"
desk.book("PUNE-GOA", "Dee")
try:
desk.book("PUNE-GOA", "Eve")
except ValueError as error:
assert str(error) == "route PUNE-GOA is full"
else:
raise AssertionError("a full bus must refuse a booking")
assert (desk.seats_left("PUNE-GOA"), desk.seats_left("PUNE-NASHIK")) == (0, 3)
# The structure the split asks for.
def test_a_facade_over_two_classes():
"""the desk holds one Fares and one SeatMap, and none of their fields"""
assert inspect.isclass(getattr(booking, "Fares", None)), "define a class Fares"
assert inspect.isclass(getattr(booking, "SeatMap", None)), "define a class SeatMap"
held = sorted(type(value).__name__ for value in vars(new_desk()).values())
assert held.count("Fares") == 1 and held.count("SeatMap") == 1, f"the desk holds {held}"
for name in ("base_fares", "weekend_surcharge_pct", "child_discount_pct", "free_seats"):
assert not hasattr(new_desk(), name), f"BookingDesk still has {name}: move it into Fares or SeatMap"
def test_the_new_classes_work_on_their_own():
"""Fares.price, SeatMap.take, SeatMap.left and SeatMap.give_back work without a desk"""
fares = booking.Fares(ROUTES)
assert (fares.price("PUNE-GOA"), fares.price("PUNE-GOA", child=True, weekend=True)) == (89_900, 49_445)
seats = booking.SeatMap(list(ROUTES), 2)
assert (seats.take("PUNE-NASHIK"), seats.take("PUNE-NASHIK"), seats.left("PUNE-NASHIK")) == (1, 2, 0)
seats.give_back("PUNE-NASHIK", 1)
assert (seats.left("PUNE-NASHIK"), seats.take("PUNE-NASHIK")) == (1, 1) A hint
Read __init__ first: its comments already group the fields into fares, seats and tickets. Start with Fares. Copy fare() into it as price(), give Fares the three fare fields, create one in the desk's __init__, and make BookingDesk.fare() call it. Run the tests, then remove the old fields from the desk. Do the same for the seats.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
8 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is a god object, and how do you start splitting one?
A god object is a class that has taken on most of a program’s responsibilities, such as pricing, stock, messages and reports, so that nearly every change touches it and its tests are slow and fragile. It grows one convenient addition at a time, usually under deadline pressure. To split one, first pin down its current behaviour with characterization tests. Then group its fields by the job they serve and check which methods use fields from several groups: those are coordinators. Extract one group at a time into its own class, starting with the part that changes most often, and keep the old class as a facade with the same public methods, so callers do not change in the same step. Run the tests after every extraction and commit each green step.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
A 3,000-line OrderService is changed by most pull requests and its test suite takes nine minutes. The team cannot stop feature work for a rewrite. How do you split it, and how do you show that nothing broke along the way?
-
Mid to senior
A pull request replaces the constructor parameters of twenty classes with calls to a global ServiceLocator, to make wiring easier. Do you approve it? What would you ask for or change?
Key takeaways
- An anti-pattern is a familiar answer that costs more than it saves, with a known way out; most of them start as sensible decisions under pressure.
- A god object shows itself in numbers: in the example, 30 methods, 17 fields, 9 methods that cross the groups of a split, and 56 of 60 commits.
- Rank what to split by changes multiplied by size:
place_orderscored 702, while a 10-line method changed once scored almost nothing. - Split behind a facade, one class at a time, and prove each step with a check that can fail: the slipped split differed in 17 of 40 scenarios.
- A golden hammer is the familiar tool used one problem too far; name the problem’s shape before choosing the tool.
- Singletons, flag arguments, data-only objects and service locators are trade-offs when their reason is written down and still holds, and anti-patterns when nobody can say why they are there.
References
- Big Ball of Mud (Brian Foote and Joseph Yoder)
- AntiPatterns: Refactoring Software, Architectures, and Projects in Crisis (William J. Brown, Raphael C. Malveau, Hays W. McCormick and Thomas J. Mowbray (Wiley))
- Anemic Domain Model (Martin Fowler)
- Call Super (Martin Fowler)
- Flag Argument (Martin Fowler)
- Inversion of Control Containers and the Dependency Injection pattern (Martin Fowler)
- git-log: show commit logs (Git project)
- ast: Abstract Syntax Trees (Python Software Foundation)
- csv: CSV file reading and writing (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress