Skip to content

Session 7: Refactoring to OOP & Custom Exceptions

Week: 7
Element: ICTPRG430 Element 2.2
Duration: 4 hours
Phase: OOP Integration & Professional Practices


Session Introduction

In this session, you'll take messy, feature-creeped code and refactor it into clean, professional OOP design. You'll work with a coffee shop ordering system that started simple but grew into a tangled mess of if/elif chains, repeated code, and mixed concerns. Through guided discussion and hands-on refactoring, you'll apply everything from Sessions 4-6: encapsulation, composition, inheritance, and polymorphism.

You'll also learn to design and implement custom exception hierarchies for robust error handling—a professional practice that makes code maintainable and fault-tolerant. This session consolidates your OOP foundations and prepares you for adding file I/O (Session 8) and packaging (Session 9).


Learning Objectives

By the end of this session, you will be able to:

  • Analyze messy code and identify violations of OOP principles
  • Design class hierarchies that use both composition and inheritance appropriately
  • Refactor procedural code into clean, modular OOP architecture
  • Implement custom exception classes for domain-specific error handling
  • Apply separation of concerns to create focused, reusable components
  • Make IS-A vs HAS-A design decisions when faced with competing solutions
  • Prepare code for packaging and distribution through proper modularity

Session Structure

  1. The Problem - Examining feature-creeped coffee shop code
  2. User Stories - Understanding requirements from customer perspective
  3. Design Discussion - Whiteboard class design and relationships
  4. The Curveball - Challenging your IS-A vs HAS-A understanding
  5. Custom Exceptions - Building an exception hierarchy
  6. Refactoring Exercise - Transform messy code to clean OOP
  7. Testing - Verify behavior with unit tests
  8. Connections - Robotics parallels and packaging preview

Pre-Session Preparation

Required

  • Python 3.9+ installed
  • VS Code with Python extension
  • Completed Session 4 (Class fundamentals & encapsulation)
  • Completed Session 5 (Composition & component design)
  • Completed Session 6 (Inheritance & polymorphism)

Review

  • IS-A vs HAS-A relationships
  • Separation of concerns principle
  • Unit testing basics (Session 1-2)

📊 Lecture Slides

Week 7 Presentation Slides

View the presentation slides for this session:

📊 Open Slides in New Window

The slides contain: - Feature creep story and messy code analysis - Code smells and OOP violations - Design discussion and class hierarchy planning - The curveball: IS-A vs HAS-A challenge - Custom exception hierarchies - Refactoring process and best practices - Connections to robotics and packaging


1. The Problem: Feature Creep in the Real World

The Story

A local coffee shop hired a developer to build a simple ordering system. Version 1.0 was straightforward:

"As a barista, I want to enter a customer's order and print a receipt."

Simple code. Clean logic. Everyone happy.

Then the feature requests started rolling in:

Sprint 2: "Can we add drink sizes? Small, medium, large?"
Sprint 3: "We need milk alternatives: whole, skim, oat, almond."
Sprint 4: "What about extra shots? Whipped cream?"
Sprint 5: "Can we track loyalty points? Every $1 spent = 1 point."
Sprint 6: "Members get a 10% discount."
Sprint 7: "Can we offer free drinks for 100 loyalty points?"
Sprint 8: "We need to save daily sales to a file for accounting."

Each feature added more if/elif blocks, more global variables, more copy-pasted logic. The developer never refactored. They just... kept adding.


2. The Messy Code

Here's what you're inheriting:

Download the starter code: coffee_shop_messy.py

Quick Analysis (Don't read the whole file yet)

# Sample of what you'll see:
menu = {...}  # Global variable
loyalty_accounts = {...}  # Global variable
member_discount = 0.10  # Global constant mixed with variables

def take_order():
    # 80+ lines of tangled logic
    # Validation mixed with pricing
    # Pricing mixed with output
    # Error handling via print statements
    # if size == 'small': price = X elif size == 'medium': ... 
    # if milk == 'oat': price += Y elif milk == 'almond': ...
    # Repeated everywhere

def process_payment():
    # Mix of cash and loyalty point logic
    # No clear separation

def daily_report():
    # File I/O mixed with business logic
    # Hard-coded filename

Smells (code problems):

God functions - take_order() does validation, pricing, discounts, output
Global state - menu, loyalty_accounts scattered everywhere
Repeated logic - Size pricing copied in 5 places
Magic numbers - 0.10 for discount, 100 for free drink points
Mixed concerns - Business logic tangled with I/O
Error handling via print - No exceptions, just print and hope


3. User Stories: Understanding Requirements

Before refactoring, understand what the system must do:

User Story 1: Basic Ordering

As a barista
I want to create an order with drink name, size, and customizations
So that I can prepare the correct drink for the customer

User Story 2: Pricing

As a barista
I want to see the total price including size, milk, and extras
So that I can charge the customer correctly

User Story 3: Loyalty Program

As a customer
I want to earn 1 point per dollar spent
So that I can save toward free drinks

User Story 4: Redeeming Points

As a customer
I want to redeem 100 points for a small free drink
So that I get rewards for being a regular

User Story 5: Member Discount

As a member
I want to receive a 10% discount on all purchases
So that I save money

User Story 6: Daily Sales Report

As the owner
I want to see a summary of daily sales saved to a file
So that I can track revenue for accounting


4. Design Discussion: What Classes Do We Need?

This is where the whiteboard comes in. As a class, we'll discuss:

Key Questions

  1. What are the core "things" (nouns) in this domain?
  2. Order, MenuItem, Customer, LoyaltyProgram, Payment...

  3. What relationships exist?

  4. Does an Order IS-A MenuItem or HAS-A MenuItem?
  5. Does a Customer IS-A LoyaltyMember or HAS-A LoyaltyAccount?

  6. Where is composition appropriate?

  7. Order HAS-A list of items
  8. MenuItem HAS-A list of customizations

  9. Where is inheritance appropriate?

  10. Different drink types? Beverage, Espresso, Tea?
  11. Different payment types? CashPayment, PointsPayment?

  12. What responsib ilities does each class have?

  13. MenuItem: pricing for one item
  14. Order: manage collection of items, calculate total
  15. LoyaltyProgram: track points, validate redemption
  16. Customer: hold customer info, link to loyalty account

Whiteboard Activity (In-Class)

You'll sketch class diagrams showing:

  • Class names and key attributes
  • Relationships (HAS-A with arrows/diamonds, IS-A with inheritance)
  • Method responsibilities (not full signatures, just ideas)

This is exploratory - there's no single "right" answer. The goal is to practice design thinking.


5. The Curveball: Challenging Your Understanding

Midway through the session, I'll introduce a new requirement that challenges your initial design:

Curveball: Staff Drinks

As the owner
I want staff drinks to be free (no charge)
But I still need to track them for inventory purposes
So that I know how much coffee we're using

Design question: Is a staff drink:

Option A: A special type of order? (StaffOrder inherits from Order)
Option B: A flag on the customer? (Customer(is_staff=True))
Option C: A different payment method? (FreePayment class)
Option D: Something else?

This forces you to think:

  • What varies? (The payment/pricing)
  • What stays the same? (The order contents, inventory tracking)
  • Where does the "free" behavior belong?

We'll discuss as a class and see how different designs handle this.


6. Custom Exceptions: Professional Error Handling

Why Custom Exceptions?

Instead of this:

def redeem_points(points):
    if points < 100:
        print("Error: Not enough points")
        return False
    # ... proceed

Professional code does this:

class InsufficientPointsError(Exception):
    """Raised when customer doesn't have enough loyalty points."""
    pass

def redeem_points(points):
    if points < 100:
        raise InsufficientPointsError(f"Need 100 points, have {points}")
    # ... proceed

Benefits

Explicit error types - Caller knows exactly what went wrong
Propagation - Errors bubble up to appropriate handler
Centralized handling - try/except in one place, not scattered prints
Testing - Can assert specific exceptions are raised
Documentation - Exception names document failure modes

Exception Hierarchy Design

For the coffee shop, we might design:

class CoffeeShopError(Exception):
    """Base exception for all coffee shop errors."""
    pass

class OrderError(CoffeeShopError):
    """Base exception for order-related errors."""
    pass

class InvalidItemError(OrderError):
    """Raised when item doesn't exist on menu."""
    pass

class InvalidCustomizationError(OrderError):
    """Raised when customization not available."""
    pass

class PaymentError(CoffeeShopError):
    """Base exception for payment errors."""
    pass

class InsufficientPointsError(PaymentError):
    """Raised when loyalty points insufficient for redemption."""
    pass

class InvalidDiscountError(PaymentError):
    """Raised when discount can't be applied."""
    pass

Hierarchy benefits:

  • Catch broad categories: except OrderError catches all order issues
  • Catch specific issues: except InvalidItemError for precise handling
  • Clear responsibilities: Payment errors separated from order errors

Implementation Example

class MenuItem:
    def __init__(self, name, base_price, available_sizes):
        self.name = name
        self.base_price = base_price
        self.available_sizes = available_sizes

    def calculate_price(self, size):
        """Calculate price based on size.

        Args:
            size: Drink size (small, medium, large)

        Returns:
            Price as float

        Raises:
            InvalidCustomizationError: If size not available
        """
        if size not in self.available_sizes:
            raise InvalidCustomizationError(
                f"Size '{size}' not available for {self.name}. "
                f"Available: {', '.join(self.available_sizes)}"
            )

        size_multipliers = {'small': 1.0, 'medium': 1.3, 'large': 1.6}
        return self.base_price * size_multipliers[size]

Using Exceptions

try:
    item = MenuItem("Latte", 4.50, ['small', 'medium', 'large'])
    price = item.calculate_price('extra-large')
except InvalidCustomizationError as e:
    print(f"Order error: {e}")
    # Offer alternatives or log error

7. Refactoring Exercise: Transform the Code

Your Task

Working individually or in pairs, refactor coffee_shop_messy.py into clean OOP architecture:

Requirements

1. Class Design (30%)

Create at least these classes with clear responsibilities:

  • MenuItem - Represents one menu item with pricing
  • Order - Manages collection of items, calculates totals
  • Customer - Holds customer information
  • LoyaltyProgram - Manages points earning and redemption
  • Custom exception classes (minimum 3)

2. Separation of Concerns (25%)

  • Business logic separated from I/O
  • Pricing logic centralized (not scattered)
  • Each class has single, clear responsibility
  • No global variables (except perhaps constants)

3. Composition & Inheritance (20%)

  • Use composition where appropriate (Order HAS-A MenuItem)
  • Consider inheritance if types emerge (optional, but think about it)
  • Demonstrate understanding of IS-A vs HAS-A

4. Error Handling (15%)

  • Use custom exceptions (not print statements for errors)
  • Appropriate exception messages with context
  • Demonstrate exception hierarchy

5. Google Docstrings (10%)

  • All classes documented
  • All public methods documented
  • Type hints on method signatures

Deliverables

  • coffee_shop_refactored.py - Your OOP solution
  • coffee_shop_exceptions.py - Custom exception hierarchy
  • test_coffee_shop.py - Unit tests (provided template, you complete)

Starter Unit Tests

import unittest
from coffee_shop_refactored import MenuItem, Order, Customer, LoyaltyProgram
from coffee_shop_exceptions import InvalidItemError, InsufficientPointsError

class TestCoffeeShop(unittest.TestCase):

    def setUp(self):
        """Set up test fixtures."""
        self.latte = MenuItem("Latte", 4.50, ['small', 'medium', 'large'])
        self.customer = Customer("Alice", member=True)
        self.loyalty = LoyaltyProgram()

    def test_menu_item_pricing_small(self):
        """Test MenuItem calculates small drink price correctly."""
        price = self.latte.calculate_price('small')
        self.assertEqual(price, 4.50)

    def test_menu_item_pricing_invalid_size(self):
        """Test MenuItem raises exception for invalid size."""
        with self.assertRaises(InvalidCustomizationError):
            self.latte.calculate_price('extra-large')

    def test_order_total_with_multiple_items(self):
        """Test Order calculates total correctly."""
        order = Order()
        # Add implementation
        # self.assertEqual(order.total(), expected_value)

    def test_member_discount_applied(self):
        """Test member discount is 10%."""
        # Add implementation

    def test_loyalty_points_earned(self):
        """Test loyalty points: $1 = 1 point."""
        # Add implementation

    def test_redeem_points_insufficient(self):
        """Test redeeming with insufficient points raises exception."""
        with self.assertRaises(InsufficientPointsError):
            self.loyalty.redeem_points(self.customer, points_to_redeem=100)

    # Add more tests as needed

if __name__ == '__main__':
    unittest.main()

8. Testing & Validation

Behavioral Tests

Your refactored code must behave identically to the original for these scenarios:

  1. Order small latte → $4.50
  2. Order medium oat milk latte → $5.85 + oat milk surcharge
  3. Member orders latte → 10% discount applied
  4. Earn points on $10 order → 10 points added
  5. Redeem 100 points → Free small drink
  6. Staff drink → $0.00 but tracked

Run unit tests:

python -m unittest test_coffee_shop.py

All tests should pass.

Code Quality Checks

  • No functions longer than 20 lines
  • No repeated code (DRY principle)
  • Clear class and method names
  • Type hints on all methods
  • Google docstrings on all public methods

9. Connections: Robotics & Packaging

Robotics Parallel

The design decisions you made apply directly to your robot hierarchy:

Coffee Shop Question: Is a StaffOrder a type of Order (IS-A) or does Order have a payment type (HAS-A)?

Robotics Question: Is a LineFollowerRobot a type of RobotBase (IS-A) or does RobotBase have a LineFollowingBehavior component (HAS-A)?

Both are valid. Your job is to: - Understand the trade-offs - Make deliberate design choices
- Document why you chose your approach

Think ahead to your final assessment: When you integrate file I/O next week, you'll load robot configurations just like coffee shop orders. Clean OOP now = easier integration later.


Packaging Preview

Notice how your refactored code is now modular:

  • coffee_shop_exceptions.py - Independent exception hierarchy
  • coffee_shop_refactored.py - Business logic classes (MenuItem, Order, etc.)
  • test_coffee_shop.py - Tests

Next steps (Week 9):

These clean modules are now packageable. You could: - Package LoyaltyProgram for reuse in other projects - Distribute MenuItem pricing logic as a library - Share exception hierarchy across multiple applications

Clean code → Reusable components → Professional packages

This is the thread connecting Sessions 7 → 8 → 9.


10. Key Design Principles Applied

Single Responsibility Principle (SRP)

Each class has one reason to change:

Class Responsibility Would Change If...
MenuItem Item pricing and validation Menu pricing structure changes
Order Managing collection of items Order business rules change
Customer Customer information Customer data requirements change
LoyaltyProgram Points and redemption logic Loyalty rules change

DRY (Don't Repeat Yourself)

Before (messy code):

# Size pricing repeated 5+ times
if size == 'small': price = base * 1.0
elif size == 'medium': price = base * 1.3
elif size == 'large': price = base * 1.6

After (refactored):

# Centralized in MenuItem class
class MenuItem:
    SIZE_MULTIPLIERS = {'small': 1.0, 'medium': 1.3, 'large': 1.6}

    def calculate_price( self, size):
        return self.base_price * self.SIZE_MULTIPLIERS[size]

Open/Closed Principle

Classes open for extension, closed for modification:

Adding a new drink type shouldn't require changing existing code:

# Extending MenuItem (composition approach)
class Beverage(MenuItem):
    def __init__(self, name, base_price, temperature):
        super().__init__(name, base_price, ['small', 'medium', 'large'])
        self.temperature = temperature

# Usage - no change to Order class needed
order = Order()
order.add_item(Beverage("Espresso", 3.50, "hot"))

Check Your Understanding

Q1: IS-A vs HAS-A

A member customer gets a discount. Should MemberCustomer inherit from Customer (IS-A) or should Customer have a membership attribute (HAS-A)?

Answer

Both work, but HAS-A is often better:

# HAS-A approach (flexible)
class Customer:
    def __init__(self, name, membership=None):
        self.name = name
        self.membership = membership  # None or Membership object

# Easier to change membership status at runtime
customer.membership = PremiumMembership()

Q2: Exception Hierarchies

Why create CoffeeShopError as a base if we never raise it directly?

Answer

Allows catching all coffee shop errors:

try:
    # Complex operation
except CoffeeShopError as e:
    # Handle any coffee shop error
Also documents that all coffee shop exceptions are related.

Q3: Separation of Concerns

Where should file I/O code go in the refactored design?

Answer

Not in business logic classes. Create separate reporting/persistence classes:

class DailySalesReport:
    def __init__(self, orders):
        self.orders = orders

    def save_to_file(self, filename):
        # I/O logic here
Keep Order focused on order logic, not file operations.

Q4: Testing Refactored Code

How do you know your refactoring didn't break functionality?

Answer

Unit tests verify behavior. If old code passed tests A, B, C, and new code passes the same tests, behavior is preserved. This is why we provide test templates.


Session Summary

You've learned:

  1. Real-world code degrades through feature creep and lack of refactoring
  2. User stories guide design - understand requirements before coding
  3. Design discussion reveals options - IS-A vs HAS-A, composition vs inheritance
  4. Custom exceptions create robust, professional error handling
  5. Refactoring applies Sessions 4-6 - encapsulation, composition, inheritance together
  6. Clean architecture enables reuse - modular code is packageable code
  7. Design decisions have trade-offs - understand them, make deliberate choices

You're now ready to add file I/O (Session 8) and packaging (Session 9) to your clean OOP foundation.


Next Session Preview

Session 8: Configuration Manager & File I/O

  • Load and save data from JSON, XML, YAML files
  • Design ConfigurationManager class for separation of concerns
  • Apply file I/O to your refactored coffee shop (load menu from file)
  • Prepare for robot configuration management

Navigation:
← Week 6 | Learning Plan | Week 8 →