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

Marcus 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, Marcus 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. Marcus 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 Marcus types comic["tilte"] instead of comic["title"], he gets a silent KeyError at runtime instead of a clear error at development time. 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 35-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 both have hardcoded print statements. There's no way to reuse the formatting for different outputs.

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 Marcus 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:
            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,
        }

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, store_name: str = "Kapow Comics",
                 store_address: str = "123 Main St",
                 tax_rate: float = 0.08):
        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}")

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

Adding pull lists: Create a PullListService that observes the Inventory for new stock arrivals and notifies subscribed customers. Again, zero changes to existing sales code.

Lessons Learned

Before After Principle Applied
1 God Class (200+ lines) 7 focused classes (20-40 lines each) 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. Marcus'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 files and more classes. 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 Marcus 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?