Design Patterns, Explained Simply · Capstone Projects

Capstone: Refactor a Tangled Checkout

Refactor a checkout full of if/else chains into Strategy, Factory and Decorator, and prove the behaviour didn't change.

Scenario

Example app: a small online shop's checkout() function has grown over three years. It picks a payment method, a shipping rule and discounts with nested if/else chains, and logs everything inline. Each new payment provider or promotion takes a day, and last month a change to express shipping broke gift-card payments.

Your task: refactor it using patterns from this course, keeping the output identical for the same inputs. Refactoring changes structure, not behaviour.

Starting code

The tangled checkout (before)Try it yourself
def checkout(payment, shipping, coupon, subtotal):
    print(f"log: checkout {payment}/{shipping}/{coupon}")
    total = subtotal
    if coupon == "SAVE10":
        total -= total // 10
    elif coupon == "FLAT5":
        total -= 500
    if shipping == "standard":
        total += 0 if total >= 5000 else 499
    elif shipping == "express":
        total += 1299
    if payment == "card":
        return f"card charged {total}"
    elif payment == "giftcard":
        return f"gift card charged {total}" if total <= 10000 else "gift card declined"
    raise ValueError("unknown payment")


for args in [("card", "standard", "SAVE10", 6000), ("giftcard", "express", "FLAT5", 4000),
             ("giftcard", "standard", "", 12000)]:
    print(checkout(*args))

Output

log: checkout card/standard/SAVE10
card charged 5400
log: checkout giftcard/express/FLAT5
gift card charged 4799
log: checkout giftcard/standard/
gift card declined

Requirements

  • Adding a new payment method, shipping rule or coupon must not require editing checkout().
  • Logging must be separate from pricing and payment, and easy to turn off in tests.
  • Each rule (coupon, shipping, payment) must be testable on its own.
  • The output for the three sample inputs must stay exactly the same.

Constraints

  • No frameworks; plain classes and functions in your language.
  • Keep it proportionate: no pattern that doesn't remove a real if/else or duplication.

Deliverables

  1. The refactored code, with a sentence for each pattern explaining which symptom it fixes.
  2. A check that the refactored version prints the same output as the original for the three inputs.
  3. A short note on what you would not turn into a pattern yet, and why.

Hints

  • Coupons, shipping rules and payment methods are each a family of interchangeable rules: Strategy, registered in a lookup table (simple factory).
  • Logging around checkout is a cross-cutting concern: a Decorator around the checkout object.
  • The order of steps (discount → shipping → payment) stays in one place: that's fine as plain code.

One possible solution

The same behaviour with Strategy, a factory table and a Decorator (after)Try it yourself
COUPONS = {"SAVE10": lambda t: t - t // 10, "FLAT5": lambda t: t - 500, "": lambda t: t}  # strategies
SHIPPING = {"standard": lambda t: t + (0 if t >= 5000 else 499), "express": lambda t: t + 1299}


class Card:
    def pay(self, total):
        return f"card charged {total}"


class GiftCard:
    def pay(self, total):
        return f"gift card charged {total}" if total <= 10000 else "gift card declined"


PAYMENTS = {"card": Card(), "giftcard": GiftCard()}  # simple factory as a lookup table


class Checkout:
    def run(self, payment, shipping, coupon, subtotal):
        total = SHIPPING[shipping](COUPONS[coupon](subtotal))
        return PAYMENTS[payment].pay(total)


class LoggedCheckout:  # decorator: same run(), adds logging
    def __init__(self, inner):
        self.inner = inner

    def run(self, payment, shipping, coupon, subtotal):
        print(f"log: checkout {payment}/{shipping}/{coupon}")
        return self.inner.run(payment, shipping, coupon, subtotal)


checkout = LoggedCheckout(Checkout())
for args in [("card", "standard", "SAVE10", 6000), ("giftcard", "express", "FLAT5", 4000),
             ("giftcard", "standard", "", 12000)]:
    print(checkout.run(*args))

Output

log: checkout card/standard/SAVE10
card charged 5400
log: checkout giftcard/express/FLAT5
gift card charged 4799
log: checkout giftcard/standard/
gift card declined

The output is identical to the starting code: the refactoring kept the behaviour. A new coupon is one entry in COUPONS; logging can be dropped in tests by using Checkout() without the decorator.

Deliverables

Which patterns did you apply, and which symptom does each one fix?

Model answer

A strong answer maps each pattern to a symptom: Strategy for the coupon, shipping and payment if/else chains; a lookup table (simple factory) to pick strategies by name; a Decorator to separate logging from checkout logic.

Rubric

Look for: each pattern justified by a concrete symptom, behaviour preserved (same output), logging removable in tests. Misses: adding patterns that don't remove any conditional, changing behaviour while refactoring, putting logging inside each strategy.

How did you prove the refactoring didn't change behaviour?

Model answer

Run the original and the refactored version on the same inputs and compare outputs, ideally as automated characterisation tests written before refactoring.

Rubric

Strong answers write tests that pin the current behaviour first (characterisation tests), then refactor in small steps while keeping them green. Weak answers rely on reading the code.

What would you deliberately not turn into a pattern yet?

Model answer

For example, the fixed order of steps (discount, then shipping, then payment) can stay plain code; a Template Method or pipeline adds nothing until that order actually varies.

Rubric

Look for YAGNI reasoning: abstractions only where variation exists today, with a clear trigger for when to add more.

Open in the app to mark lessons complete, take notes and track your progress.