Card 02/ 07

WalkthroughDifficulty: Intermediate6 min

Refactoring the Checkout Class, One Principle at a Time

The checkout class that broke all five, fixed in five steps, with the tests passing after each.

Refactoring a class that breaks several principles is safe when you pin its behaviour with tests first, then make one change at a time and run the tests after each. This card applies that method to a Checkout class that broke all five principles: it priced orders, saved them to MySQL, emailed receipts and had an in-store subclass that refused to email.

A refactoring changes the structure of code without changing what it does, so the tests that pass before must pass after every step. Four tests pin today's behaviour: the member, staff and regular totals; the exact lines an online order writes to the database and the mailer; and an in-store sale that is saved but not emailed. Because the original class creates its own database and mailer, the tests capture what those print.

Five principles, one change each

Pin the behaviour

Write the four tests against the class as it is, and see them pass. Nothing changes yet. Without this step, nothing tells you if a later one changed behaviour.

Single responsibility

Move the discount arithmetic into a Pricing class and the receipt wording into a receipt_text function. Pricing rules and receipt wording now change in their own places. Run the tests.

Open/closed

Replace the if customer_type == ... chain with a table of rates passed to Pricing. A new customer type is a new entry, not a new branch. Run the tests.

Liskov substitution

Move email_receipt out of Checkout into an OnlineCheckout subclass. InStoreCheckout no longer inherits a method it has to refuse. Run the tests.

Interface segregation

Give the checkout two small interfaces, OrderStore with save_order and ReceiptSender with send, instead of whole database and mail classes. Run the tests.

Dependency inversion

Take the pricing, the store and the sender as constructor parameters typed by those interfaces, and create the MySQL and SMTP implementations at start-up. Run the tests.

Each step was run as its own module against the same four tests. Here is what the runs printed:

py
import importlib
import io
import sys
from contextlib import redirect_stdout

sys.path.insert(0, "py/checkout_steps")

BASKET = [("Notebook", 4.50, 2), ("Pen", 1.20, 5)]


def captured(action):
    buffer = io.StringIO()
    with redirect_stdout(buffer):
        result = action()
    return result, buffer.getvalue().splitlines()


def test_member_total(step):
    total, _ = captured(lambda: step.run_online(BASKET, "member", "[email protected]"))
    assert f"{total:.2f}" == "13.50"


def test_staff_and_regular_totals(step):
    staff, _ = captured(lambda: step.run_online(BASKET, "staff", "[email protected]"))
    regular, _ = captured(lambda: step.run_online(BASKET, "regular", "[email protected]"))
    assert (f"{staff:.2f}", f"{regular:.2f}") == ("10.50", "15.00")


def test_online_order_is_saved_and_emailed(step):
    _, lines = captured(lambda: step.run_online(BASKET, "member", "[email protected]"))
    assert lines == [
        "INSERT INTO orders: [('Notebook', 4.5, 2), ('Pen', 1.2, 5)]",
        "MAIL to [email protected]: You paid 13.50",
    ]


def test_in_store_sale_is_saved_not_emailed(step):
    total, lines = captured(lambda: step.run_in_store([("Pen", 1.20, 1)], "regular"))
    assert f"{total:.2f}" == "1.20"
    assert lines == ["INSERT INTO orders: [('Pen', 1.2, 1)]"]


TESTS = [
    test_member_total,
    test_staff_and_regular_totals,
    test_online_order_is_saved_and_emailed,
    test_in_store_sale_is_saved_not_emailed,
]

STEPS = [
    "step0_pinned",
    "step1_single_responsibility",
    "step2_open_closed",
    "step3_liskov",
    "step4_interface_segregation",
    "step5_dependency_inversion",
]

for name in STEPS:
    step = importlib.import_module(name)
    for test in TESTS:
        test(step)
    print(f"{name}: {len(TESTS)} tests passed")
OutputRan with python:3.12-slim (Python 3.12.13)
step0_pinned: 4 tests passed
step1_single_responsibility: 4 tests passed
step2_open_closed: 4 tests passed
step3_liskov: 4 tests passed
step4_interface_segregation: 4 tests passed
step5_dependency_inversion: 4 tests passed
The same four tests run against the class after each step.

The final design is below in all three languages. In these listings the stand-in database prints a line count rather than the rows.

The checkout after all five steps

py
from typing import Protocol


class OrderStore(Protocol):
    def save_order(self, items) -> None: ...


class ReceiptSender(Protocol):
    def send(self, to: str, body: str) -> None: ...


class Pricing:
    def __init__(self, rates):
        self.rates = rates

    def total(self, items, customer_type):
        subtotal = sum(price * quantity for _, price, quantity in items)
        return subtotal * self.rates.get(customer_type, 1.0)


def receipt_text(amount):
    return f"You paid {amount:.2f}"


class Checkout:
    def __init__(self, pricing: Pricing, store: OrderStore):
        self.items = []
        self.pricing = pricing
        self.store = store

    def add(self, name, price, quantity):
        self.items.append((name, price, quantity))

    def total(self, customer_type):
        return self.pricing.total(self.items, customer_type)

    def save(self):
        self.store.save_order(self.items)


class OnlineCheckout(Checkout):
    def __init__(self, pricing: Pricing, store: OrderStore, sender: ReceiptSender):
        super().__init__(pricing, store)
        self.sender = sender

    def email_receipt(self, address, customer_type):
        self.sender.send(address, receipt_text(self.total(customer_type)))


class InStoreCheckout(Checkout):
    pass


class MySQLOrderStore:
    def save_order(self, items):
        print(f"INSERT INTO orders: {len(items)} lines")


class SmtpReceiptSender:
    def send(self, to, body):
        print(f"MAIL to {to}: {body}")


pricing = Pricing({"member": 0.9, "staff": 0.7})
online = OnlineCheckout(pricing, MySQLOrderStore(), SmtpReceiptSender())
online.add("Notebook", 4.50, 2)
online.add("Pen", 1.20, 5)
online.save()
online.email_receipt("[email protected]", "member")
OutputRan with python:3.12-slim (Python 3.12.13)
INSERT INTO orders: 2 lines
MAIL to [email protected]: You paid 13.50
java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class ApplyCheckoutFinal {
    public static void main(String[] args) {
        Pricing pricing = new Pricing(Map.of("member", 0.9, "staff", 0.7));
        OnlineCheckout online = new OnlineCheckout(pricing, new MySQLOrderStore(), new SmtpReceiptSender());
        online.add(new Item("Notebook", 4.50, 2));
        online.add(new Item("Pen", 1.20, 5));
        online.save();
        online.emailReceipt("[email protected]", "member");
    }
}

record Item(String name, double price, int quantity) {}

interface OrderStore {
    void saveOrder(List<Item> items);
}

interface ReceiptSender {
    void send(String to, String body);
}

class Pricing {
    private final Map<String, Double> rates;

    Pricing(Map<String, Double> rates) {
        this.rates = rates;
    }

    double total(List<Item> items, String customerType) {
        double subtotal = items.stream().mapToDouble(item -> item.price() * item.quantity()).sum();
        return subtotal * rates.getOrDefault(customerType, 1.0);
    }
}

class Checkout {
    protected final List<Item> items = new ArrayList<>();
    private final Pricing pricing;
    private final OrderStore store;

    Checkout(Pricing pricing, OrderStore store) {
        this.pricing = pricing;
        this.store = store;
    }

    void add(Item item) { items.add(item); }

    double total(String customerType) { return pricing.total(items, customerType); }

    void save() { store.saveOrder(items); }
}

class OnlineCheckout extends Checkout {
    private final ReceiptSender sender;

    OnlineCheckout(Pricing pricing, OrderStore store, ReceiptSender sender) {
        super(pricing, store);
        this.sender = sender;
    }

    void emailReceipt(String address, String customerType) {
        sender.send(address, String.format("You paid %.2f", total(customerType)));
    }
}

class InStoreCheckout extends Checkout {
    InStoreCheckout(Pricing pricing, OrderStore store) { super(pricing, store); }
}

class MySQLOrderStore implements OrderStore {
    public void saveOrder(List<Item> items) {
        System.out.println("INSERT INTO orders: " + items.size() + " lines");
    }
}

class SmtpReceiptSender implements ReceiptSender {
    public void send(String to, String body) {
        System.out.println("MAIL to " + to + ": " + body);
    }
}
OutputRan with eclipse-temurin:21-jdk (Java 21.0.12)
INSERT INTO orders: 2 lines
MAIL to [email protected]: You paid 13.50
ts
type Item = { name: string; price: number; quantity: number };

interface OrderStore {
  saveOrder(items: Item[]): void;
}

interface ReceiptSender {
  send(to: string, body: string): void;
}

class Pricing {
  constructor(private readonly rates: Record<string, number>) {}

  total(items: Item[], customerType: string): number {
    const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    return subtotal * (this.rates[customerType] ?? 1);
  }
}

class Checkout {
  protected readonly items: Item[] = [];

  constructor(private readonly pricing: Pricing, private readonly store: OrderStore) {}

  add(item: Item): void { this.items.push(item); }

  total(customerType: string): number { return this.pricing.total(this.items, customerType); }

  save(): void { this.store.saveOrder(this.items); }
}

class OnlineCheckout extends Checkout {
  constructor(pricing: Pricing, store: OrderStore, private readonly sender: ReceiptSender) {
    super(pricing, store);
  }

  emailReceipt(address: string, customerType: string): void {
    this.sender.send(address, `You paid ${this.total(customerType).toFixed(2)}`);
  }
}

class InStoreCheckout extends Checkout {}

class MySQLOrderStore implements OrderStore {
  saveOrder(items: Item[]): void { console.log(`INSERT INTO orders: ${items.length} lines`); }
}

class SmtpReceiptSender implements ReceiptSender {
  send(to: string, body: string): void { console.log(`MAIL to ${to}: ${body}`); }
}

const pricing = new Pricing({ member: 0.9, staff: 0.7 });
const online = new OnlineCheckout(pricing, new MySQLOrderStore(), new SmtpReceiptSender());
online.add({ name: "Notebook", price: 4.5, quantity: 2 });
online.add({ name: "Pen", price: 1.2, quantity: 5 });
online.save();
online.emailReceipt("[email protected]", "member");
OutputRan with node:22-slim (Node 22.23.1), TypeScript 5.9.3
INSERT INTO orders: 2 lines
MAIL to [email protected]: You paid 13.50

Notice the order of the steps. They follow the letters of SOLID here for clarity, but in practice you start with whichever change the next feature needs. What must not change is the rhythm: one structural change, then the tests, then the next.