Case Study: Refactoring a Messy Codebase

This case study walks through a realistic refactoring of a student project that "works" but has accumulated significant design debt. The code is fictional but the patterns are drawn from real student projects and junior developer codebases.

The Scenario

Darius is a CS1 student who built an inventory management system for a local comic book shop as a side project. He's proud of it — it tracks comics, handles sales, and prints receipts. But after three months of adding features, the code has become difficult to maintain. Every time the shop owner asks for a new feature, Darius finds himself breaking something that used to work.

The owner just asked for two new features: (1) email receipts in addition to printed ones, and (2) a "pull list" system where customers can subscribe to new issues of their favorite series. Darius realizes that adding these features to his current code would be a nightmare.

Let's look at what he has, identify the problems, and refactor step by step.

The Original Code (Before)

class ComicShop:
    def __init__(self):
        self.comics = []  # list of dicts
        self.customers = []  # list of dicts
        self.sales = []  # list of dicts
        self.tax_rate = 0.08
        self.receipt_printer = "EPSON-TM88"
        self.store_name = "Kapow Comics"
        self.store_address = "123 Main St"

    def add_comic(self, title, issue, publisher, price, quantity):
        comic = {
            "title": title,
            "issue": issue,
            "publisher": publisher,
            "price": price,
            "quantity": quantity,
        }
        self.comics.append(comic)
        print(f"Added: {title} #{issue}")

    def find_comic(self, title, issue):
        for c in self.comics:
            if c["title"] == title and c["issue"] == issue:
                return c
        return None

    def sell_comic(self, customer_name, title, issue, quantity=1):
        comic = self.find_comic(title, issue)
        if comic is None:
            print(f"ERROR: {title} #{issue} not in inventory")
            return
        if comic["quantity"] < quantity:
            print(f"ERROR: Only {comic['quantity']} copies available")
            return

        comic["quantity"] -= quantity
        subtotal = comic["price"] * quantity
        tax = subtotal * self.tax_rate
        total = subtotal + tax

        sale = {
            "customer": customer_name,
            "title": title,
            "issue": issue,
            "quantity": quantity,
            "subtotal": subtotal,
            "tax": tax,
            "total": total,
        }
        self.sales.append(sale)

        # Print receipt
        print("=" * 40)
        print(f"  {self.store_name}")
        print(f"  {self.store_address}")
        print("=" * 40)
        print(f"  Customer: {customer_name}")
        print(f"  {title} #{issue} x{quantity}")
        print(f"  Subtotal: ${subtotal:.2f}")
        print(f"  Tax:      ${tax:.2f}")
        print(f"  Total:    ${total:.2f}")
        print("=" * 40)

        # Check if customer exists
        found = False
        for c in self.customers:
            if c["name"] == customer_name:
                c["total_spent"] += total
                c["visit_count"] += 1
                found = True
                break
        if not found:
            self.customers.append({
                "name": customer_name,
                "total_spent": total,
                "visit_count": 1,
                "pull_list": [],
            })

    def get_top_customers(self, n=5):
        sorted_customers = sorted(
            self.customers, key=lambda c: c["total_spent"], reverse=True
        )
        print(f"\n--- Top {n} Customers ---")
        for i, c in enumerate(sorted_customers[:n], 1):
            print(f"  {i}. {c['name']} — ${c['total_spent']:.2f} "
                  f"({c['visit_count']} visits)")

    def daily_report(self):
        total_revenue = sum(s["total"] for s in self.sales)
        total_items = sum(s["quantity"] for s in self.sales)
        print(f"\n--- Daily Report ---")
        print(f"  Transactions: {len(self.sales)}")
        print(f"  Items sold: {total_items}")
        print(f"  Revenue: ${total_revenue:.2f}")

    def save_inventory(self, filename):
        import json
        with open(filename, "w") as f:
            json.dump({"comics": self.comics, "customers": self.customers,
                        "sales": self.sales}, f)
        print(f"Saved to {filename}")

    def load_inventory(self, filename):
        import json
        with open(filename) as f:
            data = json.load(f)
        self.comics = data["comics"]
        self.customers = data["customers"]
        self.sales = data.get("sales", [])
        print(f"Loaded from {filename}")

Identifying the Problems

Let's apply the diagnostic questions from this chapter:

1. God Class (SRP Violation)

"Can you describe this class in one sentence without 'and'?"

ComicShop manages inventory and handles sales and prints receipts and tracks customers and generates reports and handles file persistence. That's six responsibilities in one class. It has six reasons to change.

2. Primitive Obsession

Comics, customers, and sales are all dictionaries. When Darius types comic["tilte"] instead of comic["title"], nothing warns him while he's writing the code. He only finds out with a KeyError when that exact line finally runs, which might be weeks later, in front of a customer. Dictionaries provide no structure, no type hints, and no IDE autocompletion.

3. Tight Coupling

The sell_comic() method handles business logic (inventory check, price calculation), receipt formatting, and customer tracking all in one 50-line method. You can't test the pricing logic without also triggering receipt printing.

4. Violated OCP

Adding email receipts would require modifying sell_comic() directly — adding an if email: branch that mixes receipt delivery logic with sales logic.

5. Duplicated Formatting

Receipt formatting and report formatting are both hardcoded print() calls buried inside business logic. get_top_customers() and daily_report() print their results instead of returning them, so there's no way to reuse that data for a different output — an email, a web page, or a test that checks the numbers.

The Refactoring Plan

We'll apply these changes in order: 1. Replace dictionaries with dataclasses (fix Primitive Obsession) 2. Extract focused classes (fix God Class / SRP) 3. Use Observer pattern for receipts (fix OCP, enable email receipts) 4. Decouple components (fix tight coupling)

Step 1: Dataclasses for Data Objects

from dataclasses import dataclass, field

@dataclass
class Comic:
    title: str
    issue: int
    publisher: str
    price: float
    quantity: int = 0

    @property
    def display_name(self) -> str:
        return f"{self.title} #{self.issue}"

@dataclass
class Sale:
    customer_name: str
    comic: Comic
    quantity: int
    subtotal: float
    tax: float
    total: float

@dataclass
class Customer:
    name: str
    total_spent: float = 0.0
    visit_count: int = 0
    pull_list: list[str] = field(default_factory=list)

Immediately, every place that accessed comic["title"] becomes comic.title — with IDE autocompletion and type checking. If Darius types comic.tilte, his IDE flags it instantly.

Step 2: Extract Focused Classes

class Inventory:
    """Manages comic book stock — one responsibility."""

    def __init__(self):
        self._comics: list[Comic] = []

    def add(self, comic: Comic) -> None:
        existing = self.find(comic.title, comic.issue)
        if existing is not None:
            existing.quantity += comic.quantity
        else:
            self._comics.append(comic)

    def find(self, title: str, issue: int) -> Comic | None:
        for comic in self._comics:
            if comic.title == title and comic.issue == issue:
                return comic
        return None

    def reduce_stock(self, comic: Comic, quantity: int) -> bool:
        if comic.quantity >= quantity:
            comic.quantity -= quantity
            return True
        return False

    @property
    def all_comics(self) -> list[Comic]:
        return list(self._comics)


class CustomerRegistry:
    """Tracks customer information — one responsibility."""

    def __init__(self):
        self._customers: dict[str, Customer] = {}

    def get_or_create(self, name: str) -> Customer:
        if name not in self._customers:
            self._customers[name] = Customer(name=name)
        return self._customers[name]

    def record_purchase(self, name: str, amount: float) -> None:
        customer = self.get_or_create(name)
        customer.total_spent += amount
        customer.visit_count += 1

    def top_customers(self, n: int = 5) -> list[Customer]:
        return sorted(
            self._customers.values(),
            key=lambda c: c.total_spent,
            reverse=True,
        )[:n]


class PricingEngine:
    """Calculates prices and tax — one responsibility."""

    def __init__(self, tax_rate: float = 0.08):
        self.tax_rate = tax_rate

    def calculate(self, unit_price: float, quantity: int) -> dict:
        subtotal = unit_price * quantity
        tax = subtotal * self.tax_rate
        return {
            "subtotal": subtotal,
            "tax": tax,
            "total": subtotal + tax,
        }

One honest confession: Inventory.add() merges a restock into the existing entry, while Darius's original add_comic() happily appended a duplicate. That's a behavior change, not a refactoring, even though the duplicates were a latent bug. In real work, make a fix like that as its own small step, with its own test, so that "cleaned up the design" and "changed what the program does" never hide inside the same commit.

Saving and loading would get a focused class of its own too (an InventoryStorage, much like TaskStorage in Chapter 14). We'll leave it out here to keep the listings short.

Step 3: Observer Pattern for Receipts

This is where the new email requirement becomes easy to add:

from abc import ABC, abstractmethod

class SaleObserver(ABC):
    @abstractmethod
    def on_sale(self, sale: Sale) -> None:
        pass

class PrintedReceipt(SaleObserver):
    def __init__(self, store_name: str, store_address: str):
        self.store_name = store_name
        self.store_address = store_address

    def on_sale(self, sale: Sale) -> None:
        print("=" * 40)
        print(f"  {self.store_name}")
        print(f"  {self.store_address}")
        print("=" * 40)
        print(f"  Customer: {sale.customer_name}")
        print(f"  {sale.comic.display_name} x{sale.quantity}")
        print(f"  Subtotal: ${sale.subtotal:.2f}")
        print(f"  Tax:      ${sale.tax:.2f}")
        print(f"  Total:    ${sale.total:.2f}")
        print("=" * 40)

class EmailReceipt(SaleObserver):
    """New feature — added without modifying any existing code!"""
    def on_sale(self, sale: Sale) -> None:
        print(f"[EMAIL] Receipt sent to {sale.customer_name}: "
              f"{sale.comic.display_name} — ${sale.total:.2f}")

class SalesLog(SaleObserver):
    def __init__(self):
        self.sales: list[Sale] = []

    def on_sale(self, sale: Sale) -> None:
        self.sales.append(sale)

    @property
    def total_revenue(self) -> float:
        return sum(s.total for s in self.sales)

    @property
    def total_items(self) -> int:
        return sum(s.quantity for s in self.sales)

Step 4: The Refactored Shop

class ComicShop:
    """Coordinates components — now a thin orchestration layer."""

    def __init__(self, tax_rate: float = 0.08):
        # Store name and address now live in PrintedReceipt, the only
        # class that uses them.
        self.inventory = Inventory()
        self.customers = CustomerRegistry()
        self.pricing = PricingEngine(tax_rate)
        self._sale_observers: list[SaleObserver] = []

    def add_sale_observer(self, observer: SaleObserver) -> None:
        self._sale_observers.append(observer)

    def add_comic(self, title: str, issue: int, publisher: str,
                  price: float, quantity: int) -> None:
        comic = Comic(title, issue, publisher, price, quantity)
        self.inventory.add(comic)

    def sell(self, customer_name: str, title: str, issue: int,
             quantity: int = 1) -> Sale | None:
        comic = self.inventory.find(title, issue)
        if comic is None:
            print(f"ERROR: {title} #{issue} not in inventory")
            return None

        if not self.inventory.reduce_stock(comic, quantity):
            print(f"ERROR: Only {comic.quantity} copies available")
            return None

        pricing = self.pricing.calculate(comic.price, quantity)
        sale = Sale(
            customer_name=customer_name,
            comic=comic,
            quantity=quantity,
            subtotal=pricing["subtotal"],
            tax=pricing["tax"],
            total=pricing["total"],
        )

        self.customers.record_purchase(customer_name, sale.total)

        for observer in self._sale_observers:
            observer.on_sale(sale)

        return sale

The Payoff

Now let's see how easy the two new features are:

# Setup
shop = ComicShop()
log = SalesLog()

# Add observers — including the NEW email receipt
shop.add_sale_observer(PrintedReceipt("Kapow Comics", "123 Main St"))
shop.add_sale_observer(EmailReceipt())  # New feature: ONE line to enable!
shop.add_sale_observer(log)

# Add inventory
shop.add_comic("Spider-Man", 1, "Marvel", 4.99, 50)

# Make a sale — all observers fire automatically
shop.sell("Alice", "Spider-Man", 1)

# Reports use SalesLog data
print(f"\nTotal revenue: ${log.total_revenue:.2f}")
print(f"Items sold: {log.total_items}")

Expected output:

========================================
  Kapow Comics
  123 Main St
========================================
  Customer: Alice
  Spider-Man #1 x1
  Subtotal: $4.99
  Tax:      $0.40
  Total:    $5.39
========================================
[EMAIL] Receipt sent to Alice: Spider-Man #1 — $5.39

Total revenue: $5.39
Items sold: 1

Adding email receipts: Create the EmailReceipt class and add one line to register it. Zero changes to existing code.

Adding pull lists: This one takes a single, one-time change. Inventory doesn't announce new stock yet, so we give it the same add_observer() hook that ComicShop has for sales, and have add() notify observers when a new issue arrives. After that, a PullListService observer can check each new arrival against customers' pull lists and notify them. The sales code doesn't change at all, and every future "when new stock arrives…" feature is just another observer.

Lessons Learned

Before After Principle Applied
1 God Class (~110 lines, and growing with every feature) 11 small classes (most under 30 lines) Single Responsibility
Dictionaries for data Dataclasses with type hints Eliminated Primitive Obsession
Hardcoded receipt printing Observer pattern for notifications Open/Closed Principle
sell_comic() does 5 things sell() coordinates focused components Loose coupling
Adding email requires modifying sales code Adding email = 1 new class + 1 registration line Extensibility

Discussion Questions

  1. Darius's original code "worked." In a real project, how would you decide whether refactoring is worth the time investment? What factors would you consider?

  2. The refactored version has more classes and more total lines. A classmate argues this makes it "more complex." How would you respond? What's the difference between complexity in number of pieces and complexity in understanding each piece?

  3. If Darius wanted to add a loyalty discount (10% off for customers with 10+ purchases), where in the refactored design would that logic go? Would you need to modify any existing classes?

  4. The PricingEngine is currently very simple. What future requirements might make it more complex? How does having it as a separate class help prepare for those requirements?