Skip to main content

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.login("[email protected]", "hunter2")
server.sendmail("[email protected]", f"{user_id}@company.com", message)
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.

CategoryProblem It SolvesExamples
CreationalHow objects are createdSingleton, Factory Method, Abstract Factory, Builder, Prototype
StructuralHow objects are composedAdapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy
BehavioralHow objects communicateChain 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.login("[email protected]", "hunter2") # hardcoded credentials
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:

  1. 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.

  2. 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.

  3. Python idioms sometimes replace patterns. A context manager (with statement) 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.

  4. 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 ProblemReach For
Creating objects whose type is determined at runtimeFactory Method
Families of related objects that must be consistentAbstract Factory
Complex objects with many optional parametersBuilder
Exactly one instance of a resource managerSingleton
Copying expensive-to-create objectsPrototype
Using a class with an incompatible interfaceAdapter
Adding behavior to an object without subclassingDecorator
Simplifying a complex subsystemFacade
Controlling access to an expensive resourceProxy
Swappable algorithms in a contextStrategy
Notifying many objects when state changesObserver
Encapsulating a request as an objectCommand
An ordered pipeline where each step may abortChain of Responsibility
Skeleton algorithm with customizable stepsTemplate Method
Objects with complex state machinesState

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.

PrincipleOne-Line SummaryPatterns That Embody It
Single ResponsibilityOne class, one reason to changeAll patterns (separation of concerns is the goal)
Open/ClosedOpen for extension, closed for modificationStrategy, Observer, Decorator, Factory
Liskov SubstitutionSubtypes must be substitutable for their base typesTemplate Method, Strategy (correct use of inheritance)
Interface SegregationClients should not depend on interfaces they do not useAdapter, Facade
Dependency InversionDepend on abstractions, not concretionsFactory, 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:

  1. Surprising code snippet - something that reveals a problem or unexpected behavior
  2. Problem statement - what goes wrong and why
  3. Pattern solution - the named pattern, fully implemented in Python
  4. Real-world use case - a production-grade example with complete, runnable code
  5. Pythonic alternative - where Python idioms replace or simplify the classical pattern
  6. 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.

© 2026 EngineersOfAI. All rights reserved.