Python: Design Patterns & Architecture

Keywords

design patterns, SOLID, dependency injection, clean architecture, strategy, factory, adapter, pythonic patterns, decoupling

Introduction

The order service started simple. A function took a request, looked the customer up in the database, applied a discount, charged the card, and wrote a row. It worked, so it grew. The discount logic learned about loyalty tiers; the charge step learned about a second payment provider; the lookup learned about a read replica. None of this was unreasonable on its own. What made it fatal was that every new rule was bolted directly onto the same function, which talked directly to the ORM, which talked directly to the web framework’s request object. The business logic and the plumbing had fused into one slab of code.

The bill came due the day the team tried to add a third payment provider. The “discount tier” rule lived inside the same method that parsed the HTTP body, so testing the discount meant standing up a web request. The provider choice was a chain of if/elif that also committed the database transaction, so adding a branch meant touching the persistence code and re-reading the entire method to be sure nothing else moved. A change that should have been local — add a provider — rippled through HTTP parsing, discounting, and database writes, because all three were welded together. The team had not written bad code. They had written coupled code, and coupling is the tax you pay later, with interest, every time a requirement changes.

This is the problem design patterns exist to solve, and it is worth naming precisely before we name a single pattern. The enemy is not ugliness or missing abstraction; it is coupling that makes change non-local. When a small change to one requirement forces edits across unrelated parts of the system, the design is fighting you. Patterns are the accumulated, named answers to “how do I arrange this so the next change stays where it belongs?” They are not Java boilerplate to transliterate into Python. They are shapes that keep change cheap.

The Core Insight

A design pattern is a recurring solution to a recurring coupling problem. That framing matters because the canonical patterns — the Gang of Four catalog — were written for C++ and Java, languages where the only tool for “a thing you can swap out” is a class implementing an interface. Much of the ceremony in those patterns exists to work around the absence of first-class functions. Python has first-class functions, duck typing, and modules-as-singletons, so it collapses a great deal of that ceremony. In Python:

  1. A strategy is often just a function. If the only thing that varies is one behavior, you pass the behavior. You do not need a Strategy interface and three concrete subclasses to swap one algorithm.
  2. A singleton is often just a module. A module is imported once and shared; its top-level state is already a single instance. You rarely need a metaclass to enforce what the import system gives you for free.
  3. A factory is often just a dictionary. Mapping a name to a class is a dict lookup, not an AbstractFactory hierarchy.

So the lesson is not “memorize twenty-three patterns.” It is that patterns name a small number of moves for managing dependencies so that change stays local — and that Python lets you make most of those moves with far less code than the textbook suggests. The goal is the decoupling. The pattern is just one way to get there, and often not the lightest one.

A mental model

Think of patterns as seams — the deliberate cuts you make in a system so that two parts can change independently. A garment without seams is one piece of cloth: alter the sleeve and you re-cut the whole thing. Seams are where you join pieces so that you can replace a sleeve without touching the collar. A pattern is a well-placed seam: the Strategy seam lets the algorithm change without the caller changing; the Repository seam lets the database change without the business logic changing; the Adapter seam lets a third-party interface change without your code changing.

This reframes the whole subject. The question is never “which pattern should I use?” It is “where do I expect change, and how do I put a seam there?” A seam you do not need is dead weight — a place the cloth is cut for no reason, weaker than if it were whole. A seam you do need, placed where change actually happens, is the difference between a one-line edit and a week of rework. Patterns are the catalog of seams that have proven worth cutting.

When to use a pattern (and when it’s overengineering)

The hard part of patterns is not learning them; it is resisting them. Every pattern adds an abstraction, and every abstraction has a cost: another layer to read, another indirection to follow, another file to open. That cost is only worth paying when you get something back — and what you get back is cheap change at a seam you will actually use.

The discipline is YAGNI: You Aren’t Gonna Need It. Reach for a pattern when you have demonstrated volatility — a second implementation you can point to, a branch that keeps growing, duplication you have already copy-pasted twice. Do not reach for one on speculation. An interface with a single implementation is not flexible; it is an empty promise of flexibility that you pay for in indirection today and may never cash. The Strategy pattern around a single, stable algorithm is not “clean”; it is a seam cut into cloth that was never going to be altered.

Figure 9.1 shows the one structural pattern that earns its keep almost universally — the Dependency Rule — but even there the right move is to let the seams appear as the system grows, not to scaffold all of them on day one. Start with the simplest thing that works. Add a seam the moment a change is fighting you, and not before. The pain is the signal; treat its absence as permission to stay simple.

What you’ll learn

  • Why coupling — not ugliness — is the real cost in a codebase, and how patterns are named seams that keep change local
  • The two SOLID principles that carry the most weight in Python — single responsibility and dependency inversion — and why the others are corollaries
  • The Pythonic form of the key Gang of Four patterns (Strategy, Factory, Adapter, Observer), and how first-class functions collapse their ceremony
  • How to do dependency injection with no framework at all, using the constructor as the seam between a component and its collaborators
  • The Dependency Rule of Clean Architecture: a domain at the center that imports nothing, with frameworks and databases as replaceable detail at the edges
  • How to judge when a pattern earns its abstraction and when it is speculative overengineering

Prerequisites

  • Python: Advanced Language Features — first-class functions, Protocol, decorators, and abstract base classes are the raw material every pattern here is built from
  • Object-Oriented Programming — classes, composition versus inheritance, polymorphism, and why “favor composition” is more than a slogan
  • Comfort writing and running unit tests, since “is this testable in isolation?” is the single best test of whether a seam is in the right place

SOLID, distilled

SOLID is five principles, but in Python two of them do most of the work and the rest fall out as consequences. We will give the two their due and note the others honestly.

The Single Responsibility Principle says a class should have one reason to change. The operative word is reason, not thing — a class can do several related actions and still have one responsibility, as long as all of them change together for the same cause. The order service from the introduction violated this flagrantly: a single method changed when the discount rules changed, when the payment provider changed, and when the database schema changed. Three reasons, one place. The fix is to separate along the axes of change — discounting, charging, persisting — so that a change to one does not force you to read and re-test the other two. The litmus test is a question you ask out loud: “if requirement X changes, will I edit this class?” If you can name more than one independent X, you have more than one responsibility.

The Dependency Inversion Principle is the one that makes everything else possible, and it is worth stating carefully: high-level policy should not depend on low-level detail; both should depend on an abstraction. The order logic (high-level policy) should not import the Postgres client (low-level detail). Instead, the order logic should depend on an idea — “something I can save a user to” — and the Postgres client should implement that idea. The arrow of dependency, which naturally wants to point from your important code down toward the messy concrete library, gets inverted to point the other way: the concrete library depends on the abstraction your policy owns.

In Python the “abstraction” need not be a heavy abstract base class. A Protocol — structural typing — lets you declare the shape a collaborator must have without anyone inheriting from anything. The high-level code depends on the protocol; the low-level code merely happens to match it. This is dependency inversion with almost no ceremony.

from typing import Protocol

class UserStore(Protocol):
    """The shape the domain needs — owned by the domain, not the database."""
    def save(self, user: "User") -> "User": ...
    def by_email(self, email: str) -> "User | None": ...

Nothing implements UserStore by name; a class is a UserStore if it has those two methods. The domain depends on this protocol. The Postgres-backed class, the in-memory fake used in tests, and a future Redis-backed class all satisfy it without importing it. The dependency arrow points inward, toward the abstraction the domain controls.

The remaining three principles are real but, in Python, largely corollaries of these two. Open/Closed — open for extension, closed for modification — is what you get for free once you depend on a protocol: you add a new implementing class without touching the code that consumes the protocol. Liskov Substitution — a subtype must honor its supertype’s contract — is the rule that keeps Open/Closed from lying to you; a new implementation that secretly breaks the contract (the classic Square that breaks Rectangle because setting its width silently changes its height) makes substitution a trap rather than a guarantee. Interface Segregation — prefer many small interfaces to one fat one — is just Single Responsibility applied to the shape of an abstraction, and Protocol makes small interfaces cheap to declare. Learn SRP and DIP deeply; the other three you will mostly obey by obeying those two.

The Pythonic Gang of Four

The classic patterns are still worth knowing, because their names are a shared vocabulary — “let’s make that a Strategy” communicates a design in three words. But the Python form of each is lighter than the textbook, often dramatically so. The skill is recognizing the shape and then reaching for the smallest construct that realizes it.

Strategy is the cleanest example. The pattern’s intent is to make an algorithm swappable; the textbook realizes that with a Strategy interface and a concrete class per algorithm. In Python, an algorithm is a value — a function — so the lightest Strategy is a function you pass in. You climb to a class only when the strategy needs state between calls, several related methods, or its own configuration. The branchy if/elif dispatch that grew unmanageable in the introduction is a Strategy waiting to be extracted: replace the branches with a registry that maps a name to a behavior.

from typing import Callable

# A "strategy" is just a callable here; the registry is just a dict.
Pricer = Callable[[float], float]
PRICERS: dict[str, Pricer] = {
    "standard": lambda amount: amount,
    "loyalty": lambda amount: amount * 0.9,
    "clearance": lambda amount: amount * 0.5,
}

def price(tier: str, amount: float) -> float:
    try:
        return PRICERS[tier](amount)   # add a tier by adding a dict entry
    except KeyError:
        raise ValueError(f"unknown tier: {tier}")

Adding a pricing tier is now adding one entry to a dictionary, not editing a chain of branches and re-reading everything around them. That is the Open/Closed Principle made concrete — and it is also the Factory pattern, because PRICERS is exactly the “dictionary of things keyed by name” that a Python factory usually is. The two patterns collapse into one small dict. You graduate to a Protocol-typed registry when each strategy carries enough behavior to deserve a class:

class Pricer(Protocol):
    def price(self, amount: float) -> float: ...
    def explain(self) -> str: ...      # now there's more than one method to group

Adapter is the pattern with no shortcut, because its whole job is translation between two interfaces you do not control on both sides. When a third-party library speaks charge_cents(int) and your domain speaks process(Money), you write a thin class that presents your interface and forwards to theirs, converting on the way in and out. The adapter is the seam that keeps the vendor’s interface from leaking into your code — when they rename a method or change units, exactly one file changes. The Python version is still just a small wrapper class; what makes it Pythonic is keeping it thin and letting it satisfy a Protocol your domain owns, rather than building an adapter hierarchy.

Observer — notify many dependents when one thing changes — is the pattern most softened by Python’s first-class functions. The heavyweight form has an Observer interface and update() methods; the Python form is a list of callbacks the subject calls on change. A property setter that fires the callbacks is an idiom worth knowing, because it makes notification automatic rather than something a caller must remember.

class Document:
    def __init__(self) -> None:
        self._subscribers: list[Callable[[str], None]] = []

    def on_change(self, fn: Callable[[str], None]) -> None:
        self._subscribers.append(fn)     # register a callback, no interface needed

    @property
    def text(self) -> str:
        return self._text

    @text.setter
    def text(self, value: str) -> None:
        self._text = value
        for fn in self._subscribers:      # setting text notifies everyone
            fn(value)

One production caveat travels with Observer: a subject that holds strong references to its subscribers keeps them alive forever, and long-lived subjects with churning subscribers leak memory. When subjects outlive their observers, hold the callbacks in a weakref.WeakSet so dead subscribers drop out automatically. The pattern is light; the lifetime management is the part that bites.

Dependency injection without a framework

Dependency injection sounds enterprise, but it is the simplest idea in this chapter and the one with the highest payoff: a component should be handed its collaborators rather than constructing them itself. That is the whole technique. The constructor is the seam. When a class reaches out and builds its own database connection, it is welded to that connection; when it receives the connection as an argument, the weld becomes a seam, and anything matching the shape can be slotted in — including a fake, in a test.

Contrast the two. A class that constructs its own dependency cannot be tested without that dependency; a class that receives it can be tested against anything:

class UserService:
    """Collaborators arrive through the constructor — the seam."""
    def __init__(self, store: UserStore) -> None:
        self._store = store              # not constructed here; handed in

    def register(self, name: str, email: str) -> "User":
        if self._store.by_email(email):
            raise ValueError("email already registered")
        return self._store.save(User(name=name, email=email))

UserService never names Postgres. In production you hand it a SQLUserStore; in a test you hand it a dictionary-backed fake; neither requires changing the service. The test needs no database, no network, no fixtures — just an object with save and by_email. This is dependency inversion (depend on the UserStore protocol) and dependency injection (receive the implementation) working together: the principle says which way the arrow points, the technique is how you wire it. You do not need a DI framework or a container in Python. The constructor is the container, and the place where you assemble the real objects — the “composition root,” typically your application’s startup — is the one spot that knows which concrete classes exist.

War story: the singleton that ate the test suite

A team built a Config singleton — one global instance, reachable from anywhere via Config.instance(). It was genuinely convenient: no passing config around, just import and call. Then the test suite started failing intermittently, and only when run in a certain order. The cause was the singleton’s nature, not a bug in it: because it was one shared global, a test that mutated config — flipping a feature flag, pointing at a test database — left that state behind for whatever test ran next. Tests that passed alone failed in the suite, and passed again when run alone, the worst kind of flakiness to chase. A singleton is global mutable state wearing a design-pattern costume, and global mutable state is exactly what makes code untestable: you cannot give each test a clean instance because there is only ever one. The fix was to delete the singleton and inject config through constructors. Every test now built its own config and handed it in; state could not leak because nothing was shared. The lesson generalizes past singletons: any time a component reaches out to grab a global instead of receiving it, you have traded a little typing today for hidden coupling and unkillable test flakiness later.

Build it → Constructor-injected services and a layered domain in practice: Project 05: SaaS Web Platform wires its FastAPI handlers to a service layer that receives its repositories, so the business logic is tested without standing up the web framework or the database.

Clean architecture and the Dependency Rule

Everything so far — SRP, dependency inversion, injection — composes into a single architectural idea, and it is the centerpiece of the chapter. Clean architecture arranges a system as concentric rings, with the most stable, most valuable code at the center and the most volatile, most replaceable code at the edges. The center is the domain: your entities and business rules, the part that encodes what the software is actually for. Around it sits the application or use-case layer, which orchestrates the domain to accomplish a task. Around that sit interface adapters — controllers, presenters, repository implementations — and at the outermost edge sit frameworks and drivers: the web framework, the database, the external APIs.

The single rule that governs the whole structure is the Dependency Rule: source-code dependencies point only inward. An inner ring may know nothing about an outer ring. The domain does not import the web framework. The use cases do not import SQLAlchemy. Dependencies flow toward stability, never away from it — which is the Dependency Inversion Principle promoted from a class-level guideline to the organizing law of the entire codebase. Figure 9.1 shows the rings and the inward arrows; read it as “the further in you go, the less the code knows about the messy outside world, and the less reason it has to change.”

The mental model from earlier — stable things shouldn’t depend on volatile things — is exactly this rule. Your business rules are the most stable thing you own; they change when the business changes, which is rarely, and for good reasons. The database vendor, the web framework, the payment provider are the most volatile things you own; they change for reasons that have nothing to do with your business — a migration, a price change, a deprecation. If your stable core imports the volatile edge, then every churn at the edge threatens the core. Invert it, and the core sits untouched while the edges churn around it.

The payoff is concrete and worth stating plainly. When the domain imports nothing from the frameworks, two things become true at once. The database becomes swappable: the domain talks to a UserStore protocol, so you can back it with Postgres in production, SQLite in development, and a dictionary in tests, and the domain cannot tell the difference. The domain becomes testable in isolation: with no framework imports, you can construct an entity, run a business rule, and assert on the result with no server, no connection, and no fixtures — a millisecond unit test of the part that actually matters. The order service from the introduction failed both tests precisely because its domain logic was tangled with its framework and its database. Untangling them — pushing the frameworks to the edge and pointing every arrow inward — is what turns a slab that resists change into a system that absorbs it.

It is worth saying what this is not: it is not a mandate to scaffold four rings on the first day of a project. Like every pattern in this chapter, the rings should emerge as the system grows and the seams prove necessary. A small script does not need a domain layer. A service that has earned a second database backend, a second delivery mechanism, or a test suite that keeps tripping over the framework has earned its rings. Build the structure when the change it enables is a change you can actually see coming.

Build it → A domain-centered layout with frameworks at the edge: Project 05: SaaS Web Platform keeps its service and domain layers free of FastAPI specifics. For strategy- and command-style behavior selection at the application layer, see Project 28: AI Workflow Engine (pluggable step handlers) and Project 29: Model Routing Layer (policy-driven strategy selection over interchangeable backends).


Practical exercise

Difficulty: Level I · Level II · Level III

  1. Level I — Inject a dependency and fake it. Take a class that constructs its own collaborator internally — for example, a WeatherReport that builds an HTTP client in its __init__ and calls a live API. Refactor it so the client is passed into the constructor instead. Then write a unit test that hands it a fake client returning canned data, and assert on the report without any network call. Write down what was impossible to test before the refactor and is trivial after it.
  2. Level II — Replace branchy dispatch with a Strategy registry. Find (or write) a function whose body is a growing if/elif chain dispatching on a string — a formatter, an exporter, a notifier. Define a Protocol for the behavior, move each branch into a small class that satisfies it, and register the classes in a dict. Show that adding a new case now means adding a class and a registry entry, with the dispatch code untouched. Then write the counterargument: a paragraph on when this refactor is overengineering — when the if/elif was fine and the registry is dead weight. Be specific about what evidence would make you choose each way.
  3. Level III — Restructure a small app to the Dependency Rule. Take a small app whose business logic currently imports the database or the web framework directly. Separate it into a domain layer that imports neither, a Protocol for persistence that the domain owns, and a concrete repository at the edge that implements it. Wire the real objects together in a single composition root. Then demonstrate the two payoffs: swap the production repository for an in-memory fake without changing the domain, and write a domain unit test that runs with no server and no database. Write a short note on which arrows you had to invert and why each one now points inward.

Summary

Patterns are not Java incantations and they are not goals in themselves. They are named solutions to the one problem that makes codebases hard to change: coupling that turns a local requirement into a non-local edit. The way out is to put seams where change actually happens — and the foundational seam is dependency inversion, realized in Python with Protocol and constructor injection rather than frameworks. Most Gang of Four patterns are lighter in Python than the textbook suggests, because first-class functions and duck typing collapse their ceremony: a Strategy is often a function, a Factory a dictionary, an Observer a list of callbacks. Scaled up, dependency inversion becomes the Dependency Rule of clean architecture — a stable domain at the center that imports nothing, with the volatile frameworks and databases pushed to the edges and every dependency arrow pointing inward. That is what makes the database swappable and the domain testable in isolation. And the meta-skill above all of it is restraint: a seam you do not need is dead weight, so cut one only when a change is fighting you.

Key takeaways

  • The real cost in a codebase is coupling that makes change non-local; patterns are named seams that keep change where it belongs.
  • In Python, learn SRP and DIP deeply — the other three SOLID principles mostly follow from obeying those two. Protocol makes dependency inversion nearly free.
  • The Gang of Four patterns are lighter in Python: Strategy as a function or Protocol, Factory as a dict, Observer as callbacks. Reach for the smallest construct that realizes the shape.
  • Dependency injection is just “hand a component its collaborators.” The constructor is the seam; the composition root is the one place that knows the concrete classes.
  • Clean architecture is dependency inversion at system scale: a domain that imports nothing at the center, frameworks at the edge, dependencies pointing inward — which is what makes the DB swappable and the domain unit-testable.
  • Restraint is the hardest pattern. YAGNI: add a seam when change demonstrably hurts, never on speculation.

Connections to other chapters

  • Python: Advanced Language Features (prerequisite): every pattern here is built from the constructs that chapter teaches — first-class functions are the raw material of Strategy and Observer, Protocol is the raw material of dependency inversion, and decorators are the raw material of the Decorator pattern. The patterns are what those features are for.
  • Concurrency and Parallelism Models (cross-language): the same coupling lessons reappear with sharper teeth under concurrency. The singleton-as-global-state war story becomes a data race the moment two threads touch the shared instance, and injecting collaborators rather than grabbing globals is what makes concurrent code reasoned-about rather than feared — a hazard that chapter’s comparative treatment traces across all six languages’ threading models.
  • The Polyglot Landscape (Part I opener): that chapter argues the domain layer is the part of a service worth porting between languages, because it encodes the value while the frameworks are interchangeable detail. Clean architecture is the structural reason that claim is true — the Dependency Rule is precisely what keeps the domain free of language- and framework-specific entanglement, so the shape survives the port.
  • Web Services and Microservices (extension): a microservice split is a seam too, and a far more expensive one than a class boundary. The judgment from this chapter — cut a seam only where demonstrated change justifies it — is what separates a clean service boundary from a premature distributed monolith. The SaaS web platform project is where the layered, injected, domain-centered design of this chapter runs at full scale against real traffic and a real database.

Further reading

Essential

  • Percival & Gregory, Architecture Patterns with Python — the definitive Python treatment of repository, unit-of-work, service layer, and dependency injection without a framework; the closest book to this chapter’s worldview.
  • Martin, Clean Architecture — the long-form argument for the Dependency Rule and the concentric-ring model, with the SOLID principles developed in depth.

Deep dives

  • Gamma, Helm, Johnson & Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software — the original Gang of Four catalog; read it for the vocabulary and the problem each pattern solves, then translate to the Pythonic form.
  • Fowler, Patterns of Enterprise Application Architecture — where Repository, Service Layer, and many of the architectural patterns were first cataloged for real systems.

Historical context

  • Martin, “The Principles of OOD” (the original SOLID articles) — the source essays that introduced SRP, OCP, LSP, ISP, and DIP before they were an acronym.
  • Liskov & Wing, “A Behavioral Notion of Subtyping” (1994) — the formal statement behind the Liskov Substitution Principle, and the reason “honors the contract” is more than a slogan.