Lesson 5 - Guided Project: Architecting Ledgerly's Domain

Welcome to the Guided Project

Ledgerly, the three-person invoicing app you have followed through this course, now needs a refund feature. A freelancer using the app wants to refund part of an invoice after a customer complains about the work, and the team has to decide how to build that safely, for two different payment methods, without making a mess of the billing code that already works. This project takes that one feature from an open design question to running, tested code, using the architecture thinking, object-oriented design, and pattern choices from this module’s first four lessons.

This project has four stages. First, you make an architectural decision: where should refund logic live, inside the existing billing code or in a new module of its own? Second, you design a class hierarchy for refund rules, using inheritance for what Stripe and bank-transfer refunds share and composition for how RefundService uses them. Third, you apply two design patterns on purpose, Strategy for the refund rules themselves and Observer for what happens after a refund is approved, and you justify each choice rather than reaching for a pattern out of habit. Fourth, you build the working code and prove it with a real test. By the end, you will have designed and shipped one real, non-trivial piece of Ledgerly’s domain.

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

  • Decide where a new piece of business logic should live, and justify the decision using coupling and cohesion
  • Design a class hierarchy that uses inheritance for shared rules and composition for how a service uses those rules
  • Apply the Strategy pattern to swap refund rules per payment gateway without branching on a gateway name
  • Apply the Observer pattern so unrelated parts of a system can react to an event without the event’s source knowing they exist
  • Write a test that proves a design choice by substituting a fake dependency for a real one

Stage 1: Decide Where Refund Logic Lives

Ledgerly’s billing code already has a PaymentGateway that takes a payment, an InvoiceRepository that stores and looks up invoices, and a NotificationService that reaches customers. A refund is a very different operation from taking a payment: it needs its own eligibility rules, its own approval trail, and its own record of what happened, even though it starts from the same Invoice object billing already knows about. The first design question is not “how do we write the code,” it is “which part of the system owns this logic.”

Coupling is how much one part of a system depends on the internal details of another part; cohesion is how closely the responsibilities inside one part of a system relate to each other. Two ways to place refund logic in Ledgerly, and what each one does to those two properties:

  • Add refund methods directly onto the existing billing classes. This keeps everything in one place, but it lowers cohesion inside billing, because “take a payment” and “decide whether to reverse one, and by how much” are different responsibilities that would now sit in the same class. It also raises coupling, because every future change to refund rules risks breaking code that has nothing to do with refunds.
  • Create a new refunds module that depends on billing only through its existing abstractions, InvoiceRepository for reading invoice data and NotificationService for reaching customers, never touching billing’s internals directly. This keeps billing’s cohesion intact and keeps coupling one-directional: refunds knows about billing’s abstractions, but billing never has to know refunds exists.

The team picks the second option. A new RefundService, together with a RefundPolicy hierarchy and a set of refund-completion observers, lives in its own module. It reads invoices through InvoiceRepository and it can notify customers through the same kind of abstraction NotificationService already represents, but it never reaches into billing’s own database tables or edits a PaymentGateway object directly. That boundary is what the rest of this project builds inside.

A diagram titled 'A new Refunds module, depending on Billing only through an abstraction.' On the left, a blue box labeled Billing Module contains PaymentGateway (Stripe-like) and InvoiceRepository (interface), with a caption noting it owns Invoice, Customer, and how payments are stored and taken. On the right, a purple box labeled Refunds Module (new) contains RefundService, RefundPolicy (Strategy hierarchy), and RefundObserver (Observer interface), with a caption noting it decides refund eligibility and amount and never edits billing's own tables. An arrow points from the Refunds Module to the Billing Module labeled 'depends on the InvoiceRepository interface only.' Below, three green boxes represent observers: NotificationObserver (emails the customer), AccountingLedgerObserver (records a ledger entry), and a dashed box labeled Future observer, e.g. fraud review. Arrows fan out from RefundService to all three, labeled 'notifies every registered observer once a refund is approved,' with the arrow to the future observer dashed to show it does not exist yet. A caption at the bottom explains that the Refunds module is a separate, low-coupling module depending on Billing only through the InvoiceRepository interface, choosing a RefundPolicy per payment gateway through Strategy, and notifying any number of observers without knowing what they do.
The Refunds module depends on Billing only through the InvoiceRepository interface, keeping refund eligibility rules and notifications completely separate from how payments are taken and stored.

A dependency arrow should point toward stability

Notice the arrow in the diagram runs one way: refunds depends on billing’s interfaces, never the reverse. Billing’s code does not import anything from the refunds module. That one-directional arrow is what lets the team change refund rules next quarter without re-testing every part of billing that already works.


Stage 2: Design the RefundPolicy Class Hierarchy

Refunds behave differently depending on how the customer originally paid. A Stripe-backed card payment can often be refunded instantly, in full or in part, within a fairly wide window. A bank transfer refund needs a human to actually move money back, so it only makes sense as a full-amount refund, within a shorter window, before someone forgets what it was for. That difference in behavior, sharing one shape but differing in rules, is exactly what an inheritance hierarchy is for.

RefundPolicy is an abstract base class: a class that defines a shared method every subclass must implement, but that Python refuses to instantiate on its own. Every concrete refund rule extends it and fills in evaluate, the one method that decides whether a request is approved:

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
from enum import Enum


class RefundStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"


@dataclass
class Customer:
    name: str
    email: str


@dataclass
class Invoice:
    id: str
    customer: Customer
    amount_paid: float
    payment_date: date
    payment_gateway: str  # "stripe" or "bank_transfer"


@dataclass
class RefundDecision:
    status: RefundStatus
    amount: float
    reason: str


class RefundPolicy(ABC):
    """Decides whether an invoice qualifies for a refund, and for how much."""

    @abstractmethod
    def evaluate(
        self, invoice: Invoice, requested_amount: float, today: date
    ) -> RefundDecision:
        """Return a RefundDecision for one refund request."""
        raise NotImplementedError


try:
    RefundPolicy()
except TypeError as e:
    print(f"TypeError: {e}")
TypeError: Can't instantiate abstract class RefundPolicy without an implementation for abstract method 'evaluate'

That error is the class hierarchy doing its job: RefundPolicy describes a contract, not a working rule, so nothing can create one directly until a subclass fills in evaluate. Here are the two concrete policies, each one owning its own refund window and its own rule about partial amounts:

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
from enum import Enum


class RefundStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"


@dataclass
class Customer:
    name: str
    email: str


@dataclass
class Invoice:
    id: str
    customer: Customer
    amount_paid: float
    payment_date: date
    payment_gateway: str  # "stripe" or "bank_transfer"


@dataclass
class RefundDecision:
    status: RefundStatus
    amount: float
    reason: str


class RefundPolicy(ABC):
    """Decides whether an invoice qualifies for a refund, and for how much."""

    @abstractmethod
    def evaluate(
        self, invoice: Invoice, requested_amount: float, today: date
    ) -> RefundDecision:
        """Return a RefundDecision for one refund request."""
        raise NotImplementedError


class StripeRefundPolicy(RefundPolicy):
    """Stripe allows full or partial refunds within 120 days of payment."""

    REFUND_WINDOW_DAYS = 120

    def evaluate(self, invoice, requested_amount, today):
        days_since_payment = (today - invoice.payment_date).days
        if days_since_payment > self.REFUND_WINDOW_DAYS:
            return RefundDecision(
                RefundStatus.REJECTED,
                0.0,
                f"Stripe's {self.REFUND_WINDOW_DAYS}-day refund window has passed "
                f"({days_since_payment} days since payment)",
            )
        if requested_amount > invoice.amount_paid:
            return RefundDecision(
                RefundStatus.REJECTED, 0.0, "Requested amount exceeds the amount paid"
            )
        return RefundDecision(
            RefundStatus.APPROVED, requested_amount, "Approved instantly through Stripe"
        )


class ManualBankTransferRefundPolicy(RefundPolicy):
    """Bank transfer refunds are full-amount only, within 30 days, needing manual review."""

    REFUND_WINDOW_DAYS = 30

    def evaluate(self, invoice, requested_amount, today):
        days_since_payment = (today - invoice.payment_date).days
        if days_since_payment > self.REFUND_WINDOW_DAYS:
            return RefundDecision(
                RefundStatus.REJECTED,
                0.0,
                f"Bank transfer's {self.REFUND_WINDOW_DAYS}-day refund window has passed "
                f"({days_since_payment} days since payment)",
            )
        if requested_amount != invoice.amount_paid:
            return RefundDecision(
                RefundStatus.REJECTED,
                0.0,
                "Bank transfer refunds must be for the full amount paid, not a partial amount",
            )
        return RefundDecision(
            RefundStatus.APPROVED,
            requested_amount,
            "Approved, pending a manual bank transfer",
        )


maria = Customer(name="Maria Santos", email="[email protected]")
today = date(2026, 7, 5)

stripe_invoice = Invoice(
    id="INV-2001", customer=maria, amount_paid=200.0,
    payment_date=date(2026, 6, 5), payment_gateway="stripe",
)
old_stripe_invoice = Invoice(
    id="INV-2002", customer=maria, amount_paid=90.0,
    payment_date=date(2026, 1, 1), payment_gateway="stripe",
)
bank_invoice = Invoice(
    id="INV-2003", customer=maria, amount_paid=500.0,
    payment_date=date(2026, 6, 25), payment_gateway="bank_transfer",
)

policies_by_gateway: dict[str, RefundPolicy] = {
    "stripe": StripeRefundPolicy(),
    "bank_transfer": ManualBankTransferRefundPolicy(),
}


def describe(invoice: Invoice, requested_amount: float) -> None:
    policy = policies_by_gateway[invoice.payment_gateway]
    decision = policy.evaluate(invoice, requested_amount, today)
    print(f"{invoice.id} ({invoice.payment_gateway}): {decision.status.value} - {decision.reason}")


describe(stripe_invoice, 50.0)
describe(old_stripe_invoice, 90.0)
describe(bank_invoice, 100.0)
describe(bank_invoice, 500.0)
INV-2001 (stripe): approved - Approved instantly through Stripe
INV-2002 (stripe): rejected - Stripe's 120-day refund window has passed (185 days since payment)
INV-2003 (bank_transfer): rejected - Bank transfer refunds must be for the full amount paid, not a partial amount
INV-2003 (bank_transfer): approved - Approved, pending a manual bank transfer

Inheritance carries the shared shape, both policies expose the same evaluate method, so any code that holds a RefundPolicy can call it without knowing which subclass it actually has. Composition is what a service does with that shape: policies_by_gateway is a plain dictionary holding policy objects, not a chain of parent classes, and the describe function above uses composition already, looking up whichever policy the invoice’s gateway needs. Stage 3 turns that dictionary lookup into the Strategy pattern by name, and adds a second pattern for what happens after a refund is approved.


Stage 3: Apply Strategy and Observer, and Justify Each One

A design pattern is a proven, named solution to a recurring design problem, not a piece of code to copy blindly. Picking one because it fits a real problem in this feature, and being able to say why, is the actual skill this module has been building toward.

The Strategy pattern lets a class swap out one algorithm for another at runtime, using a shared interface, without changing the class that uses it. That fits the refund rules exactly: RefundPolicy is the shared interface, StripeRefundPolicy and ManualBankTransferRefundPolicy are interchangeable strategies, and nothing in the code that calls evaluate needs an if gateway == "stripe" branch anywhere. Stage 2’s policies_by_gateway dictionary is Strategy in its simplest form: the right object gets picked once, by gateway name, and everything after that call is generic. Adding a third gateway later, PayPal, say, means writing one new class, not editing any existing one.

The Observer pattern lets one object announce that something happened without knowing, or caring, who is listening. That fits what happens after a refund is approved: the customer needs an email, and accounting needs a ledger entry, and neither of those two concerns has anything to do with the other, or with how the refund was approved in the first place. Without Observer, RefundService would need to import an email sender and a ledger writer directly, and every new thing that should react to a refund, a fraud-review flag, say, would mean editing RefundService again. With Observer, RefundService just calls one method on every object registered as a listener, and it never has to change to support a new listener:

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
from enum import Enum


class RefundStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"


@dataclass
class Customer:
    name: str
    email: str


@dataclass
class Invoice:
    id: str
    customer: Customer
    amount_paid: float
    payment_date: date
    payment_gateway: str  # "stripe" or "bank_transfer"


@dataclass
class RefundDecision:
    status: RefundStatus
    amount: float
    reason: str


class RefundObserver(ABC):
    """Reacts to a refund once it is approved. Concrete observers plug in here."""

    @abstractmethod
    def on_refund_completed(self, invoice: Invoice, decision: RefundDecision) -> None:
        raise NotImplementedError


class NotificationObserver(RefundObserver):
    """Tells the customer their refund went through. Stands in for a real email provider."""

    def __init__(self) -> None:
        self.sent_log: list[str] = []

    def on_refund_completed(self, invoice, decision):
        message = (
            f"To: {invoice.customer.email} | Your refund of ${decision.amount:.2f} "
            f"for {invoice.id} has been approved."
        )
        self.sent_log.append(message)
        print(message)


class AccountingLedgerObserver(RefundObserver):
    """Records a ledger entry for every completed refund."""

    def __init__(self) -> None:
        self.entries: list[str] = []

    def on_refund_completed(self, invoice, decision):
        entry = f"LEDGER: -${decision.amount:.2f} against {invoice.id} ({invoice.payment_gateway})"
        self.entries.append(entry)
        print(entry)


print("RefundObserver, NotificationObserver, and AccountingLedgerObserver are defined.")
RefundObserver, NotificationObserver, and AccountingLedgerObserver are defined.

Neither NotificationObserver nor AccountingLedgerObserver knows the other exists, and RefundPolicy, from Stage 2, does not know either of them exists. Each class has exactly one job. Stage 4 wires all three ideas, the policy hierarchy, Strategy’s dictionary lookup, and Observer’s list of listeners, into one working RefundService.


Stage 4: Build and Test the Working RefundService

RefundService is the one class that ties Stage 2’s policies and Stage 3’s observers together. It picks a RefundPolicy by the invoice’s payment gateway, Strategy in action, evaluates the request, and, only if the refund is approved, tells every registered RefundObserver about it:

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
from enum import Enum


class RefundStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"


@dataclass
class Customer:
    name: str
    email: str


@dataclass
class Invoice:
    id: str
    customer: Customer
    amount_paid: float
    payment_date: date
    payment_gateway: str  # "stripe" or "bank_transfer"


@dataclass
class RefundDecision:
    status: RefundStatus
    amount: float
    reason: str


class RefundPolicy(ABC):
    """Decides whether an invoice qualifies for a refund, and for how much."""

    @abstractmethod
    def evaluate(
        self, invoice: Invoice, requested_amount: float, today: date
    ) -> RefundDecision:
        raise NotImplementedError


class StripeRefundPolicy(RefundPolicy):
    """Stripe allows full or partial refunds within 120 days of payment."""

    REFUND_WINDOW_DAYS = 120

    def evaluate(self, invoice, requested_amount, today):
        days_since_payment = (today - invoice.payment_date).days
        if days_since_payment > self.REFUND_WINDOW_DAYS:
            return RefundDecision(
                RefundStatus.REJECTED,
                0.0,
                f"Stripe's {self.REFUND_WINDOW_DAYS}-day refund window has passed "
                f"({days_since_payment} days since payment)",
            )
        if requested_amount > invoice.amount_paid:
            return RefundDecision(
                RefundStatus.REJECTED, 0.0, "Requested amount exceeds the amount paid"
            )
        return RefundDecision(
            RefundStatus.APPROVED, requested_amount, "Approved instantly through Stripe"
        )


class ManualBankTransferRefundPolicy(RefundPolicy):
    """Bank transfer refunds are full-amount only, within 30 days, needing manual review."""

    REFUND_WINDOW_DAYS = 30

    def evaluate(self, invoice, requested_amount, today):
        days_since_payment = (today - invoice.payment_date).days
        if days_since_payment > self.REFUND_WINDOW_DAYS:
            return RefundDecision(
                RefundStatus.REJECTED,
                0.0,
                f"Bank transfer's {self.REFUND_WINDOW_DAYS}-day refund window has passed "
                f"({days_since_payment} days since payment)",
            )
        if requested_amount != invoice.amount_paid:
            return RefundDecision(
                RefundStatus.REJECTED,
                0.0,
                "Bank transfer refunds must be for the full amount paid, not a partial amount",
            )
        return RefundDecision(
            RefundStatus.APPROVED,
            requested_amount,
            "Approved, pending a manual bank transfer",
        )


class RefundObserver(ABC):
    """Reacts to a refund once it is approved. Concrete observers plug in here."""

    @abstractmethod
    def on_refund_completed(self, invoice: Invoice, decision: RefundDecision) -> None:
        raise NotImplementedError


class NotificationObserver(RefundObserver):
    """Tells the customer their refund went through. Stands in for a real email provider."""

    def __init__(self) -> None:
        self.sent_log: list[str] = []

    def on_refund_completed(self, invoice, decision):
        message = (
            f"To: {invoice.customer.email} | Your refund of ${decision.amount:.2f} "
            f"for {invoice.id} has been approved."
        )
        self.sent_log.append(message)
        print(message)


class AccountingLedgerObserver(RefundObserver):
    """Records a ledger entry for every completed refund."""

    def __init__(self) -> None:
        self.entries: list[str] = []

    def on_refund_completed(self, invoice, decision):
        entry = f"LEDGER: -${decision.amount:.2f} against {invoice.id} ({invoice.payment_gateway})"
        self.entries.append(entry)
        print(entry)


class RefundService:
    """Coordinates a refund request: picks a policy, evaluates it, notifies observers.

    RefundService never checks a gateway name with an if-statement, and it never
    imports NotificationObserver or AccountingLedgerObserver directly. It only
    knows about the RefundPolicy and RefundObserver abstractions.
    """

    def __init__(self, policies_by_gateway: dict[str, RefundPolicy]) -> None:
        self._policies_by_gateway = policies_by_gateway
        self._observers: list[RefundObserver] = []

    def add_observer(self, observer: RefundObserver) -> None:
        self._observers.append(observer)

    def request_refund(
        self, invoice: Invoice, requested_amount: float, today: date
    ) -> RefundDecision:
        policy = self._policies_by_gateway[invoice.payment_gateway]
        decision = policy.evaluate(invoice, requested_amount, today)
        if decision.status is RefundStatus.APPROVED:
            for observer in self._observers:
                observer.on_refund_completed(invoice, decision)
        return decision


maria = Customer(name="Maria Santos", email="[email protected]")
today = date(2026, 7, 5)

stripe_invoice = Invoice(
    id="INV-2001", customer=maria, amount_paid=200.0,
    payment_date=date(2026, 6, 5), payment_gateway="stripe",
)
bank_invoice = Invoice(
    id="INV-2003", customer=maria, amount_paid=500.0,
    payment_date=date(2026, 6, 25), payment_gateway="bank_transfer",
)

refund_service = RefundService({
    "stripe": StripeRefundPolicy(),
    "bank_transfer": ManualBankTransferRefundPolicy(),
})
refund_service.add_observer(NotificationObserver())
refund_service.add_observer(AccountingLedgerObserver())

decision_1 = refund_service.request_refund(stripe_invoice, 50.0, today)
print(f"Decision for {stripe_invoice.id}: {decision_1.status.value}")

decision_2 = refund_service.request_refund(bank_invoice, 100.0, today)
print(f"Decision for {bank_invoice.id}: {decision_2.status.value}")
To: [email protected] | Your refund of $50.00 for INV-2001 has been approved.
LEDGER: -$50.00 against INV-2001 (stripe)
Decision for INV-2001: approved
Decision for INV-2003: rejected

The approved Stripe refund fires both observers, in the order they were registered; the rejected bank-transfer request, over the limit for a partial refund, fires neither. That is the payoff of Observer: RefundService never wrote a line of code that says “send an email” or “write to the ledger,” it only ever called on_refund_completed on whatever objects were registered.

A design is only proven once a test exercises it without a real email or a real ledger. Using a test double, a stand-in object built to look like a real dependency without doing what it does, the following test swaps in a FakeRefundObserver and checks the decision logic directly:

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
from enum import Enum


class RefundStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"


@dataclass
class Customer:
    name: str
    email: str


@dataclass
class Invoice:
    id: str
    customer: Customer
    amount_paid: float
    payment_date: date
    payment_gateway: str


@dataclass
class RefundDecision:
    status: RefundStatus
    amount: float
    reason: str


class RefundPolicy(ABC):
    @abstractmethod
    def evaluate(self, invoice, requested_amount, today):
        raise NotImplementedError


class StripeRefundPolicy(RefundPolicy):
    REFUND_WINDOW_DAYS = 120

    def evaluate(self, invoice, requested_amount, today):
        days_since_payment = (today - invoice.payment_date).days
        if days_since_payment > self.REFUND_WINDOW_DAYS:
            return RefundDecision(
                RefundStatus.REJECTED, 0.0,
                f"Stripe's {self.REFUND_WINDOW_DAYS}-day refund window has passed "
                f"({days_since_payment} days since payment)",
            )
        if requested_amount > invoice.amount_paid:
            return RefundDecision(RefundStatus.REJECTED, 0.0, "Requested amount exceeds the amount paid")
        return RefundDecision(RefundStatus.APPROVED, requested_amount, "Approved instantly through Stripe")


class ManualBankTransferRefundPolicy(RefundPolicy):
    REFUND_WINDOW_DAYS = 30

    def evaluate(self, invoice, requested_amount, today):
        days_since_payment = (today - invoice.payment_date).days
        if days_since_payment > self.REFUND_WINDOW_DAYS:
            return RefundDecision(
                RefundStatus.REJECTED, 0.0,
                f"Bank transfer's {self.REFUND_WINDOW_DAYS}-day refund window has passed "
                f"({days_since_payment} days since payment)",
            )
        if requested_amount != invoice.amount_paid:
            return RefundDecision(
                RefundStatus.REJECTED, 0.0,
                "Bank transfer refunds must be for the full amount paid, not a partial amount",
            )
        return RefundDecision(RefundStatus.APPROVED, requested_amount, "Approved, pending a manual bank transfer")


class RefundObserver(ABC):
    @abstractmethod
    def on_refund_completed(self, invoice, decision):
        raise NotImplementedError


class RefundService:
    def __init__(self, policies_by_gateway):
        self._policies_by_gateway = policies_by_gateway
        self._observers: list[RefundObserver] = []

    def add_observer(self, observer):
        self._observers.append(observer)

    def request_refund(self, invoice, requested_amount, today):
        policy = self._policies_by_gateway[invoice.payment_gateway]
        decision = policy.evaluate(invoice, requested_amount, today)
        if decision.status is RefundStatus.APPROVED:
            for observer in self._observers:
                observer.on_refund_completed(invoice, decision)
        return decision


class FakeRefundObserver(RefundObserver):
    """A test double: records calls instead of sending an email or writing a ledger entry."""

    def __init__(self) -> None:
        self.completed_refunds: list[tuple[str, float]] = []

    def on_refund_completed(self, invoice, decision):
        self.completed_refunds.append((invoice.id, decision.amount))


def test_stripe_refund_within_window_is_approved_and_observers_notified():
    test_customer = Customer(name="Test Customer", email="[email protected]")
    today = date(2026, 7, 5)
    invoice = Invoice("T-1", test_customer, 200.0, date(2026, 6, 5), "stripe")

    service = RefundService({"stripe": StripeRefundPolicy()})
    fake_observer = FakeRefundObserver()
    service.add_observer(fake_observer)

    decision = service.request_refund(invoice, 50.0, today)

    assert decision.status is RefundStatus.APPROVED
    assert fake_observer.completed_refunds == [("T-1", 50.0)]


def test_bank_transfer_partial_amount_is_rejected_and_no_observer_fires():
    test_customer = Customer(name="Test Customer", email="[email protected]")
    today = date(2026, 7, 5)
    invoice = Invoice("T-2", test_customer, 500.0, date(2026, 6, 25), "bank_transfer")

    service = RefundService({"bank_transfer": ManualBankTransferRefundPolicy()})
    fake_observer = FakeRefundObserver()
    service.add_observer(fake_observer)

    decision = service.request_refund(invoice, 100.0, today)

    assert decision.status is RefundStatus.REJECTED
    assert fake_observer.completed_refunds == []


def test_stripe_refund_past_window_is_rejected():
    test_customer = Customer(name="Test Customer", email="[email protected]")
    today = date(2026, 7, 5)
    invoice = Invoice("T-3", test_customer, 90.0, date(2026, 1, 1), "stripe")

    service = RefundService({"stripe": StripeRefundPolicy()})
    decision = service.request_refund(invoice, 90.0, today)

    assert decision.status is RefundStatus.REJECTED
    assert "120-day" in decision.reason


test_stripe_refund_within_window_is_approved_and_observers_notified()
test_bank_transfer_partial_amount_is_rejected_and_no_observer_fires()
test_stripe_refund_past_window_is_rejected()
print("All tests passed.")
All tests passed.

Every assertion in these three tests checks a decision your own RefundService made, using a FakeRefundObserver that never sends a real email or writes a real ledger row. That is only possible because Stage 1’s boundary, Stage 2’s shared RefundPolicy interface, and Stage 3’s RefundObserver interface all point the same way: toward abstractions a test can substitute.


Practice Exercises

Exercise 1: Add a PayPal refund policy

Sketch a new PayPalRefundPolicy class that extends RefundPolicy from Stage 2. Give it its own refund window and its own rule about partial amounts, different from both Stripe’s and the bank transfer’s. Describe exactly what would need to change inside RefundService to support it.

Hint

PayPalRefundPolicy only needs one method, evaluate(self, invoice, requested_amount, today), using whatever window and partial-amount rule fits PayPal’s real behavior. Nothing inside RefundService needs to change at all; you would only add one new entry to the policies_by_gateway dictionary passed into its constructor. That is the Strategy pattern paying off exactly as Stage 3 described: a new algorithm, zero edits to the class that uses it.

Exercise 2: Add a fraud-review observer

A new requirement: any refund over $400 should also get logged to a fraud-review queue for a human to double-check, in addition to the customer email and the ledger entry. Sketch a FraudReviewObserver class implementing RefundObserver from Stage 3, and explain where in RefundService you would register it.

Hint

FraudReviewObserver.on_refund_completed would check decision.amount > 400 and only act, appending to a queue, say, when that condition is true; it can ignore smaller refunds entirely inside its own method body. You would register it the same way as the other two, with one refund_service.add_observer(FraudReviewObserver()) call, and RefundService itself would not change one line. That is Observer’s real benefit: new behavior arrives as a new class, not as a new branch inside code that already works.

Exercise 3: Write a test for a rejected, over-limit Stripe refund

Using the style from Stage 4, write a test function that creates a Stripe-paid invoice with amount_paid=100.0, requests a refund of 150.0 on the same day it was paid, and asserts the request is rejected. Include an assertion that checks the rejection reason mentions the amount paid.

Hint

Build the invoice and a RefundService({"stripe": StripeRefundPolicy()}) the same way Stage 4’s tests do, call request_refund with the too-large amount, then assert decision.status is RefundStatus.REJECTED and "exceeds the amount paid" in decision.reason. Running it should require no fake observer at all, since a rejected refund never calls one.


Summary

This guided project took one real Ledgerly feature, refunds, from an open design question to tested code, closing Module 2. Stage 1 decided that refund logic belongs in its own module, depending on billing only through the InvoiceRepository interface, keeping coupling one-directional and billing’s own cohesion intact. Stage 2 built a RefundPolicy class hierarchy, using inheritance for the shared evaluate contract and composition for how a lookup table picks the right policy. Stage 3 applied the Strategy pattern to those refund rules and the Observer pattern to what happens after approval, justifying each choice against the real problem it solved rather than reaching for a pattern by habit. Stage 4 built RefundService around both patterns and proved the design with a real test, swapping in a FakeRefundObserver to check the decision logic with no real email or ledger entry involved.

Key Concepts

  • Coupling and cohesion — how much one part of a system depends on another’s internals, and how closely one part’s own responsibilities relate to each other; both guided the decision to give refunds its own module.
  • Abstract base class — a class like RefundPolicy that defines a shared method every subclass must implement, and that Python refuses to instantiate directly.
  • Strategy pattern — interchangeable algorithms, like StripeRefundPolicy and ManualBankTransferRefundPolicy, behind one shared interface, chosen at runtime without branching on a type name.
  • Observer pattern — one object, RefundService, announcing an event to any number of registered listeners, like NotificationObserver and AccountingLedgerObserver, without knowing what they do.
  • Test double — a fake object, like FakeRefundObserver, that stands in for a real dependency during a test.

Why This Matters

A refund feature looks small from the outside, one button, one email, but it touches money, trust, and at least two different payment providers with genuinely different rules. The architecture decision in Stage 1 keeps that complexity from leaking into billing code that already works. The class hierarchy in Stage 2 keeps Stripe’s rules and bank transfer’s rules from tangling into one confusing method full of conditionals. The two patterns in Stage 3 and Stage 4 are not decoration, they are what let the team add a PayPal policy or a fraud-review check next quarter by writing a new class, never by editing RefundService again. That is what deliberate design buys a real team: change that stays contained instead of change that ripples through everything.

Next Steps

Module 3: Writing Quality, Tested Code

Start the next module: clean code, testing fundamentals, and behavior-driven development.

Back to Course Overview

Review the full Software Engineering Fundamentals course.


Continue Building Your Skills

You have now designed and built a real Ledgerly feature end to end, from a module-boundary decision through a tested class hierarchy and two deliberately chosen design patterns, closing out Module 2. The habits behind that work, drawing dependency arrows on purpose, sharing behavior through abstractions instead of conditionals, and proving a design with a substitutable test double, carry forward into every feature you design from here. Next, in Module 3, you turn that same care toward the code itself: writing it cleanly, testing it thoroughly, and describing its behavior in a way both engineers and stakeholders can read.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com