SOLID principles applied to the complete domain, refactoring anti-patterns, testing strategies, and the full integrated model.
Module 7: Design Patterns
Factory Method, Singleton, Observer, Strategy — proven GoF solutions applied to the bookshop with SysML v2 mappings.
What Are Design Patterns?
Design patterns are reusable, named solutions to recurring design problems. Catalogued by the "Gang of Four" (1994) into three categories:
| Category | Intent | Our examples |
|---|---|---|
| Creational | How objects are created | Factory Method, Singleton |
| Structural | How objects are composed | Decorator |
| Behavioural | How objects interact | Observer, Strategy |
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:
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 itget_instance().Singleton
Problem: multiple Inventory instances cause inconsistent stock. Solution: ensure at most one instance exists, thread-safely:
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() # TrueObserver
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:
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 2Strategy
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:
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% offSysML v2 Bridge
| Pattern | SysML v2 expression |
|---|---|
| Factory Method | action def create in a factory part def; returns typed Book |
| Singleton | part def Inventory with multiplicity [1]; <<singleton>> stereotype |
| Observer | interface def StockObserver; Inventory sends flow to all observers |
| Strategy | interface def PricingStrategy; Order has ref strategy : PricingStrategy |
| Decorator | part def Wrapper :> Component with ref wrappee : Component |
Centralises creation. Callers depend on abstract type, not concrete class.
One instance. Consistent global state. But: test isolation is harder.
Event-driven. Subject does not know observers. Add/remove at runtime.
Replaces if/elif with interchangeable algorithm objects. OCP for algorithms.
Adds behaviour by wrapping, without modifying or subclassing.
Named solutions provide shared design language across the team.