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
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
function checkout(payment: string, shipping: string, coupon: string, subtotal: number): string {
console.log(`log: checkout ${payment}/${shipping}/${coupon}`);
let total = subtotal;
if (coupon === "SAVE10") total -= Math.floor(total / 10);
else if (coupon === "FLAT5") total -= 500;
if (shipping === "standard") total += total >= 5000 ? 0 : 499;
else if (shipping === "express") total += 1299;
if (payment === "card") return `card charged ${total}`;
if (payment === "giftcard") return total <= 10000 ? `gift card charged ${total}` : "gift card declined";
throw new Error("unknown payment");
}
const cases: [string, string, string, number][] = [["card", "standard", "SAVE10", 6000],
["giftcard", "express", "FLAT5", 4000], ["giftcard", "standard", "", 12000]];
for (const args of cases) console.log(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
public class Main {
static String checkout(String payment, String shipping, String coupon, int subtotal) {
System.out.println("log: checkout " + payment + "/" + shipping + "/" + coupon);
int total = subtotal;
if (coupon.equals("SAVE10")) total -= total / 10;
else if (coupon.equals("FLAT5")) total -= 500;
if (shipping.equals("standard")) total += total >= 5000 ? 0 : 499;
else if (shipping.equals("express")) total += 1299;
if (payment.equals("card")) return "card charged " + total;
if (payment.equals("giftcard")) return total <= 10000 ? "gift card charged " + total : "gift card declined";
throw new IllegalArgumentException("unknown payment");
}
public static void main(String[] args) {
Object[][] cases = {{"card", "standard", "SAVE10", 6000}, {"giftcard", "express", "FLAT5", 4000},
{"giftcard", "standard", "", 12000}};
for (Object[] c : cases) {
String result = checkout((String) c[0], (String) c[1], (String) c[2], (Integer) c[3]);
System.out.println(result);
}
}
}
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
Run this on your computer — see “Get Started” for setup.
<?php
function checkout(string $payment, string $shipping, string $coupon, int $subtotal): string {
echo "log: checkout $payment/$shipping/$coupon\n";
$total = $subtotal;
if ($coupon === "SAVE10") $total -= intdiv($total, 10);
elseif ($coupon === "FLAT5") $total -= 500;
if ($shipping === "standard") $total += $total >= 5000 ? 0 : 499;
elseif ($shipping === "express") $total += 1299;
if ($payment === "card") return "card charged $total";
if ($payment === "giftcard") return $total <= 10000 ? "gift card charged $total" : "gift card declined";
throw new InvalidArgumentException("unknown payment");
}
foreach ([["card", "standard", "SAVE10", 6000], ["giftcard", "express", "FLAT5", 4000], ["giftcard", "standard", "", 12000]] as $args) {
$result = checkout(...$args);
echo $result, "\n";
}
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/elseor duplication.
Deliverables
- The refactored code, with a sentence for each pattern explaining which symptom it fixes.
- A check that the refactored version prints the same output as the original for the three inputs.
- 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
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
type Rule = (total: number) => number;
const coupons: Record<string, Rule> = { SAVE10: (t) => t - Math.floor(t / 10), FLAT5: (t) => t - 500, "": (t) => t }; // strategies
const shippingRules: Record<string, Rule> = { standard: (t) => t + (t >= 5000 ? 0 : 499), express: (t) => t + 1299 };
interface PaymentMethod { pay(total: number): string; }
class Card implements PaymentMethod { pay(total: number) { return `card charged ${total}`; } }
class GiftCard implements PaymentMethod {
pay(total: number) { return total <= 10000 ? `gift card charged ${total}` : "gift card declined"; }
}
const payments: Record<string, PaymentMethod> = { card: new Card(), giftcard: new GiftCard() }; // factory as a lookup table
interface CheckoutLike { run(payment: string, shipping: string, coupon: string, subtotal: number): string; }
class Checkout implements CheckoutLike {
run(payment: string, shipping: string, coupon: string, subtotal: number) {
return payments[payment].pay(shippingRules[shipping](coupons[coupon](subtotal)));
}
}
class LoggedCheckout implements CheckoutLike { // decorator: same run(), adds logging
constructor(private inner: CheckoutLike) {}
run(payment: string, shipping: string, coupon: string, subtotal: number) {
console.log(`log: checkout ${payment}/${shipping}/${coupon}`);
return this.inner.run(payment, shipping, coupon, subtotal);
}
}
const checkout = new LoggedCheckout(new Checkout());
const cases: [string, string, string, number][] = [["card", "standard", "SAVE10", 6000],
["giftcard", "express", "FLAT5", 4000], ["giftcard", "standard", "", 12000]];
for (const args of cases) console.log(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
import java.util.Map;
import java.util.function.IntUnaryOperator;
public class Main {
static final Map<String, IntUnaryOperator> COUPONS = Map.of( // strategies
"SAVE10", t -> t - t / 10, "FLAT5", t -> t - 500, "", t -> t);
static final Map<String, IntUnaryOperator> SHIPPING = Map.of(
"standard", t -> t + (t >= 5000 ? 0 : 499), "express", t -> t + 1299);
static final Map<String, PaymentMethod> PAYMENTS = Map.of("card", new Card(), "giftcard", new GiftCard()); // factory table
public static void main(String[] args) {
CheckoutLike checkout = new LoggedCheckout(new Checkout());
Object[][] cases = {{"card", "standard", "SAVE10", 6000}, {"giftcard", "express", "FLAT5", 4000},
{"giftcard", "standard", "", 12000}};
for (Object[] c : cases) {
String result = checkout.run((String) c[0], (String) c[1], (String) c[2], (Integer) c[3]);
System.out.println(result);
}
}
}
interface PaymentMethod { String pay(int total); }
class Card implements PaymentMethod { public String pay(int total) { return "card charged " + total; } }
class GiftCard implements PaymentMethod {
public String pay(int total) { return total <= 10000 ? "gift card charged " + total : "gift card declined"; }
}
interface CheckoutLike { String run(String payment, String shipping, String coupon, int subtotal); }
class Checkout implements CheckoutLike {
public String run(String payment, String shipping, String coupon, int subtotal) {
int total = Main.SHIPPING.get(shipping).applyAsInt(Main.COUPONS.get(coupon).applyAsInt(subtotal));
return Main.PAYMENTS.get(payment).pay(total);
}
}
class LoggedCheckout implements CheckoutLike { // decorator: same run(), adds logging
private final CheckoutLike inner;
LoggedCheckout(CheckoutLike inner) { this.inner = inner; }
public String run(String payment, String shipping, String coupon, int subtotal) {
System.out.println("log: checkout " + payment + "/" + shipping + "/" + coupon);
return inner.run(payment, shipping, coupon, subtotal);
}
}
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
Run this on your computer — see “Get Started” for setup.
<?php
$coupons = ["SAVE10" => fn($t) => $t - intdiv($t, 10), "FLAT5" => fn($t) => $t - 500, "" => fn($t) => $t]; // strategies
$shippingRules = ["standard" => fn($t) => $t + ($t >= 5000 ? 0 : 499), "express" => fn($t) => $t + 1299];
interface PaymentMethod { public function pay(int $total): string; }
class Card implements PaymentMethod { public function pay(int $total): string { return "card charged $total"; } }
class GiftCard implements PaymentMethod {
public function pay(int $total): string { return $total <= 10000 ? "gift card charged $total" : "gift card declined"; }
}
$payments = ["card" => new Card(), "giftcard" => new GiftCard()]; // simple factory as a lookup table
interface CheckoutLike { public function run(string $payment, string $shipping, string $coupon, int $subtotal): string; }
class Checkout implements CheckoutLike {
public function __construct(private array $coupons, private array $shipping, private array $payments) {}
public function run(string $payment, string $shipping, string $coupon, int $subtotal): string {
$total = ($this->shipping[$shipping])(($this->coupons[$coupon])($subtotal));
return $this->payments[$payment]->pay($total);
}
}
class LoggedCheckout implements CheckoutLike { // decorator: same run(), adds logging
public function __construct(private CheckoutLike $inner) {}
public function run(string $payment, string $shipping, string $coupon, int $subtotal): string {
echo "log: checkout $payment/$shipping/$coupon\n";
return $this->inner->run($payment, $shipping, $coupon, $subtotal);
}
}
$checkout = new LoggedCheckout(new Checkout($coupons, $shippingRules, $payments));
foreach ([["card", "standard", "SAVE10", 6000], ["giftcard", "express", "FLAT5", 4000], ["giftcard", "standard", "", 12000]] as $args) {
$result = $checkout->run(...$args);
echo $result, "\n";
}
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.