Module 01 - Design Patterns in Python
Before reading any further, predict what happens when this service goes into production:
# notification_service.py - a real codebase you will encounter
import smtplib
import requests
def send_notification(user_id, message, channel):
if channel == "email":
server = smtplib.SMTP("smtp.gmail.com", 587)
server.starttls()
server.quit()
elif channel == "sms":
requests.post(
"https://api.twilio.com/2010-04-01/Accounts/ACXXX/Messages.json",
auth=("ACXXX", "secret_token"),
data={"From": "+15551234567", "To": f"+1555{user_id}", "Body": message},
)
elif channel == "slack":
requests.post(
"https://hooks.slack.com/services/XXX/YYY/ZZZ",
json={"text": f"<@{user_id}> {message}"},
)
else:
raise ValueError(f"Unknown channel: {channel}")
# Six months later, product adds push notifications and WhatsApp.
# A junior engineer adds two more elif branches.
# There are now 14 places in the codebase that call send_notification().
# A bug in the SMS branch breaks the entire function for all channels.
# Tests take 40 seconds because every test hits real external APIs.
# The on-call engineer cannot mock any of it.
Most engineers recognize this as "bad code" but cannot name what is specifically wrong or how to fix it systematically. After this module, you will be able to name every problem, cite the pattern that solves it, and implement the solution in idiomatic Python.
The correct answer to "what goes wrong" is: every single SOLID principle is violated simultaneously, the code has zero extension points, and it is completely untestable in isolation. Adding a new notification channel requires modifying a function that already works - the definition of fragility.
What You Will Learn
- What design patterns are, why they exist, and when not to use them
- The three GoF categories: Creational, Structural, and Behavioral - with Python examples for each
- How Python's dynamic type system, duck typing, and first-class functions change the implementation of classical patterns
- The five SOLID principles, with concrete before/after code for each violation
- Domain-Driven Design vocabulary: entities, value objects, aggregates, repositories, domain events
- Hexagonal Architecture (Ports and Adapters): how to structure a Python service so the domain never depends on infrastructure
- How to identify which pattern solves which class of problem
- How to refactor the notification service above into a production-grade, fully testable system
Prerequisites
- Comfortable with Python classes, inheritance, and
abc.ABC - Familiar with Python's data model (
__init__,__repr__,__eq__, dunder methods) - Have written at least one Python service with external dependencies (HTTP calls, database queries)
- Understand basic Python type hints
Why Design Patterns Matter in Python
Design patterns have a reputation problem in Python communities. The argument goes: "Python is not Java. We have first-class functions, duck typing, and decorators. Patterns like Singleton and Factory are Java workarounds for a rigid type system."
This is half-true and fully misleading.
The half that is true: Python's expressiveness means the implementation of many patterns is shorter and more elegant than the Java textbook version. A Factory Method that requires five abstract classes in Java can sometimes be replaced by a dictionary of callables in Python.
The half that is misleading: the problems that patterns solve are not Java problems. They are software engineering problems. Tight coupling, untestable code, brittle extension points, and violation of invariants are Python problems too. The pattern is a named, well-understood solution to a recurring problem. The name is the most valuable part - it lets a team of five engineers communicate the design of a system in thirty seconds instead of thirty minutes.
When an engineer says "use a Strategy pattern for the pricing engine," every team member who knows patterns immediately understands:
- There is a family of algorithms (pricing strategies)
- They are interchangeable at runtime
- The context (order processor) does not know which algorithm it runs
- Adding a new strategy does not touch the context
That is six lines of design communication compressed into five words.
The Gang of Four: Three Categories of Patterns
The 1994 book Design Patterns: Elements of Reusable Object-Oriented Software by Gamma, Helm, Johnson, and Vlissides (the "Gang of Four") catalogued 23 patterns across three categories. Every pattern is a solution to a specific class of problem.
| Category | Problem It Solves | Examples |
|---|---|---|
| Creational | How objects are created | Singleton, Factory Method, Abstract Factory, Builder, Prototype |
| Structural | How objects are composed | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy |
| Behavioral | How objects communicate | Chain of Responsibility, Command, Iterator, Mediator, Observer, State, Strategy, Template Method, Visitor |
The categories map to three fundamental engineering concerns:
Creational patterns decouple the creation of an object from its use. If the code that creates a database connection is the same code that runs queries, you cannot test queries without a real database. Creational patterns solve this by putting creation behind an interface.
Structural patterns manage composition. Real systems combine objects in complex ways. An HTTP request goes through authentication middleware, rate limiting, caching, and logging before reaching the handler. Structural patterns give you clean, composable ways to build these pipelines.
Behavioral patterns manage communication between objects. Who calls whom? Who knows about whom? How do you decouple the sender of a request from the handler? Behavioral patterns answer these questions.
Python Changes How Patterns Look
Classical design patterns were described in terms of C++ and Smalltalk. Python's features compress many patterns and make others unnecessary. Here is what changes:
First-Class Functions Replace Many Structural Patterns
In Java, a Strategy pattern requires an interface, at least two concrete classes, and injection into a context class. In Python:
# Python Strategy - often just a callable
def process_order(order, pricing_strategy):
price = pricing_strategy(order) # strategy is just a function
return {"order": order, "price": price}
def standard_pricing(order):
return order["quantity"] * order["unit_price"]
def bulk_discount_pricing(order):
base = order["quantity"] * order["unit_price"]
return base * 0.85 if order["quantity"] > 100 else base
# Use it
result = process_order(my_order, bulk_discount_pricing)
No abstract class. No interface. The callable IS the strategy. This is not avoiding the pattern - the pattern IS there. You just see only its essence.
Duck Typing Eliminates Explicit Interfaces in Many Cases
# Java needs: implements NotificationChannel
# Python needs: just have a send() method
class EmailChannel:
def send(self, user_id: str, message: str) -> None:
...
class SMSChannel:
def send(self, user_id: str, message: str) -> None:
...
class SlackChannel:
def send(self, user_id: str, message: str) -> None:
...
def notify(channel, user_id: str, message: str) -> None:
channel.send(user_id, message) # works for all three
Python does not require channel to be declared as implementing an interface. As long as it has send(), it works. This is duck typing. In production Python, we do use ABC and Protocol to make the contract explicit - but it is for documentation and type-checker enforcement, not runtime necessity.
Decorators Are Built-in Structural Composition
The Decorator pattern - wrapping an object to add behavior without subclassing - is so natural in Python that the language has special syntax for it:
import functools
import time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timed
def load_model(path: str):
... # expensive operation
This is the Decorator pattern. The @timed decorator wraps load_model with timing behavior without modifying load_model. You can stack decorators to compose behavior.
Modules as Singletons
Python modules are imported once and cached in sys.modules. This means a module-level object is a natural singleton:
# config.py
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
db_url: str = os.environ["DATABASE_URL"]
debug: bool = os.environ.get("DEBUG", "false").lower() == "true"
# This is created once when the module is first imported
config = Config()
Any code that does from config import config gets the same object. No metaclass. No __new__ override. This is the Pythonic Singleton.
The "Before" System: A Notification Disaster
Let us examine the notification service from the opening in full detail, naming every specific problem.
# BEFORE: Everything wrong in one function
def send_notification(user_id, message, channel):
# Problem 1 (SRP violation): This function has THREE responsibilities:
# - deciding which channel to use
# - constructing the channel-specific payload
# - actually sending the message
# Problem 2 (OCP violation): Adding a new channel requires modifying
# this function, which is already tested and working.
# Problem 3 (DIP violation): High-level policy (notify user) directly
# depends on low-level details (SMTP server address, Twilio API).
# Problem 4 (testability): Cannot test without real SMTP server,
# real Twilio account, real Slack webhook.
# Problem 5 (error isolation): A bug in the email branch raises
# an exception that prevents SMS from working in the same request.
if channel == "email":
server = smtplib.SMTP("smtp.gmail.com", 587) # hardcoded server
server.starttls()
server.sendmail(
f"{user_id}@company.com",
message
)
server.quit()
elif channel == "sms":
requests.post(
"https://api.twilio.com/...", # hardcoded URL
auth=("ACXXX", "secret_token"), # hardcoded secrets
data={"From": "+15551234567", "To": f"+1555{user_id}", "Body": message},
)
elif channel == "slack":
requests.post(
"https://hooks.slack.com/...",
json={"text": f"<@{user_id}> {message}"},
)
else:
raise ValueError(f"Unknown channel: {channel}")
Now the "after" - the same system refactored with patterns:
# AFTER: Clean, extensible, testable
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Protocol
# --- Domain layer (knows nothing about SMTP, Twilio, etc.) ---
@dataclass(frozen=True)
class Notification:
user_id: str
message: str
channel: str
class NotificationChannel(Protocol):
"""Port (interface) - infrastructure implements this."""
def send(self, notification: Notification) -> None:
...
# --- Infrastructure layer (implements the port) ---
class EmailChannel:
def __init__(self, smtp_host: str, smtp_port: int, credentials: tuple):
self._host = smtp_host
self._port = smtp_port
self._credentials = credentials
def send(self, notification: Notification) -> None:
# SMTP logic here - isolated, testable, swappable
...
class SMSChannel:
def __init__(self, account_sid: str, auth_token: str, from_number: str):
self._sid = account_sid
self._token = auth_token
self._from = from_number
def send(self, notification: Notification) -> None:
# Twilio logic here - isolated, testable, swappable
...
class SlackChannel:
def __init__(self, webhook_url: str):
self._webhook = webhook_url
def send(self, notification: Notification) -> None:
# Slack logic here - isolated, testable, swappable
...
# --- Factory (Creational pattern - decouples creation from use) ---
class NotificationChannelFactory:
_registry: dict[str, type] = {}
@classmethod
def register(cls, name: str, channel_class: type) -> None:
cls._registry[name] = channel_class
@classmethod
def create(cls, channel_name: str, **kwargs) -> NotificationChannel:
if channel_name not in cls._registry:
raise ValueError(f"Unknown channel: {channel_name}")
return cls._registry[channel_name](**kwargs)
NotificationChannelFactory.register("email", EmailChannel)
NotificationChannelFactory.register("sms", SMSChannel)
NotificationChannelFactory.register("slack", SlackChannel)
# --- Application service (orchestrates, knows nothing about infrastructure) ---
class NotificationService:
def __init__(self, channels: dict[str, NotificationChannel]):
self._channels = channels
def notify(self, user_id: str, message: str, channel: str) -> None:
if channel not in self._channels:
raise ValueError(f"Channel not configured: {channel}")
notification = Notification(user_id=user_id, message=message, channel=channel)
self._channels[channel].send(notification)
Now test it without any real infrastructure:
# test_notification_service.py - no SMTP, no Twilio, no Slack
from unittest.mock import MagicMock
def test_notify_sends_to_correct_channel():
email_channel = MagicMock()
sms_channel = MagicMock()
service = NotificationService(
channels={"email": email_channel, "sms": sms_channel}
)
service.notify(user_id="u123", message="Hello", channel="email")
email_channel.send.assert_called_once()
sms_channel.send.assert_not_called()
The test runs in milliseconds. Adding WhatsApp is one new class and one register() call. Nothing else changes.
Module Structure
This module walks you through every tool used in that refactoring, in order of dependency:
Module 1 - Design Patterns in Python
│
├── 00-Overview.md ← You are here
│ Purpose: Frame the problem. Motivate the module.
│ Before/after: notification service.
│
├── 01-Creational-Patterns.md
│ Singleton, Factory Method, Abstract Factory, Builder, Prototype.
│ Learn how objects get created without coupling creation to use.
│
├── 02-Structural-Patterns.md
│ Adapter, Decorator, Facade, Proxy, Composite.
│ Learn how objects are composed without subclassing explosions.
│
├── 03-Behavioral-Patterns.md
│ Strategy, Observer, Command, Template Method, Chain of Responsibility.
│ Learn how objects communicate without tight coupling.
│
├── 04-SOLID-Principles.md
│ SRP, OCP, LSP, ISP, DIP - each with before/after Python code.
│ The vocabulary for critiquing and improving any design.
│
├── 05-Domain-Driven-Design.md
│ Entities, Value Objects, Aggregates, Repositories, Domain Events.
│ How to model business logic as code rather than database tables.
│
└── 06-Hexagonal-Architecture.md
Ports, Adapters, Application Core, Dependency Inversion.
The architecture that makes all other patterns compose cleanly.
A Pattern is a Vocabulary, Not a Template
The most important thing to understand about design patterns is that they are descriptions of solutions, not blueprints. The GoF book did not invent Singleton or Observer. It named solutions that experienced engineers were already using independently. The names became shared vocabulary.
This means:
-
You do not implement "the pattern." You solve a problem, and the solution may resemble a known pattern. Recognizing that resemblance lets you communicate it efficiently.
-
Patterns have trade-offs. Singleton makes global state testable via dependency injection. It also introduces global state, which is a liability. Every pattern is a tool that costs something.
-
Python idioms sometimes replace patterns. A context manager (
withstatement) IS the Template Method pattern for setup/teardown. A generator IS the Iterator pattern. Recognizing this means you reach for the idiomatic Python solution, not the Java-ported class hierarchy. -
Over-engineering is as bad as under-engineering. A two-file script does not need Hexagonal Architecture. A multi-team service with five external dependencies probably does.
The table below maps common problems to the pattern that solves them:
| You Have This Problem | Reach For |
|---|---|
| Creating objects whose type is determined at runtime | Factory Method |
| Families of related objects that must be consistent | Abstract Factory |
| Complex objects with many optional parameters | Builder |
| Exactly one instance of a resource manager | Singleton |
| Copying expensive-to-create objects | Prototype |
| Using a class with an incompatible interface | Adapter |
| Adding behavior to an object without subclassing | Decorator |
| Simplifying a complex subsystem | Facade |
| Controlling access to an expensive resource | Proxy |
| Swappable algorithms in a context | Strategy |
| Notifying many objects when state changes | Observer |
| Encapsulating a request as an object | Command |
| An ordered pipeline where each step may abort | Chain of Responsibility |
| Skeleton algorithm with customizable steps | Template Method |
| Objects with complex state machines | State |
SOLID: The Principles Behind the Patterns
Design patterns solve specific problems. SOLID principles govern how you structure any code, whether you use patterns or not. The patterns are implementations of SOLID thinking.
| Principle | One-Line Summary | Patterns That Embody It |
|---|---|---|
| Single Responsibility | One class, one reason to change | All patterns (separation of concerns is the goal) |
| Open/Closed | Open for extension, closed for modification | Strategy, Observer, Decorator, Factory |
| Liskov Substitution | Subtypes must be substitutable for their base types | Template Method, Strategy (correct use of inheritance) |
| Interface Segregation | Clients should not depend on interfaces they do not use | Adapter, Facade |
| Dependency Inversion | Depend on abstractions, not concretions | Factory, Abstract Factory, all behavioral patterns |
Lesson 04 covers each principle in depth with Python code. The key insight here is that SOLID violations are symptoms; design patterns are treatments.
Domain-Driven Design: Modeling the Business
Domain-Driven Design (DDD), introduced by Eric Evans in 2003, addresses a different level of the problem. Where patterns solve code structure problems, DDD solves modeling problems: how do you translate business rules into code so that the code reflects the business, not the database schema?
The core idea: the code should speak the language of the domain experts. If the business says "an Order can only be confirmed if all line items are in stock," that sentence should be directly expressible in the code, not buried in a sequence of SQL queries.
DDD introduces:
- Entities: Objects with identity that persists through state changes (
Order,User,Course) - Value Objects: Immutable objects defined by their attributes (
Money,EmailAddress,CourseId) - Aggregates: A cluster of entities and value objects with a consistency boundary (
Order+OrderLines) - Repositories: Interfaces for retrieving and persisting aggregates (not ORMs - abstractions over them)
- Domain Events: Things that happened in the domain (
OrderConfirmed,UserEnrolled,PaymentFailed) - Bounded Contexts: The explicit boundary within which a domain model applies
Lesson 05 covers DDD in full. The preview here is that DDD and design patterns are complementary: DDD tells you what to model; patterns tell you how to structure the modeling code.
Hexagonal Architecture: Where Everything Connects
Hexagonal Architecture (also called Ports and Adapters) is the architecture that makes all of the above compose cleanly. Proposed by Alistair Cockburn in 2005, it says:
The application core must not depend on infrastructure. Infrastructure must depend on the application core.
┌─────────────────────────────────┐
│ Application Core │
│ │
HTTP API ───┤ Port Domain Logic Port ├─── Database
CLI ───┤ (input) ────────────── (output)├─── Email
Tests ───┤ Entities ├─── Twilio
│ Use Cases │
│ Services │
└─────────────────────────────────┘
Adapters implement ports
The "ports" are Python Protocol classes or ABC abstract classes - pure interfaces. The "adapters" are the concrete classes that implement those interfaces using real infrastructure (psycopg2, httpx, boto3).
The application core contains domain logic and calls ports. It never imports psycopg2. It never imports httpx. It never knows it is running inside a FastAPI server.
This means:
- You can test 100% of domain logic without any real infrastructure
- You can swap databases without touching domain code
- You can add a CLI interface to a service that already has an HTTP API - just write a new input adapter
Lesson 06 builds a complete hexagonal Python service from scratch.
How to Use This Module
Each lesson in this module follows the same structure:
- Surprising code snippet - something that reveals a problem or unexpected behavior
- Problem statement - what goes wrong and why
- Pattern solution - the named pattern, fully implemented in Python
- Real-world use case - a production-grade example with complete, runnable code
- Pythonic alternative - where Python idioms replace or simplify the classical pattern
- Trade-offs - what the pattern costs, when not to use it
You should run every code example. The fastest way to internalize a pattern is to modify it: break it intentionally, add a new case, inject a mock. Passive reading produces passive knowledge. Active experimentation produces engineering judgment.
The Notification Service, Complete
By the end of this module, you will be able to build this - the notification service, fully implemented with patterns, DDD, and hexagonal architecture. Here is the complete architecture diagram:
┌──────────────────────────────────────────────────────────────────┐
│ Application Core │
│ │
│ ┌─────────────────────┐ ┌───────────────────────────────┐ │
│ │ Domain Model │ │ Use Cases / Services │ │
│ │ │ │ │ │
│ │ Notification │ │ SendNotificationUseCase │ │
│ │ NotificationChannel │───▶│ - validate(notification) │ │
│ │ (Protocol/Port) │ │ - channel.send(notification) │ │
│ │ │ │ │ │
│ └─────────────────────┘ └───────────────────────────────┘ │
│ │ │
└──────────────────────────────────────────┼─────────────────────── ┘
│ depends on ports (up)
┌──────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ Infrastructure / Adapters │
│ │
│ EmailAdapter SMSAdapter SlackAdapter │
│ (implements port) (implements port) (implements port) │
│ │
│ Uses: smtplib Uses: httpx/Twilio Uses: httpx │
└──────────────────────────────────────────────────────────────────┘
▲
│ wired at startup
┌──────────────────────────────────────────────────────────────────┐
│ Composition Root │
│ │
│ main.py / FastAPI lifespan │
│ - Creates adapters with real config │
│ - Creates use case with adapters injected │
│ - Registers routes │
│ │
│ conftest.py (tests) │
│ - Creates use case with mock adapters injected │
└──────────────────────────────────────────────────────────────────┘
Summary
Design patterns are named solutions to recurring software engineering problems. They are more valuable as vocabulary than as templates. Python's features - first-class functions, duck typing, decorators, modules-as-singletons - change how patterns are implemented, but not why they exist.
The problems they solve - tight coupling, untestable code, fragile extension points, unclear responsibilities - appear in every language, including Python.
This module teaches you to:
- Identify the class of problem you are facing
- Name the pattern that addresses it
- Implement it idiomatically in Python
- Know when the Pythonic idiom replaces the full pattern
- Compose patterns with SOLID principles and DDD to build systems that are a pleasure to work on at scale
Move on to Lesson 01: Creational Patterns.
