Python Advance 📂 Important concepts · 6 of 6 49 min read

Python Design Patterns — Singleton, Factory & Observer Tutorial

A practical Python design patterns tutorial covering Singleton, Factory, and Observer with visual anatomy diagrams, runnable code, and real-world examples. Learn four ways to build a Singleton, three flavors of Factory, thread-safety pitfalls, event-bus observers, testing strategies, and how to combine all three patterns in a real notification system.

Section 01

The Story That Explains Design Patterns

The Master Carpenter's Notebook
Imagine a master carpenter with 40 years of experience. He doesn't reinvent joinery for every chair he builds. He carries a small notebook of proven joints — dovetail, mortise and tenon, biscuit, tongue and groove — each with a name, a use-case, and a diagram. When an apprentice asks "how should I join these two boards?", he doesn't lecture on wood physics. He says "use a mortise and tenon here." Two words. Instant understanding.

Design patterns are that notebook for programmers. They are named, battle-tested solutions to problems that come up again and again in software. Once your team knows the names, an entire design decision fits in a sentence: "let's make this a Singleton", "use a Factory here", "wire it up with the Observer pattern".

A design pattern is not a library, a class, or code you can copy. It is a template — a way of arranging objects and responsibilities that solves a recurring problem. The pattern's real value is a shared vocabulary between developers and a proven structure that avoids common mistakes.

💡
The Core Insight

Patterns don't make bad code good. They make experienced-developer intuition teachable. When you name a pattern, you compress a long design discussion into two words the whole team already understands. This is why "Gang of Four" patterns from 1994 are still on every senior engineer's bookshelf today.


Section 02

The Three Families of Patterns

Every classical design pattern falls into one of three categories, based on what it helps you organise: object creation, object composition, or object communication.

🛠️
Creational
How objects are made
Control where and how new objects are instantiated. Hides construction logic so the rest of the code doesn't need to know the concrete class. Includes: Singleton, Factory, Builder, Prototype.
🧱
Structural
How objects are composed
Concerned with combining objects and classes into larger structures while keeping them flexible. Adapts, wraps, or decorates existing objects. Includes: Adapter, Decorator, Facade, Proxy.
🔄
Behavioral
How objects communicate
Define the flow of messages and responsibilities between objects. Loose coupling so senders don't hard-depend on receivers. Includes: Observer, Strategy, Command, State.

In this tutorial we cover three of the most common Python patterns — one from each family (with a heavy focus on Creational and Behavioural since those matter most in Python): Singleton (creational), Factory (creational), and Observer (behavioral).


Section 03

The Singleton Pattern — One Instance to Rule Them All

The Country's Central Bank
A country has exactly one central bank. It doesn't matter which citizen, minister, or commercial bank asks — every request lands at the same institution, with the same monetary policy, the same reserves, and the same authority.

If two central banks existed with different interest rates, the economy would collapse into contradiction. The uniqueness itself is a guarantee.

That's a Singleton. A class that ensures at most one instance exists, and every caller in the entire program gets the exact same object back.

A Singleton restricts a class so it can be instantiated only once. All subsequent constructor calls return the existing instance. Used when a resource is naturally global: application configuration, a connection pool, a logger, a cache, an event bus.

⚠️
Use It Sparingly — It's a Global in Disguise

A Singleton is a global variable dressed up as a class. Global state makes testing harder (tests share state), hides dependencies, and creates hidden coupling. Reach for a Singleton only when uniqueness is a domain rule — not just for convenience. In doubt, pass the object as a normal argument.


Section 04

Four Ways to Implement Singleton in Python

Python is unusually flexible here. The classical Java-style implementation is one of at least four idiomatic approaches. Each has trade-offs.

🔮 Singleton Implementation Choices
Way 1
Module-level singleton — the Pythonic way. Modules are imported once and cached by Python itself.
Way 2
Override __new__ — classical OOP, works with inheritance, no metaclass magic.
Way 3
Decorator — clean and reusable across classes; wraps the class in a factory.
Way 4
Metaclass — most robust for library code and subclass-friendly, but heavier syntax.

Way 1 — Module-Level Singleton (Pythonic Default)

# file: config.py — this file IS the singleton
import os

class _Config:
    def __init__(self):
        self.env      = os.getenv("APP_ENV", "dev")
        self.db_url   = os.getenv("DB_URL", "sqlite:///./app.db")
        self.debug    = self.env == "dev"

# A single instance, created at import time
config = _Config()

# ── Anywhere else in the app ──────────────────────────
from config import config
print(config.db_url)   # same object every time, everywhere

Way 2 — Override __new__

class Logger:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, name="app"):
        # Careful — __init__ runs on EVERY call, not just the first!
        if not hasattr(self, "_ready"):
            self.name  = name
            self.lines = []
            self._ready = True

    def log(self, msg): self.lines.append(msg)

a = Logger("first")
b = Logger("second")
print(a is b)      # True
print(a.name)      # "first" — __init__ was guarded

Way 3 — Decorator-Based Singleton

from functools import wraps

def singleton(cls):
    instances = {}

    @wraps(cls)
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Cache:
    def __init__(self):
        self.store = {}

    def set(self, k, v): self.store[k] = v
    def get(self, k):    return self.store.get(k)

c1 = Cache(); c1.set("user:1", "Alice")
c2 = Cache()
print(c2.get("user:1"))   # "Alice" — same store
print(c1 is c2)             # True

Way 4 — Metaclass-Based Singleton

class SingletonMeta(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class DBPool(metaclass=SingletonMeta):
    def __init__(self, size=10):
        self.size        = size
        self.connections = [f"conn-{i}" for i in range(size)]

p1 = DBPool(size=20)
p2 = DBPool(size=99)  # args ignored — instance already exists
print(p1 is p2, p2.size)   # True 20
ApproachBoilerplateThread-SafeSubclass-FriendlyBest When
Module-levelZeroYes (import lock)N/AConfig, constants, app-wide state
__new__LowNo — add LockYesSmall apps, ad-hoc use
DecoratorLowNo — add LockNo — decorator hides classReusing across many classes
MetaclassHighNo — add LockYesLibrary code, framework-level
🌿
The Pythonic Default

Reach for a module-level singleton first. Python modules are cached by the import system — you get thread-safe, lazy, zero-boilerplate singletons for free. Only use the class-based approaches when you genuinely need multiple keyed instances or subclass polymorphism.


Section 05

Singleton — A Thread-Safe Real-World Example

In production, singletons often initialize expensive resources — a database pool, a redis client, a machine-learning model. If two threads both call the constructor at the same time during startup, a naive singleton creates two instances. The fix is a simple lock.

import threading
from typing import ClassVar

class DatabasePool:
    _instance: ClassVar["DatabasePool | None"] = None
    _lock:     ClassVar[threading.Lock]              = threading.Lock()

    def __new__(cls, *args, **kwargs):
        # Double-checked locking — fast path avoids lock after init
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, dsn="postgres://localhost/app", size=10):
        if getattr(self, "_ready", False):
            return
        self.dsn         = dsn
        self.size        = size
        self.connections = [f"conn-{i} @ {dsn}" for i in range(size)]
        self._ready     = True

    def acquire(self):  return self.connections.pop()
    def release(self, c): self.connections.append(c)


# ── Usage from multiple threads ────────────────────
def worker(i):
    pool = DatabasePool()
    conn = pool.acquire()
    print(f"[T{i}] uses {conn}")
    pool.release(conn)

threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads: t.start()
for t in threads: t.join()
OUTPUT
[T0] uses conn-9 @ postgres://localhost/app [T1] uses conn-8 @ postgres://localhost/app [T2] uses conn-7 @ postgres://localhost/app

A Single Shared Database Connection (SQLite)

The pool example above shares many connections. The most classic Singleton use-case is actually simpler — one process, one persistent database connection, reused by every module. This avoids the cost of opening a new TCP handshake or SQLite file handle for every query, and guarantees that transactions and settings (like SQLite's PRAGMA) apply globally.

import sqlite3
import threading
from typing import ClassVar

class Database:
    """Singleton wrapper around a single sqlite3.Connection."""

    _instance:   ClassVar["Database | None"] = None
    _lock:       ClassVar[threading.Lock]         = threading.Lock()

    def __new__(cls, db_path: str = "app.db"):
        # Double-checked locking — fast path avoids the lock after init
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, db_path: str = "app.db"):
        # Guard: __init__ runs on every call, but we only want it once
        if getattr(self, "_ready", False):
            return
        self.db_path     = db_path
        self._connection = sqlite3.connect(
            db_path,
            check_same_thread=False,     # share across threads
            isolation_level=None,        # autocommit; use BEGIN manually
        )
        self._connection.row_factory = sqlite3.Row
        self._connection.execute("PRAGMA journal_mode=WAL")
        self._connection.execute("PRAGMA foreign_keys=ON")
        self._ready = True
        print(f"[Database] new connection opened → {db_path}")

    @property
    def connection(self) -> sqlite3.Connection:
        return self._connection

    def execute(self, sql: str, params: tuple = ()):
        return self._connection.execute(sql, params)

    def query(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
        return self.execute(sql, params).fetchall()

    def close(self):
        self._connection.close()
        Database._instance = None          # allow re-init in tests


# ── Module A: creates the schema ──────────────────
db = Database("shop.db")
db.execute("""
    CREATE TABLE IF NOT EXISTS users (
        id       INTEGER PRIMARY KEY,
        email    TEXT UNIQUE NOT NULL,
        credits  INTEGER DEFAULT 0
    )
""")

# ── Module B (somewhere else): inserts users ──────
db_again = Database("IGNORED.db")     # path arg is ignored — same instance
db_again.execute("INSERT OR IGNORE INTO users(email, credits) VALUES (?, ?)",
                 ("alice@example.com", 100))
db_again.execute("INSERT OR IGNORE INTO users(email, credits) VALUES (?, ?)",
                 ("bob@example.com",   50))

# ── Module C (yet another place): reads users ─────
db_third = Database()
print(f"Same instance? {db is db_again is db_third}")

for row in db_third.query("SELECT email, credits FROM users ORDER BY id"):
    print(dict(row))
OUTPUT
[Database] new connection opened → shop.db Same instance? True {'email': 'alice@example.com', 'credits': 100} {'email': 'bob@example.com', 'credits': 50}
💡
Notice Three Things

First, only one "connection opened" line is printed — the second and third Database(...) calls returned the same object. Second, the db_path passed to later calls is silently ignored — a classic Singleton gotcha you must document. Third, the PRAGMA settings are applied once and every module inherits them automatically.

⚠️
When NOT to Use This Pattern

A single shared connection is fine for SQLite (which serialises writes internally) and for small CLI scripts. For PostgreSQL or MySQL in a web app, you want a connection pool instead (Section 05's DatabasePool pattern) — otherwise concurrent requests will queue on a single connection and throughput collapses. One-connection Singleton is a great fit for embedded databases, background workers, and Jupyter notebooks; a poor fit for high-QPS request handlers.


Section 06

The Factory Pattern — Objects on Demand

The Coffee Shop Barista
You walk into a coffee shop and say "one cappuccino, please." You don't tell the barista which espresso machine to use, which grinder setting, which milk-frothing technique, or which cup size. You give a name; they hand you the finished product.

Behind the counter is a menu — a lookup table from drink name to preparation recipe. Add a new drink? Update the menu. The customer's experience never changes.

That's the Factory pattern. Callers ask for a product by name or type; the factory decides which concrete class to instantiate and returns it. Adding new product types doesn't require every caller to be updated.

There are three related variants under the "Factory" umbrella, from simplest to most powerful:

🍩
Simple Factory
One factory function
A single function or class method reads a type argument and returns the right concrete object. Not a "true" GoF pattern but the most common Python variant.
📚
Factory Method
Subclass decides
A base class defines an abstract create() method. Subclasses override it to produce their specific variant. Used when the family of products aligns with a family of creators.
🏭
Abstract Factory
Families of products
A factory that produces multiple related products as a set (e.g. all matching UI widgets for a theme). Guarantees products are used together consistently.

Section 07

Simple Factory — Payment Gateway Example

Imagine an e-commerce app that supports Stripe, PayPal, and Razorpay. Each has its own SDK, but from the checkout code's point of view, "process a payment" is a single operation. A factory hides the differences.

from abc import ABC, abstractmethod

# ── Common interface ──────────────────────────────
class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount: float, currency: str) -> dict: ...

# ── Concrete implementations ──────────────────────
class StripeGateway(PaymentGateway):
    def charge(self, amount, currency):
        return {"gateway": "stripe",
                "charge_id": f"ch_{amount:.0f}",
                "status":    "succeeded"}

class PayPalGateway(PaymentGateway):
    def charge(self, amount, currency):
        return {"gateway": "paypal",
                "payment_id": f"PAY-{amount:.0f}",
                "state":      "approved"}

class RazorpayGateway(PaymentGateway):
    def charge(self, amount, currency):
        return {"gateway": "razorpay",
                "order_id":  f"order_{amount:.0f}",
                "captured":  True}

# ── The factory ───────────────────────────────────
class PaymentFactory:
    _registry = {
        "stripe":   StripeGateway,
        "paypal":   PayPalGateway,
        "razorpay": RazorpayGateway,
    }

    @classmethod
    def create(cls, name: str) -> PaymentGateway:
        name = name.lower().strip()
        if name not in cls._registry:
            raise ValueError(f"Unknown gateway: {name}")
        return cls._registry[name]()

    @classmethod
    def register(cls, name: str, gateway_cls: type[PaymentGateway]):
        # Extensibility: plug in new gateways at runtime
        cls._registry[name.lower()] = gateway_cls


# ── Client code — no if/elif chains ───────────────
for provider in ["stripe", "paypal", "razorpay"]:
    gw     = PaymentFactory.create(provider)
    result = gw.charge(1499.00, "INR")
    print(result)
OUTPUT
{'gateway': 'stripe', 'charge_id': 'ch_1499', 'status': 'succeeded'} {'gateway': 'paypal', 'payment_id': 'PAY-1499', 'state': 'approved'} {'gateway': 'razorpay', 'order_id': 'order_1499', 'captured': True}
🎯
Why This Beats a Big if/elif Chain

Without the factory, every checkout endpoint would have if provider == "stripe": ... repeated everywhere. Adding a new gateway would mean hunting down each branch. With the factory, the client code doesn't change — you just call PaymentFactory.register("adyen", AdyenGateway) once at startup.


Section 08

Factory Method — When Creators Come in Families

The Factory Method variant is used when the creator class hierarchy matches the product class hierarchy. Each creator subclass produces its own product.

from abc import ABC, abstractmethod

# ── Product hierarchy ─────────────────────────────
class Document(ABC):
    @abstractmethod
    def render(self) -> str: ...

class PDFDocument(Document):
    def render(self): return "%PDF-1.7 ... [binary]"

class HTMLDocument(Document):
    def render(self): return "<html>...</html>"

class MarkdownDocument(Document):
    def render(self): return "# Title\n\nParagraph"

# ── Creator hierarchy — one factory_method per subclass ─
class Report(ABC):
    @abstractmethod
    def factory_method(self) -> Document: ...

    def generate(self) -> str:
        doc = self.factory_method()
        return doc.render()

class MonthlyPDFReport(Report):
    def factory_method(self): return PDFDocument()

class MonthlyHTMLReport(Report):
    def factory_method(self): return HTMLDocument()

# ── Client stays generic ─────────────────────────
for report in [MonthlyPDFReport(), MonthlyHTMLReport()]:
    print(report.generate()[:30])

Section 09

Factory — Anatomy Diagram

01
Client Requests a Product by Name
Application code calls PaymentFactory.create("stripe"). The client only knows the abstract interface, not the concrete class. Zero if/elif chains in business logic.
02
Factory Consults Its Registry
A dictionary maps the requested name to a concrete class. Registration can happen at import time (static) or at runtime (plugin-style).
03
Instantiation of the Concrete Class
The factory calls the concrete constructor (with any configured args) and holds a reference to the fresh object.
04
Return as the Abstract Type
The instance is returned but the caller's static type is the abstract PaymentGateway. All uses go through the abstract interface — swappability is automatic.
05
Client Invokes Behaviour Polymorphically
Client calls gw.charge(...). Python dispatches to the concrete method via the MRO. Same call site — different behaviour per concrete class.

Section 10

The Observer Pattern — Broadcast Without Coupling

The Newspaper Subscription
A newspaper publisher doesn't know each subscriber personally. It doesn't call them every morning. It doesn't even know how many subscribers there are today. It just publishes the day's paper, and the distribution system delivers copies to everyone currently subscribed. Cancel your subscription tomorrow — the publisher's workflow doesn't change.

That's the Observer pattern. A subject (publisher) maintains a list of observers (subscribers). When its state changes, it broadcasts a notification to everyone in the list. Observers can subscribe or unsubscribe freely. The subject and observers stay independent.

Observer is used everywhere: GUI event handlers, spreadsheet cell dependencies, message buses, Redux/Vuex stores, Django signals, JavaScript's DOM events, RxJS streams. Anywhere you see the words "listener", "subscriber", "handler", or "event bus" — it's Observer under the hood.

🔄
Push vs Pull Style

In push mode, the subject sends new data along with the notification: observer.update(new_price). In pull mode, it just says "something changed" and observers ask the subject for details. Push is more efficient; pull is more decoupled. Most modern implementations combine both.


Section 11

Observer — Anatomy Diagram

01
Observers Subscribe to the Subject
Each observer calls subject.attach(self). The subject stores them in an internal list. Observers can also unsubscribe at any time.
02
Subject's State Changes
Something in the subject changes — a new stock price, a new order, a new sensor reading. The change happens through a normal setter or method.
03
Subject Calls notify()
The setter automatically invokes self.notify(), which loops over every attached observer and calls their update(...) method with the new value or event.
04
Each Observer Reacts Independently
One observer might log the change, another might email a user, a third might trigger a re-render. None of them know about each other. All they know is the subject.
05
Observers Unsubscribe When Done
Call subject.detach(observer) to stop receiving events. Critical for avoiding memory leaks in long-lived subjects (GUIs, servers).

Section 12

Observer — Practical Example: Stock Price Alerts

from abc import ABC, abstractmethod
from typing import Protocol

# ── Observer interface ────────────────────────────
class Observer(Protocol):
    def update(self, symbol: str, price: float) -> None: ...

# ── Subject ───────────────────────────────────────
class StockTicker:
    def __init__(self, symbol: str):
        self.symbol             = symbol
        self._price            = 0.0
        self._observers: list[Observer] = []

    def attach(self, obs: Observer):
        self._observers.append(obs)

    def detach(self, obs: Observer):
        self._observers.remove(obs)

    def notify(self):
        for obs in self._observers:
            obs.update(self.symbol, self._price)

    @property
    def price(self): return self._price

    @price.setter
    def price(self, value: float):
        self._price = value
        self.notify()           # auto-broadcast on every change

# ── Concrete observers ────────────────────────────
class ConsoleAlert:
    def update(self, symbol, price):
        print(f"[CONSOLE] {symbol} = ${price:.2f}")

class EmailAlert:
    def __init__(self, email, threshold):
        self.email     = email
        self.threshold = threshold

    def update(self, symbol, price):
        if price >= self.threshold:
            print(f"[EMAIL to {self.email}] {symbol} hit ${price:.2f}")

class TradingBot:
    def __init__(self, buy_below):
        self.buy_below = buy_below

    def update(self, symbol, price):
        if price <= self.buy_below:
            print(f"[BOT] BUY 100 x {symbol} @ ${price:.2f}")

# ── Wiring it up ──────────────────────────────────
aapl = StockTicker("AAPL")
aapl.attach(ConsoleAlert())
aapl.attach(EmailAlert("me@example.com", threshold=200))
aapl.attach(TradingBot(buy_below=180))

for new_price in [175.50, 198.20, 203.10]:
    print(f"\n→ Setting price to ${new_price}")
    aapl.price = new_price
OUTPUT
→ Setting price to $175.5 [CONSOLE] AAPL = $175.50 [BOT] BUY 100 x AAPL @ $175.50 → Setting price to $198.2 [CONSOLE] AAPL = $198.20 → Setting price to $203.1 [CONSOLE] AAPL = $203.10 [EMAIL to me@example.com] AAPL hit $203.10
🎯
Notice the Zero Coupling

StockTicker knows nothing about consoles, emails, or trading bots. It only knows the Observer protocol — anything with an update(symbol, price) method. Add an SMS observer tomorrow without touching StockTicker. This is what "loose coupling" really looks like.


Section 13

Observer — The Pythonic Callback Version

In Python you can skip the classes entirely. Since functions are first-class objects, the simplest observer implementation is a list of callbacks. This is exactly what Django signals, blinker, and pyee do under the hood.

from collections import defaultdict
from typing import Callable, Any

class EventBus:
    def __init__(self):
        self._handlers: dict[str, list[Callable]] = defaultdict(list)

    def on(self, event: str):
        # Use as @bus.on("user.created")
        def decorator(fn: Callable):
            self._handlers[event].append(fn)
            return fn
        return decorator

    def emit(self, event: str, **payload: Any):
        for handler in self._handlers[event]:
            handler(**payload)

# ── Global bus (Singleton-ish, module-level) ─────
bus = EventBus()

# ── Register handlers ────────────────────────────
@bus.on("user.created")
def send_welcome_email(user_id, email, **_):
    print(f"→ welcome email to {email} (user {user_id})")

@bus.on("user.created")
def add_to_crm(user_id, email, **_):
    print(f"→ CRM record created for {email}")

@bus.on("user.created")
def log_analytics(user_id, **_):
    print(f"→ analytics: signup event u_{user_id}")

# ── Emit ──────────────────────────────────────────
bus.emit("user.created", user_id=42, email="alice@example.com")
OUTPUT
→ welcome email to alice@example.com (user 42) → CRM record created for alice@example.com → analytics: signup event u_42

Section 14

Singleton vs Factory vs Observer — Side by Side

PropertySingletonFactoryObserver
CategoryCreationalCreationalBehavioral
Problem it solvesEnsure one instanceHide instantiation logicLoose one-to-many notification
Number of instancesExactly 1Many1 subject, N observers
CouplingTight (global)Loose (abstract type)Loose (interface)
Runtime extensionNoYes (register new types)Yes (attach/detach)
TestabilityHarder — shared stateEasy — inject a fakeEasy — attach a spy
Real-world useLogger, config, DB poolPayment gateway, parserEvents, GUI, pub/sub
Related Python idiomimport-cached moduleRegistry dictCallback list, asyncio.Event

Section 15

Patterns Combined — A Small Real System

Real code rarely uses one pattern in isolation. Here's a compact example where all three work together — a Singleton event bus, a Factory that creates notification channels, and Observers listening on the bus.

from abc import ABC, abstractmethod
from collections import defaultdict

# ── SINGLETON: the app-wide event bus ─────────────
class EventBusMeta(type):
    _instances = {}
    def __call__(cls, *a, **kw):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*a, **kw)
        return cls._instances[cls]

class EventBus(metaclass=EventBusMeta):
    def __init__(self):
        self._handlers = defaultdict(list)
    def subscribe(self, event, handler):
        self._handlers[event].append(handler)
    def publish(self, event, **data):
        for h in self._handlers[event]:
            h(**data)

# ── FACTORY: notification channel builder ─────────
class Notifier(ABC):
    @abstractmethod
    def send(self, message: str): ...

class EmailNotifier(Notifier):
    def send(self, message): print(f"[EMAIL] {message}")

class SMSNotifier(Notifier):
    def send(self, message): print(f"[SMS]   {message}")

class SlackNotifier(Notifier):
    def send(self, message): print(f"[SLACK] {message}")

class NotifierFactory:
    _map = {"email": EmailNotifier, "sms": SMSNotifier, "slack": SlackNotifier}
    @classmethod
    def make(cls, channel): return cls._map[channel]()

# ── OBSERVER: subscribers on the bus ──────────────
bus = EventBus()

for channel in ("email", "sms", "slack"):
    notifier = NotifierFactory.make(channel)
    bus.subscribe("order.paid",
                  lambda order_id, amount, n=notifier:
                      n.send(f"Order {order_id} paid: ${amount}"))

# ── Somewhere else in the code ───────────────────
EventBus().publish("order.paid", order_id="ORD-001", amount=149.99)
OUTPUT
[EMAIL] Order ORD-001 paid: $149.99 [SMS] Order ORD-001 paid: $149.99 [SLACK] Order ORD-001 paid: $149.99

Section 16

When to Use Each Pattern

Singleton — Truly Global Resources
App configuration, logging, connection pools, cross-cutting caches. Anything where two instances would cause contradictions or waste.
config, logger, db pool
Factory — Runtime Type Selection
When the concrete class depends on config, environment, or user input — payment gateways, storage backends, file parsers, ML model versions.
plug-in architectures
Observer — Event-Driven Reactions
When one state change should trigger many independent side-effects — GUI updates, domain events, cache invalidation, audit logs.
event buses, signals, GUI
Singleton — Just to Avoid Passing Arguments
If you only reach for a Singleton because passing objects around feels tedious, refactor toward dependency injection. Global state grows monsters.
hidden-coupling code smell
Factory — For Only One Concrete Class
If there is exactly one implementation and you don't plan to add more, a factory is over-engineering. Just call the constructor directly.
YAGNI — you aren't gonna need it
Observer — For Sequential Workflows
If step B must happen after step A and step C after B in a strict order, use a direct function call chain — not fire-and-forget events. Observers are for independent reactions, not workflows.
use a pipeline instead

Section 17

Testing Code That Uses These Patterns

❌ Hard-Coded Singleton — Hard to Test
ApproachLogger().log(...)
Test isolationShared state leaks
MockingMonkey-patch class
Reset between testsManual + fragile
Parallel testsRace conditions
✅ Factory + Injection — Easy to Test
Approachservice(logger=fake)
Test isolationFresh fake per test
MockingPass a stub directly
Reset between testsAutomatic — new object
Parallel testsSafe
# ── Testable observer with a spy ─────────────────
class SpyObserver:
    def __init__(self): self.received = []
    def update(self, symbol, price):
        self.received.append((symbol, price))

def test_ticker_notifies_all_observers():
    ticker = StockTicker("MSFT")
    spy1, spy2 = SpyObserver(), SpyObserver()
    ticker.attach(spy1)
    ticker.attach(spy2)

    ticker.price = 420.50

    assert spy1.received == [("MSFT", 420.50)]
    assert spy2.received == [("MSFT", 420.50)]

def test_factory_creates_correct_gateway():
    assert isinstance(PaymentFactory.create("stripe"), StripeGateway)
    assert isinstance(PaymentFactory.create("paypal"), PayPalGateway)

Section 18

Golden Rules

🏆 Design Patterns — Non-Negotiable Rules
1
Reach for the simplest pattern first. A module-level object beats a Singleton class. A dictionary of functions beats a full Factory class. Only escalate when the simpler form actually breaks down.
2
Patterns are a vocabulary, not a checklist. Do not force patterns into every project just because you've read about them. Their value is naming structures that already needed to exist — not inventing structures nobody needed.
3
Singleton is a global. Treat every Singleton with the same suspicion you'd treat a mutable global variable. If you can achieve the same effect through dependency injection, prefer that. Global state kills testability.
4
Factory beats isinstance chains. Whenever you find yourself writing if isinstance(x, A): ... elif isinstance(x, B): ..., that's the smell of a missing factory or missing polymorphism.
5
Observers must unsubscribe. If observers can outlive the subject or vice versa, forgetting to detach() is a slow memory leak. Consider weakref.WeakSet for the observer list in long-lived subjects.
6
Never mutate the observer list during notify(). If an observer's update() attaches or detaches another observer, iterating the list breaks. Iterate a copy: for obs in list(self._observers): ....
7
Prefer duck typing over abstract base classes in Python. Use typing.Protocol instead of ABC when you just need "anything with a .update() method." Python's dynamic dispatch already gives you polymorphism — ABCs are optional documentation.
8
Combine patterns freely, but name the combination. An event bus is often "Singleton + Observer". A plugin system is often "Factory + Registry + Observer". Naming the combination in comments keeps the code honest for the next reader.
You have completed Important concepts. View all sections →