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
-
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?
-
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?
-
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?
-
The
PricingEngineis currently very simple. What future requirements might make it more complex? How does having it as a separate class help prepare for those requirements?