Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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?

Measuring a god class: size, a proposed split and the change history

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

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_order uses all five. The stock group owns only 2 tiny methods because the stock logic is written inline in place_order, cancel_order and ship_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, puts place_order first at 702, and quote second at 374. That is where to start.
  • What not to touch. export_report_json is 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.

OrderManager, one class with five jobs, is split into an OrderService facade and four classes: Pricing, Stock, Messages, Invoices.Before: OrderManager30 methods, 17 fields:prices, stock, orders,messages, invoicesAfter: OrderService,a facade with thesame public methodsOne job eachPricingprices, coupons,taxStockshelf andheld unitsMessagesSMS and emailInvoicesinvoices anddaily salessplit, with the testsgreen at every stepdelegates to

Splitting a god object: a facade and one class per job

Text description of the diagram

The diagram reads from top to bottom.

  1. The first box is the class before the split: OrderManager, with 30 methods and 17 fields that cover prices, stock, orders, messages and invoices.
  2. 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.
  3. 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.

The same 40 scenarios through the god object and the split

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)

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:

The same three lines, split on commas and read as CSV Python · hammer/csv_split.py
"""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

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 ReportStarter that creates a report, calls run() 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. OrderManager formats 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:

A forgotten super() call, and a Template Method that cannot forget Python · super/call_super.py
"""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

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 as Fares(base_fares), with a method price(route, child=False, weekend=False) and the two percentages as its own fields.
  • Move the free seats into a class SeatMap, created as SeatMap(routes, seats_per_bus), with take(route) (it returns the lowest free seat, or raises the "full" error), left(route) and give_back(route, seat).
  • Keep BookingDesk as a facade: the same constructor and the same five public methods, each handing its work to a Fares or a SeatMap that 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.

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.

Python Online Compiler Paste one of your own classes into measure.py, set CLASS and GROUPS, and see how cleanly it splits. Text Diff Compare order_manager.py with order_service.py to see where each line of the god object went.

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.

  1. Question 1 of 8 What makes a design an anti-pattern rather than just a mistake?

    Choose one answer.

    Show the answer to question 1

    Answer: It is a common, reasonable-looking answer to a recurring problem that costs more than it saves, with a known way out

    Anti-patterns recur because they look sensible when chosen, often under time pressure. What makes the description useful is the second half: the symptoms to recognise and the refactoring or pattern that leads out.

  2. Question 2 of 8 In the run of measure.py, how many methods of OrderManager use fields of more than one group of the proposed split?

    Type a number.

    Show the answer to question 2

    Answer: 9

    place_order uses all five groups, cancel_order four, ship_order and resend_invoice three, and five more methods two each. These coordinators are what the facade keeps; the other 20 methods move cleanly with their group.

  3. Question 3 of 8 measure.py ranks place_order (18 changes, 39 lines) far above export_report_json (1 change, 10 lines). What should the split do first?

    Choose one answer.

    Show the answer to question 3

    Answer: Start with the part that changes most, because every change there is paying the cost of the tangle

    Changes multiplied by size estimate where the god object costs the most. A method that nobody changes costs little even when it is long; the hotspot is where a cleaner structure pays back first.

  4. Question 4 of 8 The split with one slip returned the same order numbers as the god object. How did split_check.py still catch the slip?

    Choose one answer.

    Show the answer to question 4

    Answer: It records every answer the shop gives, including quotes, totals, messages and reports, and compares them all

    A characterization check pins down behaviour, not structure. The first difference was a quote: four pens below the coupon's minimum spend got a discount from the slipped split and none from the god object.

  5. Question 5 of 8 What does call_super.py print?

    What does this program print? Choose one answer.

    """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}")
    Show the answer to question 5

    Answer: it prints

    Call Super: 1 of 2 reports audited ['SalesReport: 2 rows']
    Template Method: 2 of 2 reports audited ['SalesBody: 2 rows', 'StockBody: 2 rows']

    StockReport overrides render() without calling super().render(rows), so its audit line is never written, and nothing fails. In the Template Method version the base class writes the line itself before calling the body() hook, so no subclass can forget it.

  6. Question 6 of 8 Which flag argument is the legitimate case?

    Choose one answer.

    Show the answer to question 6

    Answer: place_order(..., express=form.express_ticked), passing on a box the customer ticked

    When the value comes straight from data, such as a checkbox, passing it through is simpler than an if that picks one of two functions. A boolean that chooses between two unrelated behaviours hides two functions in one.

  7. Question 7 of 8 A pull request replaces constructor parameters with calls to Locator.get(). What is the useful review question?

    Choose one answer.

    Show the answer to question 7

    Answer: Is it a problem here that a class's dependencies can only be found by searching its code for locator calls?

    Fowler finds a locator and injection roughly equivalent inside one application. The real cost is that every class depends on the locator and hides its own list of dependencies, which matters most for code that other applications will use.

  8. Question 8 of 8 Why did splitting on commas fail on two of the three lines in csv_split.py?

    Choose one answer.

    Show the answer to question 8

    Answer: Their quoted values contain commas or escaped quotes, which a plain split does not understand

    CSV allows a comma inside a quoted value and doubles a quote character to escape it. The csv module follows those rules; a split on commas is the familiar tool used one problem too far.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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_order scored 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.