← Python Mastery — Senior to Principal

Design Patterns & Architecture

Design patterns are solutions to recurring problems, not rules carved in stone. Most GoF patterns were written for statically-typed, class-heavy languages. Python has first-class functions, duck typing, and protocols — so the Pythonic version of many patterns is often just “use a function.” Know the intent, not just the implementation.


SOLID Principles — Python Edition

SOLID is a set of five principles for writing code that doesn’t rot when requirements change. In small scripts they’re overkill. In a 50,000-line codebase with five engineers, they’re survival tools.

S — Single Responsibility

One class or module should have one reason to change. The wrong read: split everything into micro-classes. The right read: don’t let UserService handle authentication, email sending, and database access at the same time.

# Bad: one class, three reasons to change
class UserService:
    def authenticate(self, user, password): ...
    def send_welcome_email(self, user): ...
    def save_to_db(self, user): ...

# Better: each class changes for one reason
class UserAuthenticator:
    def authenticate(self, user, password): ...

class UserEmailer:
    def send_welcome(self, user): ...

class UserRepository:
    def save(self, user): ...

ELI5: If your class has a section on authentication AND a section on sending emails, you’ll edit it for two completely different reasons (auth bug vs email template change). That’s two responsibilities.

O — Open/Closed

Open for extension, closed for modification. In Python this means: don’t add if isinstance(x, NewType) blocks — extend via composition, plugins, or protocols instead.

# Bad: every new shape modifies this function
def area(shape):
    if isinstance(shape, Circle): return math.pi * shape.r**2
    if isinstance(shape, Rectangle): return shape.w * shape.h
    # ...add Square? modify here. Fragile.

# Good: shapes implement the protocol, this never changes
class Shape(Protocol):
    def area(self) -> float: ...

def total_area(shapes: list[Shape]) -> float:
    return sum(s.area() for s in shapes)

L — Liskov Substitution

Subtypes must be usable wherever the base type is expected — without surprising the caller. The classic violation: a Square subclass of Rectangle that breaks when you set width/height independently.

class Rectangle:
    def set_width(self, w): self.width = w
    def set_height(self, h): self.height = h

class Square(Rectangle):
    def set_width(self, w):   # violates LSP
        self.width = self.height = w  # surprise!
    def set_height(self, h):
        self.width = self.height = h

ELI5: If you have a function that works with Animal, it should work with Dog without any surprises. If Dog.speak() raises NotImplementedError, that’s a surprise.

I — Interface Segregation

Prefer small, focused protocols over fat base classes. In Python this is natural with typing.Protocol — clients depend only on the methods they actually use.

# Bad: everything must implement everything
class DataProcessor(ABC):
    def read(self): ...
    def write(self): ...
    def validate(self): ...
    def transform(self): ...

# Good: split into what callers actually need
class Readable(Protocol):
    def read(self) -> bytes: ...

class Writable(Protocol):
    def write(self, data: bytes) -> None: ...

D — Dependency Inversion

High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions. In Python: accept callables or Protocols as parameters instead of hardcoding concrete classes.

# Bad: high-level code depends on low-level EmailSender
class OrderService:
    def __init__(self):
        self.notifier = EmailSender()  # hardcoded

# Good: pass in anything that matches the protocol
class Notifier(Protocol):
    def send(self, message: str) -> None: ...

class OrderService:
    def __init__(self, notifier: Notifier):
        self.notifier = notifier  # test with FakeNotifier, prod with EmailSender

ELI5: Don’t hard-wire a lamp into the wall. Use a plug. Anyone who provides a compatible socket works.

SOLID in context:

ScaleSOLID value
1-file scriptIgnore it, not worth it
Small service (< 5k lines)Apply S and D selectively
Medium libraryApply all five, use Protocol heavily
Large application (50k+ lines)SOLID is required for survival

Creational Patterns

Factory Function

The Pythonic factory. Just a function that constructs and returns an object. No AbstractCreatorFactory needed.

def create_connection(db_type: str, **kwargs):
    match db_type:
        case "postgres": return PostgresConnection(**kwargs)
        case "sqlite":   return SQLiteConnection(**kwargs)
        case _: raise ValueError(f"Unknown db: {db_type}")

ELI5: A factory is just a function that decides what to build and builds it. You tell it “I want a postgres connection” and you get one back without knowing the gory details.

Abstract Factory

When you need families of related objects that must be consistent with each other — e.g., “create a UI kit where everything is either dark-themed or light-themed.”

class UIFactory(Protocol):
    def make_button(self) -> Button: ...
    def make_dialog(self) -> Dialog: ...

class DarkUIFactory:
    def make_button(self) -> DarkButton: ...
    def make_dialog(self) -> DarkDialog: ...

Use this when you switch entire families together. If you’re just switching one thing, a factory function is enough.

Builder

For constructing complex objects step by step. Python’s dataclass with defaults, keyword arguments, and attrs cover 80% of builder use cases without a formal Builder class.

# Python builder via method chaining
class QueryBuilder:
    def __init__(self):
        self._table = ""
        self._filters = []

    def from_table(self, table: str) -> "QueryBuilder":
        self._table = table
        return self

    def where(self, condition: str) -> "QueryBuilder":
        self._filters.append(condition)
        return self

    def build(self) -> str:
        where = " AND ".join(self._filters)
        return f"SELECT * FROM {self._table} WHERE {where}"

query = QueryBuilder().from_table("users").where("age > 18").where("active = 1").build()

Singleton

The Pythonic singleton is a module-level instance. Module imports are cached — the second import config returns the same object.

# config.py — already a singleton by virtue of being a module
_db_pool = None

def get_pool():
    global _db_pool
    if _db_pool is None:
        _db_pool = create_pool()
    return _db_pool

Need a real singleton class? Override __new__:

class Registry:
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

Anti-pattern: Singletons make testing hard because they carry global state. Prefer dependency injection for testable code — only use singletons for truly shared resources like DB connection pools.


Structural Patterns

Decorator

Python has built-in decorator syntax. This is the most powerful structural pattern in the language.

import functools, time

def retry(max_attempts: int = 3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    time.sleep(2 ** attempt)
        return wrapper
    return decorator

@retry(max_attempts=3)
def fetch_data(url: str): ...

Adapter

Wraps an incompatible interface to match what the caller expects. Use when you can’t change the third-party code.

class OldPaymentGateway:
    def process_credit_card(self, card_num, amount): ...

class PaymentAdapter:
    """Makes OldGateway look like our new PaymentProcessor protocol."""
    def __init__(self, gateway: OldPaymentGateway):
        self._gateway = gateway

    def charge(self, payment_method, amount: float) -> bool:
        return self._gateway.process_credit_card(payment_method.number, amount)

Facade

Hides a complex subsystem behind a simple interface. The most underused pattern — aggressively apply this when you have a complex library (e.g., boto3) that you call from many places.

class S3Facade:
    """Hides boto3 complexity. Callers don't need to know about it."""
    def __init__(self, bucket: str):
        self._s3 = boto3.client("s3")
        self._bucket = bucket

    def upload(self, key: str, data: bytes) -> None:
        self._s3.put_object(Bucket=self._bucket, Key=key, Body=data)

    def download(self, key: str) -> bytes:
        return self._s3.get_object(Bucket=self._bucket, Key=key)["Body"].read()

ELI5: A facade is the reception desk of a hotel. You don’t call housekeeping, maintenance, and room service directly. You call reception and they handle it.

Proxy

Controls access to another object. Useful for lazy loading, caching, and access control. Python’s __getattr__ makes transparent proxying easy.

class LazyLoader:
    def __init__(self, factory):
        self._factory = factory
        self._obj = None

    def __getattr__(self, name):
        if self._obj is None:
            self._obj = self._factory()
        return getattr(self._obj, name)

Behavioral Patterns

Strategy

Pass a function instead of a strategy class. This is Python’s biggest advantage over Java-style GoF.

# Java way: class hierarchy for each strategy
# Python way: just pass a callable
def process_payments(payments, sorter=sorted):
    return sorter(payments, key=lambda p: p.amount)

# Swap strategies at call site
process_payments(payments, sorter=lambda ps, key: sorted(ps, key=key, reverse=True))

ELI5: Instead of creating a SortStrategy class with AscendingSortStrategy and DescendingSortStrategy subclasses, just pass sorted or reversed as an argument.

Observer

Notify multiple listeners when something changes. Use weakref to avoid memory leaks when observers are short-lived.

import weakref
from typing import Callable

class EventEmitter:
    def __init__(self):
        self._listeners: dict[str, list] = {}

    def on(self, event: str, callback: Callable) -> None:
        self._listeners.setdefault(event, [])
        self._listeners[event].append(weakref.ref(callback))

    def emit(self, event: str, *args, **kwargs) -> None:
        dead = []
        for ref in self._listeners.get(event, []):
            cb = ref()
            if cb is None:
                dead.append(ref)
            else:
                cb(*args, **kwargs)
        for ref in dead:
            self._listeners[event].remove(ref)

Anti-pattern: Storing strong references to callbacks causes memory leaks. If a listener object is garbage collected, the emitter still holds it alive.

Iterator

Python’s iteration protocol is built-in. Generators are the idiomatic way to write lazy iterators.

def chunked(iterable, size: int):
    """Yield successive chunks. Memory-efficient for large sequences."""
    chunk = []
    for item in iterable:
        chunk.append(item)
        if len(chunk) == size:
            yield chunk
            chunk = []
    if chunk:
        yield chunk

Command

Callables with functools.partial cover most command use cases. Use command objects only when you need undo/redo.

import functools

# Simple commands as partials
def set_volume(mixer, level): mixer.volume = level

undo_stack = []
command = functools.partial(set_volume, mixer, 80)
command()
undo_stack.append(functools.partial(set_volume, mixer, mixer.volume))  # capture current state

State Machine

Dict-based dispatch tables are cleaner than a class hierarchy for state machines.

from enum import Enum, auto

class State(Enum):
    IDLE = auto()
    RUNNING = auto()
    PAUSED = auto()

TRANSITIONS = {
    (State.IDLE, "start"):    State.RUNNING,
    (State.RUNNING, "pause"): State.PAUSED,
    (State.RUNNING, "stop"):  State.IDLE,
    (State.PAUSED, "resume"): State.RUNNING,
    (State.PAUSED, "stop"):   State.IDLE,
}

class StateMachine:
    def __init__(self):
        self.state = State.IDLE

    def trigger(self, event: str) -> None:
        key = (self.state, event)
        if key not in TRANSITIONS:
            raise ValueError(f"No transition for {self.state} + {event!r}")
        self.state = TRANSITIONS[key]

ELI5: A state machine is like a traffic light. It can only be in one state at a time (red, yellow, green), and only specific transitions are allowed (red -> green is valid, red -> yellow is not).

Chain of Responsibility — Middleware

Middleware is Python’s most common chain of responsibility. Django, Flask, Starlette all use it.

from typing import Callable

Middleware = Callable[[dict, Callable], dict]

def logging_middleware(request: dict, next_handler: Callable) -> dict:
    print(f"Request: {request}")
    response = next_handler(request)
    print(f"Response: {response}")
    return response

def auth_middleware(request: dict, next_handler: Callable) -> dict:
    if not request.get("token"):
        return {"error": "unauthorized"}
    return next_handler(request)

Dependency Injection in Python

DI is about making dependencies explicit and swappable. Python doesn’t need a DI framework for most use cases.

ApproachWhen to use
Constructor injectionLong-lived objects, complex dependencies
Function argument injectionSimple functions, one-off calls
Module-level configurationSingletons, global settings
DI container (framework)10+ interchangeable components
# Constructor injection — explicit, testable
class ReportGenerator:
    def __init__(self, fetcher: DataFetcher, renderer: Renderer):
        self._fetcher = fetcher
        self._renderer = renderer

# In tests: inject mocks
gen = ReportGenerator(fetcher=FakeFetcher(), renderer=FakeRenderer())

# In production: inject real implementations
gen = ReportGenerator(fetcher=HttpFetcher(api_key), renderer=PDFRenderer())

ELI5: DI is just “don’t create your own tools inside the class, ask for them in the constructor.” This way your tests can hand the class a fake tool.

When a DI framework (dependency-injector, punq) is worth it: when you have 10+ services with nested dependencies that change based on environment. For 3-5 services, manual constructor injection is cleaner.


Plugin & Extension Systems

Entry Points (the Standard Way)

pyproject.toml entry points let third-party packages register plugins that your application discovers at runtime.

# In the plugin's pyproject.toml
[project.entry-points."myapp.plugins"]
my_plugin = "my_package.plugin:MyPlugin"
# In the host application
from importlib.metadata import entry_points

def load_plugins():
    plugins = {}
    for ep in entry_points(group="myapp.plugins"):
        plugins[ep.name] = ep.load()
    return plugins

This is how pytest discovers plugins (pytest11 group), how Flask extensions register themselves, how Django apps register.

__init_subclass__ Auto-Registration

Auto-register subclasses without decorators — they register themselves just by being defined.

class Plugin:
    _registry: dict[str, type] = {}

    def __init_subclass__(cls, name: str = "", **kwargs):
        super().__init_subclass__(**kwargs)
        if name:
            Plugin._registry[name] = cls

class CSVExporter(Plugin, name="csv"):
    def export(self, data): ...

class JSONExporter(Plugin, name="json"):
    def export(self, data): ...

# Plugin._registry == {"csv": CSVExporter, "json": JSONExporter}

ELI5: As soon as Python reads the class CSVExporter(Plugin, name="csv") line, it calls Plugin.__init_subclass__ automatically. The plugin registers itself. You don’t need to maintain a list anywhere.


Clean Architecture in Python

Clean architecture separates your system into layers where inner layers don’t know about outer layers. The dependency arrow always points inward.

Infrastructure (DB, HTTP, Files)
    ↓ depends on
Adapters (Repositories, Controllers)
    ↓ depends on
Use Cases (Business Logic)
    ↓ depends on
Domain (Entities, Value Objects)

The rule: Domain imports nothing from your project. Use Cases import only Domain. Adapters import Use Cases and Domain. Infrastructure imports everything.

# domain/order.py — no external imports
@dataclass
class Order:
    id: str
    items: list[OrderItem]

    def total(self) -> Decimal:
        return sum(item.price for item in self.items)

# use_cases/place_order.py — depends only on domain + abstractions
class OrderRepository(Protocol):
    def save(self, order: Order) -> None: ...

def place_order(items: list[OrderItem], repo: OrderRepository) -> Order:
    order = Order(id=str(uuid4()), items=items)
    repo.save(order)
    return order

# infrastructure/postgres_order_repo.py — the outer layer, knows about DB
class PostgresOrderRepository:
    def save(self, order: Order) -> None:
        # actual SQL here
        ...

ELI5: Your business rules (Order.total()) shouldn’t care if data is stored in Postgres or a CSV file. Swapping the database should only require changing one class in the infrastructure layer.

When clean architecture is overkill:

SituationRight call
One-off scriptFlat module, no layers
Simple CRUD microserviceFlask/FastAPI directly, no layers
Service with real business rulesUse case layer makes sense
Large system with team of 5+Full clean architecture justified

Anti-patterns to Avoid

God class: One class with 20 methods covering authentication, business logic, and data access. Grows without bound. Split by responsibility.

Premature abstraction: Creating a DataProvider protocol when you have exactly one implementation. Wait until you have two different implementations before abstracting.

Java-in-Python: Writing getUsername()/setUsername() methods instead of @property. Python has properties, __slots__, __getattr__ for a reason.

Factory of factories:

# You have created a problem factory
class AbstractParserFactoryFactory:
    def create_factory(self) -> AbstractParserFactory: ...

ELI5: If you’re three levels deep in factory classes and none of them do real work, you’re doing accidental complexity. Accidental complexity is complexity you created, not complexity that was in the problem.

Not using Python’s strengths:

Java patternPythonic equivalent
Strategy class hierarchyPass a callable
Command class hierarchyfunctools.partial
Iterator classGenerator function
Singleton patternModule-level instance
Builder classdataclass + keyword args

Decision Table

ProblemPythonic solutionWhen to escalate
Create objects conditionallyFactory functionAbstract Factory if families required
Complex object constructiondataclass + keyword argsBuilder class if ordering matters
Shared global stateModule-level instance__new__ singleton if subclassing needed
Swappable behaviorPass a callableStrategy class if state is needed
Extend without modificationProtocol + compositionABC if shared base behavior required
Cross-cutting concernsDecoratorMiddleware chain if ordering matters
Loose couplingConstructor injectionDI container if 10+ dependencies
Discover plugins at runtimeEntry points__init_subclass__ for internal plugins
Separate business from infraUse case layer + ProtocolsFull clean architecture if team of 3+