Ran Wei /Object-Oriented Design/Module 7
中文
Object-Oriented Design — Ran Wei

Module 7: Design Patterns

Factory Method, Singleton, Observer, Strategy — proven GoF solutions applied to the bookshop with SysML v2 mappings.

Prereq: OOD Module 6Python 3UML 2.5SysML v2
1

What Are Design Patterns?

Design patterns are reusable, named solutions to recurring design problems. Catalogued by the "Gang of Four" (1994) into three categories:

CategoryIntentOur examples
CreationalHow objects are createdFactory Method, Singleton
StructuralHow objects are composedDecorator
BehaviouralHow objects interactObserver, Strategy
ℹ️ PATTERNS ARE NOT COPY-PASTE
A pattern is a template for solving a class of problem — not a ready-made snippet. Applying a pattern requires understanding the problem it solves. Overusing patterns adds unnecessary complexity.
2

Factory Method

Problem: code that creates Book objects is coupled to specific concrete types. Adding AudioBook requires finding every DigitalBook(...) call site.

Solution: a factory centralises creation. Callers depend on the Book interface, never on concrete classes:

Python
class BookFactory:
    """Creates the correct Book subtype from a data dictionary."""

    @staticmethod
    def create(data: dict) -> Book:
        t = data.get("type","").lower()
        if t == "digital":
            return DigitalBook(
                isbn=data["isbn"], title=data["title"],
                author=data["author"], price=data["price"],
                file_format=data["file_format"],
                file_size=data["file_size"],
                download_url=data["download_url"],
            )
        elif t == "physical":
            return PhysicalBook(
                isbn=data["isbn"], title=data["title"],
                author=data["author"], price=data["price"],
                weight=data["weight"],
                dimensions=data["dimensions"],
                warehouse=data["warehouse"],
            )
        else:
            raise ValueError(f"Unknown book type: {t!r}")

# Caller works with Book — not aware of concrete types
books: list[Book] = [BookFactory.create(d) for d in raw_data]
for b in books: print(b.get_details())  # polymorphism handles it
diagram
Figure 1 — BookFactory creates the correct concrete type. Inventory uses the Singleton pattern — one instance returned by every call to get_instance().
3

Singleton

Problem: multiple Inventory instances cause inconsistent stock. Solution: ensure at most one instance exists, thread-safely:

Python
import threading

class Inventory:
    _instance: "Inventory | None" = None
    _lock = threading.Lock()

    def __new__(cls):
        with cls._lock:
            if cls._instance is None:
                cls._instance = super().__new__(cls)
                cls._instance._catalogue = {}
                cls._instance._observers = []
        return cls._instance

    @classmethod
    def get_instance(cls) -> "Inventory":
        return cls()   # __new__ ensures only one exists

    def add_book(self, book: Book):
        self._catalogue[book.isbn] = book

    def update_stock(self, isbn: str, delta: int):
        book = self.get_book(isbn)
        book.stock = max(0, book.stock + delta)
        self._notify(isbn, book.stock)

# Both calls return the same object
assert Inventory() is Inventory()  # True
⚠️ Pitfall
Singletons make unit testing harder (shared state between tests). Prefer dependency injection in production; use Singleton only when global consistency is truly required.
4

Observer

Problem: multiple services (email, dashboard, reorder) need to react when stock changes. Hard-coding each call in Inventory creates tight coupling.

Solution: Observer pattern. Inventory notifies all registered observers without knowing what they do:

Python
from abc import ABC, abstractmethod

class StockObserver(ABC):
    @abstractmethod
    def on_stock_change(self, isbn: str, new_qty: int) -> None: ...

class EmailAlertService(StockObserver):
    def on_stock_change(self, isbn, new_qty):
        if new_qty == 0:
            print(f"EMAIL: {isbn} OUT OF STOCK")
        elif new_qty < 5:
            print(f"EMAIL: Low stock alert — {isbn}: {new_qty} remaining")

class DashboardService(StockObserver):
    def on_stock_change(self, isbn, new_qty):
        print(f"DASHBOARD: {isbn} stock updated to {new_qty}")

# Observer wiring in Inventory
class Inventory:
    def subscribe(self, obs: StockObserver): self._observers.append(obs)
    def unsubscribe(self, obs: StockObserver): self._observers.remove(obs)
    def _notify(self, isbn, qty):
        for obs in self._observers: obs.on_stock_change(isbn, qty)

# Usage
inv = Inventory.get_instance()
inv.subscribe(EmailAlertService())
inv.subscribe(DashboardService())
inv.update_stock("978-1-11", -3)
# EMAIL: Low stock alert — 978-1-11: 2 remaining
# DASHBOARD: 978-1-11 stock updated to 2
diagram
Figure 2 — Observer: Inventory implements StockSubject and notifies all StockObserver implementations. Strategy: PricingStrategy is implemented by StandardPricing and SeasonalPricing.
5

Strategy

Problem: pricing logic varies by context (standard, seasonal, member). Hard-coding if/elif chains in Order is fragile and violates OCP.

Solution: Strategy pattern. Each algorithm is a separate class; swap at runtime:

Python
from abc import ABC, abstractmethod

class PricingStrategy(ABC):
    @abstractmethod
    def calculate(self, base_price: float) -> float: ...

class StandardPricing(PricingStrategy):
    def calculate(self, base): return base

class SeasonalPricing(PricingStrategy):
    def __init__(self, factor): self._factor = factor  # e.g. 0.8 = 20% off
    def calculate(self, base): return round(base * self._factor, 2)

class MemberPricing(PricingStrategy):
    def __init__(self, discount): self._d = discount
    def calculate(self, base): return round(base * (1 - self._d), 2)

class Order:
    def __init__(self, oid, customer, strategy: PricingStrategy = None):
        self._strategy = strategy or StandardPricing()

    def total(self) -> float:
        raw = sum(i.subtotal() for i in self._items)
        return round(self._strategy.calculate(raw), 2)

    def set_pricing(self, s: PricingStrategy): self._strategy = s

# Runtime strategy swap
order = Order("O-001", customer)
print(order.total())                          # £29.99 — standard
order.set_pricing(SeasonalPricing(0.8))
print(order.total())                          # £23.99 — 20% off
💡 STRATEGY vs INHERITANCE
You could subclass Order to get DiscountedOrder, MemberOrder, etc. — but each new pricing rule adds a class. Strategy keeps the class count stable: one Order class, many interchangeable strategies. This is OCP applied to algorithms.
6

SysML v2 Bridge

PatternSysML v2 expression
Factory Methodaction def create in a factory part def; returns typed Book
Singletonpart def Inventory with multiplicity [1]; <<singleton>> stereotype
Observerinterface def StockObserver; Inventory sends flow to all observers
Strategyinterface def PricingStrategy; Order has ref strategy : PricingStrategy
Decoratorpart def Wrapper :> Component with ref wrappee : Component
Factory Method

Centralises creation. Callers depend on abstract type, not concrete class.

Singleton

One instance. Consistent global state. But: test isolation is harder.

Observer

Event-driven. Subject does not know observers. Add/remove at runtime.

Strategy

Replaces if/elif with interchangeable algorithm objects. OCP for algorithms.

Decorator

Adds behaviour by wrapping, without modifying or subclassing.

Patterns vocabulary

Named solutions provide shared design language across the team.

📚
Next — Module 8: OOD in Practice

SOLID principles applied to the complete domain, refactoring anti-patterns, testing strategies, and the full integrated model.