Lesson 4 - Design Patterns II: Behavioral
Welcome to Design Patterns II: Behavioral
The previous lesson covered creational and structural patterns: ways to create objects and ways to assemble them into larger structures. This lesson covers a third family, behavioral patterns. These patterns answer a different question: once objects exist, how should they talk to each other and share responsibility for getting work done?
You will keep working inside Ledgerly, the small invoicing and subscription-billing app built by a three-person team. Ledgerly needs interchangeable discount rules, a way to alert several systems when an invoice gets paid, and a way to queue billing operations so they can run later and be undone if something goes wrong. Each of these needs maps directly onto one behavioral pattern.
By the end of this lesson, you will be able to:
- Apply the Strategy pattern to swap discount rules without touching the code that uses them
- Apply the Observer pattern so one event notifies several independent listeners
- Apply the Command pattern to queue, execute, and undo an operation
- Explain how each pattern reduces conditional logic or hidden coupling between objects
- Recognize which of the three patterns fits a given communication problem
Strategy: Interchangeable Discount Rules
Ledgerly’s PricingEngine currently picks a discount using an if/elif chain based on a customer’s plan tier. Every time the team adds a new kind of discount, someone has to open PricingEngine and add another branch. That branch touches code that has nothing to do with the new discount, and it risks breaking an existing branch by accident.
The Strategy pattern fixes this by pulling each discount rule out into its own class, all implementing the same interface. PricingEngine then holds a reference to whichever strategy it was given, and calls it without knowing which specific rule it is running.
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
"""Defines how to turn a subtotal into a discounted total."""
@abstractmethod
def apply(self, subtotal_cents):
pass
class NoDiscount(DiscountStrategy):
"""Applies no discount at all."""
def apply(self, subtotal_cents):
return subtotal_cents
class PercentageDiscount(DiscountStrategy):
"""Cuts the subtotal by a fixed percentage."""
def __init__(self, percent_off):
self.percent_off = percent_off
def apply(self, subtotal_cents):
return round(subtotal_cents * (1 - self.percent_off / 100))
class FlatAmountDiscount(DiscountStrategy):
"""Subtracts a fixed number of cents, never going below zero."""
def __init__(self, amount_off_cents):
self.amount_off_cents = amount_off_cents
def apply(self, subtotal_cents):
return max(0, subtotal_cents - self.amount_off_cents)
class PricingEngine:
"""Calculates an invoice total using whichever discount strategy it is given."""
def __init__(self, discount_strategy):
self.discount_strategy = discount_strategy
def calculate_total(self, subtotal_cents):
return self.discount_strategy.apply(subtotal_cents)
subtotal_cents = 12000 # $120.00
gold_engine = PricingEngine(PercentageDiscount(15))
print(gold_engine.calculate_total(subtotal_cents))
promo_engine = PricingEngine(FlatAmountDiscount(1000))
print(promo_engine.calculate_total(subtotal_cents))
standard_engine = PricingEngine(NoDiscount())
print(standard_engine.calculate_total(subtotal_cents))10200
11000
12000PricingEngine never checks a plan-tier name or runs an if statement. It only calls apply() on the strategy object it holds. If Ledgerly needs a loyalty discount next quarter, the team writes one new DiscountStrategy subclass. PricingEngine does not change, and no existing discount rule is at risk of breaking.
Observer: Notifying Several Listeners When an Invoice Is Paid
When a Ledgerly invoice gets fully paid, three separate things need to happen: an email receipt goes out, an SMS confirmation goes out, and an internal audit log records the event. Writing all three actions directly inside Invoice.record_payment() would tie invoice logic to email formatting, SMS providers, and logging details that have nothing to do with tracking a balance.
The Observer pattern separates these concerns. Invoice becomes a subject: an object other code can attach itself to, in order to be told about changes. Each listener becomes an observer: an object with one method that reacts to the event. Invoice calls that method on every attached observer, without knowing what any of them actually do.
from abc import ABC, abstractmethod
class InvoiceObserver(ABC):
"""Reacts when an invoice becomes fully paid."""
@abstractmethod
def on_invoice_paid(self, invoice):
pass
class EmailReceiptObserver(InvoiceObserver):
"""Sends an email receipt to the customer."""
def on_invoice_paid(self, invoice):
print(f"Email receipt sent for {invoice.invoice_id}")
class SMSConfirmationObserver(InvoiceObserver):
"""Sends a short SMS payment confirmation."""
def on_invoice_paid(self, invoice):
print(f"SMS confirmation sent for {invoice.invoice_id}")
class AuditLogObserver(InvoiceObserver):
"""Records every payment event for later review."""
def __init__(self):
self.entries = []
def on_invoice_paid(self, invoice):
self.entries.append(invoice.invoice_id)
print(f"Audit log recorded payment for {invoice.invoice_id}")
class Invoice:
"""Tracks payment state and notifies observers once fully paid."""
def __init__(self, invoice_id, amount_due_cents):
self.invoice_id = invoice_id
self._amount_due_cents = amount_due_cents
self._amount_paid_cents = 0
self._observers = []
def attach(self, observer):
"""Register an observer to receive future payment notifications."""
self._observers.append(observer)
def record_payment(self, amount_cents):
"""Add a payment and notify observers once the invoice is settled."""
self._amount_paid_cents += amount_cents
if self._amount_paid_cents >= self._amount_due_cents:
self._notify_paid()
def _notify_paid(self):
for observer in self._observers:
observer.on_invoice_paid(self)
invoice = Invoice("INV-2001", 5000)
audit_log = AuditLogObserver()
invoice.attach(EmailReceiptObserver())
invoice.attach(SMSConfirmationObserver())
invoice.attach(audit_log)
invoice.record_payment(5000)
print(audit_log.entries)Email receipt sent for INV-2001
SMS confirmation sent for INV-2001
Audit log recorded payment for INV-2001
['INV-2001']Invoice never imports an email library or an SMS provider. It only holds a list of observers and calls one method on each. If Ledgerly adds a fourth listener, such as a Slack alert for the finance team, the team writes one new InvoiceObserver subclass and attaches it. Invoice itself does not change at all.
Subject and observer stay loosely coupled
Invoice depends only on the InvoiceObserver interface, never on a concrete class like EmailReceiptObserver. This is the same idea as the PaymentGateway abstraction from an earlier lesson: the subject talks to a stable interface, and any object implementing that interface can plug in without the subject knowing or caring which one it is.
Command: Queuing and Undoing Billing Operations
Ledgerly sometimes needs to run a batch of billing operations, a charge here, a refund there, and later be able to reverse one of them if a mistake is found. Calling charge() and refund() directly does not leave anything behind to reverse: once the call returns, there is no record of what happened or how to undo it.
The Command pattern turns each operation into an object with two methods: execute() to run it, and undo() to reverse it. A queue class runs commands one at a time and keeps a history, so the most recent command can be undone on demand.
from abc import ABC, abstractmethod
class BillingCommand(ABC):
"""A billing action that can be queued, run later, and undone."""
@abstractmethod
def execute(self):
pass
@abstractmethod
def undo(self):
pass
class ChargeCommand(BillingCommand):
"""Adds an amount to a customer's balance."""
def __init__(self, ledger, customer_id, amount_cents):
self.ledger = ledger
self.customer_id = customer_id
self.amount_cents = amount_cents
def execute(self):
self.ledger[self.customer_id] = self.ledger.get(self.customer_id, 0) + self.amount_cents
print(f"Charged {self.customer_id} {self.amount_cents} cents")
def undo(self):
self.ledger[self.customer_id] -= self.amount_cents
print(f"Reversed charge of {self.amount_cents} cents for {self.customer_id}")
class RefundCommand(BillingCommand):
"""Subtracts an amount from a customer's balance."""
def __init__(self, ledger, customer_id, amount_cents):
self.ledger = ledger
self.customer_id = customer_id
self.amount_cents = amount_cents
def execute(self):
self.ledger[self.customer_id] -= self.amount_cents
print(f"Refunded {self.customer_id} {self.amount_cents} cents")
def undo(self):
self.ledger[self.customer_id] += self.amount_cents
print(f"Reversed refund of {self.amount_cents} cents for {self.customer_id}")
class BillingQueue:
"""Runs billing commands in order and keeps history so the last one can be undone."""
def __init__(self):
self._history = []
def run(self, command):
command.execute()
self._history.append(command)
def undo_last(self):
if not self._history:
print("Nothing to undo")
return
command = self._history.pop()
command.undo()
ledger = {}
queue = BillingQueue()
queue.run(ChargeCommand(ledger, "cust_42", 3000))
queue.run(RefundCommand(ledger, "cust_42", 500))
print(ledger)
queue.undo_last()
print(ledger)Charged cust_42 3000 cents
Refunded cust_42 500 cents
{'cust_42': 2500}
Reversed refund of 500 cents for cust_42
{'cust_42': 3000}BillingQueue never knows whether a command is a charge or a refund. It only calls execute() and, later, undo() on whatever command object it holds. This is what makes the undo history possible: each command already knows how to reverse itself, so the queue does not need special-case logic for every operation type.
Practice Exercises
Exercise 1: Add a loyalty discount strategy
Ledgerly wants a new discount rule: customers with more than two years of history get a flat $5.00 off, but only if that is larger than a 5% percentage discount would be. Sketch a LoyaltyDiscount class implementing DiscountStrategy that picks whichever discount is larger.
Hint
LoyaltyDiscount.apply() can compute both the flat $5.00 result and the 5% result internally, then return whichever is smaller (the smaller total means the bigger discount). It still returns a single number from apply(), so PricingEngine needs no changes at all — it just receives a new strategy object.
Exercise 2: Detach an observer
Using the Invoice class from this lesson, add a detach(observer) method that removes an observer from self._observers. Why might Ledgerly need this, for example if a customer opts out of SMS notifications?
Hint
detach() can call self._observers.remove(observer), guarded by a check that the observer is actually in the list to avoid a ValueError. Ledgerly needs this because attaching an observer once at invoice creation is not always permanent — a customer’s notification preferences can change after the invoice already exists.
Exercise 3: Undo more than one command
BillingQueue.undo_last() only reverses the single most recent command. Describe how you would extend BillingQueue to undo the last three commands in the correct order, and explain why the order in which you undo them matters.
Hint
Pop commands off self._history one at a time, from the end, calling undo() on each, exactly like undo_last() already does but in a loop of three. Order matters because commands are not always independent: undoing a refund before undoing a charge that came after it can leave the ledger in a state that never actually existed.
Summary
Behavioral patterns manage how objects communicate, rather than how they are created or assembled. Strategy lets PricingEngine swap discount rules like PercentageDiscount and FlatAmountDiscount without touching its own code. Observer lets Invoice notify EmailReceiptObserver, SMSConfirmationObserver, and AuditLogObserver through one shared method, without knowing what any of them do. Command turns billing operations like ChargeCommand and RefundCommand into objects that a BillingQueue can run, log, and undo, without special-casing each operation type.
Key Concepts
- Strategy — encapsulates an algorithm behind a shared interface so it can be swapped at runtime.
- Observer — lets a subject notify a list of independent listeners without knowing what each one does.
- Command — turns an operation into an object with
execute()andundo(), so it can be queued, logged, and reversed. - Loose coupling — the thread connecting all three patterns: the object driving the behavior depends only on an interface, never on a specific class.
Why This Matters
Every one of these patterns removes a form of hidden coupling that would otherwise make Ledgerly harder to extend. Without Strategy, every new discount rule means editing PricingEngine and risking existing rules. Without Observer, every new notification channel means editing Invoice itself. Without Command, there is no way to safely reverse a batch of billing operations after the fact. Together with the creational and structural patterns from the previous lesson, these give the team a complete vocabulary for solving recurring design problems without inventing a new, one-off solution each time.
Next Steps
Guided Project: Architecting Ledgerly's Domain
Apply creational, structural, and behavioral patterns together to design a real piece of Ledgerly's domain model.
Back to Module Overview
Return to the Design, Architecture & OOP module overview
Continue Building Your Skills
You now have three behavioral patterns to add to the creational and structural patterns from the previous lesson: Strategy for interchangeable algorithms, Observer for one-to-many notifications, and Command for queueable, reversible operations. The guided project next puts all of these to work together, designing a real piece of Ledgerly’s domain from scratch.