booklore

Architecture Patterns with Python

Enabling Test-Driven Development, Domain-Driven Design, and Event-Driven Microservices

sufficient

reading path: overview → analysis → narration


overview

Overview

Architecture Patterns with Python (2020) by Harry Percival and Bob Gregory is a hands-on guide to applying proven software architecture patterns — long staples of Java and C# ecosystems — to modern Python applications. Both authors were engineers at MADE.com, and the book grew out of questions Percival fielded after his earlier book on TDD with Django: how do you structure an application so that your core business logic is covered by unit tests and you minimize the number of integration tests needed?

The answer is a layered architecture built around Domain-Driven Design, Ports and Adapters (Hexagonal Architecture), and event-driven patterns — all demonstrated through a single example application (an e-commerce allocation system) built step by step.

The book's core philosophy: your application's business logic should be testable without databases, web servers, or external APIs. Every architectural decision flows from that principle.


The Architecture Stack at a Glance

| Layer | Responsibility | Pattern | |-------|----------------|---------| | Domain Model | Business rules, entities, value objects | DDD Aggregates | | Service Layer | Orchestration, use cases | Unit of Work | | Repository | Data access abstraction | Repository Pattern | | Message Bus | Command/event dispatch | Event Bus | | Adapters | Web, DB, external I/O | Ports and Adapters |


Structure

The book is organized in two parts:

Part I — Building an Architecture to Support Domain Modeling covers the foundational patterns: Repository, Unit of Work, Aggregates, and the Service Layer. Each chapter alternates between writing a failing test and implementing the pattern to make it pass.

Part II — Event-Driven Architecture introduces domain events, the Message Bus, CQRS, and dependency injection. Patterns from Part I are retrofitted as the application grows to handle more complex workflows like email notifications and external API calls.


Key Themes

| Theme | Description | |-------|-------------| | Testability as a Design Force | TDD is not just a testing practice — it shapes the architecture. Patterns emerge from the need to make core logic unit-testable. | | Dependency Inversion | Rely on abstractions (protocols / ABCs), not concrete implementations. The domain model knows nothing about Django, Flask, or SQLAlchemy. | | Domain-Driven Design | Ubiquitous language, bounded contexts, aggregates, and domain events — adapted for Python without Java's ceremony. | | Event-Driven Composition | Side effects (emails, logs, API calls) become event handlers, not inline code. The message bus orchestrates them. | | Incremental Architecture | Start simple (single Flask app + SQLAlchemy). Introduce patterns as complexity demands. No big upfront design. |


Why This Book Matters

Before this book, Python developers had few resources bridging the gap between "tutorial-level Flask/Django apps" and the architectural literature from the Java world (Evans' DDD, Martin's Clean Architecture, Vernon's Implementing DDD). Most Python shops either reinvented these patterns poorly or dismissed them as "too Java."

Percival and Gregory changed that by showing that DDD and hexagonal architecture are not language-specific — they are programmer constructs that work beautifully in Python when adapted idiomatically. The book is also notable for its strict TDD approach: every architectural decision is validated by a test written first.

The companion website (cosmicpython.com) hosts the complete code, exercises, and errata, making this one of the most practical architecture books for the Python community.


Who Should Read

| Reader Type | Why | |---|---| | Python developers building apps that outgrow a single module | The patterns scale with complexity. Start with Repository; add the bus later. | | Developers transitioning from Django/Flask to microservices | Part II directly addresses event-driven decomposition. | | TDD practitioners seeking architectural guidance | Each pattern emerges from the need to write cleaner tests. | | Architects evaluating Python for enterprise | Shows Python can handle DDD and hexagonal patterns without sacrificing expressiveness. |


| Book | Connection | |------|------------| | Clean Architecture (Martin) | The theoretical foundation for Ports and Adapters. APWP implements what Clean Architecture preaches, in Python. | | Designing Data-Intensive Applications (Kleppmann) | Deeper treatment of event-driven systems, stream processing, and CQRS at scale. | | Building Microservices (Newman) | APWP's event-driven patterns are a stepping stone to the microservice decomposition Newman describes. | | The Pragmatic Programmer (Hunt) | Shares the same practical, tool-agnostic philosophy — invest in patterns that pay for themselves. | | Design Patterns (GoF) | APWP updates classic GoF patterns (e.g., Command, Observer) for the Python and DDD context. |


Final Verdict

Architecture Patterns with Python is the best practical introduction to DDD and hexagonal architecture for Python developers. Its strength is concreteness: every pattern is accompanied by working code, tests, and a clear explanation of the problem it solves.

The book's limitations are its scope — it covers one medium-sized example, and the treatment of CQRS and event sourcing is introductory. Readers needing production-scale guidance will need to supplement with Kleppmann or Vernon.

Rating: 8.5/10 — Indispensable for the intermediate Python developer; less novel for architects already familiar with the patterns from other languages.


content map

The Architectural Blueprint

The book builds a single e-commerce allocation system across every chapter. The architecture evolves through six layers:

flowchart TB
    subgraph Adapters["Adapters / I/O"]
      Flask["Flask (Web)"]
      DB["SQLAlchemy (DB)"]
      Email["Email (SMTP)"]
      Redis["Redis (Pub/Sub)"]
    end

    subgraph ServiceLayer["Service Layer"]
      Handlers["Command / Event Handlers"]
      UoW["Unit of Work"]
    end

    subgraph Domain["Domain Model"]
      Products["Product Aggregate"]
      Orders["Order Entity"]
      Batches["Batch Entity"]
      Events["Domain Events"]
    end

    subgraph Bus["Message Bus"]
      Router["Command -> Handler Router"]
      EventsQueue["Event Queue"]
    end

    subgraph Tests["Test Suite"]
      UnitT["Unit Tests"]
      IntT["Integration Tests"]
      E2ET["End-to-End Tests"]
    end

    Flask --> Handlers
    DB --> UoW
    UoW --> Products
    UoW --> Orders
    UoW --> Batches
    Handlers --> Router
    Router --> UoW
    Products -.->|"emit"| Events
    Events --> EventsQueue
    EventsQueue --> Router
    Tests -.->|"drive"| Domain
    Tests -.-> ServiceLayer
    Tests -.-> Adapters

The rule: arrows point toward the domain. The domain model depends on nothing. Everything depends on it.


Part I: Building the Foundation

1. Domain Model First

The book starts with a plain Python domain model — no ORM, no framework. An OrderLine value object, a Batch entity, and a Product aggregate. Business rules live here as pure functions:

  • Batch.can_allocate() — can this batch fulfill an order line?
  • Batch.allocate() — reduce available quantity
  • Product.allocate() — find and allocate the best batch

These are tested with zero infrastructure. Pure TDD: write the test, write the domain code, watch it pass.

2. The Repository Pattern

Problem: domain logic tests that hit a real database are slow and brittle. Solution: abstract data access behind a Repository protocol.

class AbstractRepository(ABC):
    @abstractmethod
    def add(self, product):
        ...

    @abstractmethod
    def get(self, sku) -> Product:
        ...

Two implementations follow:

  • SqlAlchemyRepository — real database
  • FakeRepository — an in-memory dict for tests

The test for allocation now creates a FakeRepository, injects it into the service layer, and verifies business logic without a database. This is the core win: your core logic is testable in milliseconds.

3. The Unit of Work Pattern

The Repository pattern works for reads but doesn't coordinate writes. If allocating an order line triggers multiple repository writes, partial failures leave the database inconsistent.

The Unit of Work captures the atomicity concern:

class AbstractUnitOfWork(ABC):
    products: AbstractRepository

    @abstractmethod
    def commit(self):
        ...

    @abstractmethod
    def rollback(self):
        ...

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.rollback()

The service layer uses a context manager:

def allocate(order_line: OrderLine, uow: AbstractUnitOfWork):
    with uow:
        product = uow.products.get(order_line.sku)
        product.allocate(order_line)
        uow.commit()

Key benefit: the test can use a FakeUnitOfWork (which bundles a FakeRepository and a no-op commit) and verify that commit() was called. No database needed — even transactional behavior is testable at unit speed.

4. Aggregates and Consistency Boundaries

An Aggregate is a cluster of domain objects treated as a single unit for data changes. The book's Product aggregate contains Batch entities and enforces invariants:

  • Cannot allocate more than available stock
  • Can deallocate and reallocate
  • Emits domain events when state changes

The aggregate is the consistency boundary: within a single aggregate, strong consistency; between aggregates, eventual consistency. This distinction becomes critical in Part II.

5. The Service Layer

The service layer (or use-case layer) orchestrates domain operations without embedding orchestration in the domain or leaking it into the web layer. A typical service function:

  1. Retrieves an aggregate via the repository
  2. Calls a domain method
  3. Commits the unit of work

The Flask endpoint becomes a thin adapter:

@flask.route("/allocate", methods=["POST"])
def allocate_endpoint():
    line = OrderLine(request.json["sku"], ...)
    try:
        allocate(line, uow)
        return OK, 201
    except OutOfStock as e:
        return {"error": str(e)}, 400

No business logic in the controller. The controller just translates HTTP to domain calls and back.


Part II: Going Event-Driven

6. Domain Events

A domain event is something that happened in the domain that domain experts care about:

class Allocated(Event):
    order_id: str
    sku: str
    qty: int
    batch_ref: str

class Deallocated(Event):
    order_id: str
    sku: str
    qty: int

Events are emitted by the aggregate when state changes. They are not yet handled — they are simply recorded:

class Product:
    def allocate(self, line: OrderLine) -> str:
        batch = self._find_batch(line)
        batch.allocate(line)
        self.events.append(
            Allocated(
                order_id=line.order_id,
                sku=line.sku,
                qty=line.qty,
                batch_ref=batch.ref,
            )
        )
        return batch.ref

7. The Message Bus

The Message Bus connects commands (intent) to handlers (behavior) and events (facts) to event handlers (side effects). The bus pattern decouples the caller from the callee:

class MessageBus:
    def __init__(self, uow: AbstractUnitOfWork):
        self.uow = uow

    def handle(self, command: Command):
        handler = COMMAND_HANDLERS[type(command)]
        handler(command, self.uow)
        for event in self.uow.collect_new_events():
            self._handle_event(event)

    def _handle_event(self, event: Event):
        for handler in EVENT_HANDLERS[type(event)]:
            handler(event, self.uow)
            for e in self.uow.collect_new_events():
                self._handle_event(e)

This is the heart of the architecture. Commands are routed to a single handler. Events can have multiple handlers (e.g., send email and update read model). Handlers can emit new events, which the bus recursively processes.

8. CQRS (Command-Query Responsibility Segregation)

The book introduces CQRS as an escape hatch: when the read model diverges from the write model, build a separate read-only view.

For example, allocating products requires aggregates and invariants. But showing a user their order history is a simple query that does not need aggregates at all. CQRS says: use the domain model for writes, use flat queries (raw SQL, materialized views) for reads.

The authors are pragmatic here — CQRS is an option, not a mandate. They introduce it only when the example genuinely needs it.

9. Dependency Injection

The final piece: wire everything together at the entrypoint:

uow = SqlAlchemyUnitOfWork(session_factory)
bus = MessageBus(uow)

app = Flask(__name__)
app.config["bus"] = bus

No global state. No import db from a config module. The bus, the unit of work, and the session are created once and injected. Every component can be swapped for testing.


Key Lessons

  • Architecture follows testability. The patterns exist to make core logic testable without infrastructure. If a pattern doesn't improve testability, reconsider it.
  • The domain model is the center of the universe. Infrastructure (web, DB, queues) is peripheral. Dependencies point inward.
  • Start simple, add patterns as you grow. The book introduces Repository in chapter 2, UoW in chapter 3, events in chapter 8. Each pattern solves a concrete problem that appeared in the previous chapter.
  • Python handles these patterns idiomatically. Protocols instead of interfaces, context managers for UoW, simple callables for handlers — no heavy framework needed.
  • Events make side effects explicit. Instead of hiding email sending inside allocate(), emit an event and let a handler send it. The domain stays pure; the side effect is visible and testable.

analysis

Strengths

  • Exceptionally concrete. Most architecture books stay at the diagram level. This one gives you working Python code, with tests, for every pattern. You can run pytest and see the architecture in action. The cosmicpython.com repo is a genuine resource.
  • TDD as an architectural tool. The book does not treat testing as an afterthought. Tests come first in every chapter, and the patterns emerge from the need to make those tests fast and reliable. This is a rare and valuable pedagogical approach.
  • Pythonic without being sloppy. The authors adapt DDD and hexagonal architecture to Python's idioms — protocols over interfaces, context managers for UoW, simple dicts for the message bus routing. The result is readable and pragmatic rather than ceremonious.
  • Excellent pacing. Patterns are introduced one at a time, each motivated by a concrete problem. The book never throws too many concepts at once. By chapter 12 you have a fully event-driven system — but you got there incrementally.
  • Honest about trade-offs. The authors flag when a pattern is overkill (CQRS for simple cases, domain events for trivial side effects). They include a chapter on "it's not always DDD" that advises readers when to deviate from the architecture.

Weaknesses

  • Single example, limited scope. The allocation domain is simple enough to fit in a book but does not expose the patterns to realistic complexity. Readers working on systems with dozens of aggregates, nested transactions, or distributed sagas will need supplementary material.
  • Light on production concerns. The book does not cover monitoring, distributed tracing, idempotency, retry policies, dead-letter queues, or deployment. The message bus is in-process. Turning it into a distributed bus (RabbitMQ, Kafka) is left as an exercise.
  • Assumes TDD acceptance. If you are not already a TDD practitioner, the strict test-first flow may feel forced. The book does not argue for TDD — it assumes you already buy in.
  • Limited TypeScript / typing coverage. Protocols are used for dependency abstraction, but the broader typing story (Pydantic for value objects, generic repository types) is underdeveloped by modern Python 3.12+ standards.
  • CQRS treatment is introductory. The CQRS chapter is the shortest in the book and does not address read-model synchronization latency, eventual consistency failure modes, or multi-service CQRS topologies.

Critique

The "TDD First" Approach

The book's defining choice — every pattern emerges from a failing test — is both its greatest strength and a source of friction. Readers who prefer understanding the architecture before writing code may find the test-first rhythm distracting. The tests occasionally triage design decisions that would be clearer with a diagram-first explanation. That said, for readers who already write tests, this approach validates that the patterns actually deliver testability — they are not just aspirational diagrams.

Abstraction Inversion Risk

The Repository and Unit of Work patterns abstract SQLAlchemy, but SQLAlchemy already has its own unit of work (the session). The book's UoW is essentially an adapter around SQLAlchemy's session. Critics argue this adds a layer of indirection without proportional value — especially since swapping SQLAlchemy for another ORM is rare in practice. The authors acknowledge this trade-off and argue the value is in testability, not ORM-swappability.

Event Bus as In-Process

The book's message bus runs in-process. Events are handled synchronously in the same transaction. This works for the example but does not prepare readers for event-driven failures in production: a handler crashes, the event is lost, and the system is inconsistent. The book notes this limitation but does not address compensating transactions or outbox patterns.


Comparison to Similar Books

| Book | Key Difference | |------|----------------| | Clean Architecture (Martin) | Martin is theoretical and language-agnostic. APWP is Python-specific and hands-on. Read Martin for why; read APWP for how. | | Designing Data-Intensive Applications (Kleppmann) | DDIA focuses on distributed data systems at scale. APWP focuses on in-process architecture. They complement rather than overlap. | | Building Microservices (Newman) | Newman assumes services already exist. APWP shows how to structure a single service so it can be decomposed later. | | Domain-Driven Design (Evans) | Evans is the canonical DDD reference — dense, comprehensive, language-agnostic. APWP is a Python-focused subset. | | Implementing Domain-Driven Design (Vernon) | Vernon is more complete than APWP but uses Java. APWP is the best Python alternative to Vernon. |


Final Assessment

| Dimension | Rating | Notes | |-----------|--------|-------| | Practical Utility | 9/10 | Every pattern has runnable code; the test-first approach proves each works | | Originality | 7/10 | Patterns are known; the Python adaptation and TDD pedagogy are novel | | Readability | 8/10 | Clean, conversational prose; code examples are well-commented | | Depth | 7/10 | Covers one example thoroughly; does not venture into multi-service territory | | Python Fit | 9/10 | Idiomatic, framework-agnostic Python — not Java translated to Python | | Overall | 8/10 | The best book on architecture patterns in and for Python |

Architecture Patterns with Python fills a genuine gap: it teaches proven patterns with Python code, not Java diagrams. It is not a comprehensive architecture handbook — it is a practical, test-driven introduction to the patterns that matter most for greenfield Python projects. Its shelf life will be long because the patterns it covers precede any framework or version.


narration

Introduction

Welcome to BookAtlas. Today: Architecture Patterns with Python: Enabling Test-Driven Development, Domain-Driven Design, and Event-Driven Microservices by Harry Percival and Bob Gregory. Published March 2020, O'Reilly Media. 301 pages. Free companion code on cosmicpython.com. This is the book that showed the Python world that DDD and hexagonal architecture are not "Java things."

Tonight we have two perspectives. On one side, a backend architect who has used these patterns in production for years. On the other, a Django developer who has built monoliths and wonders if all this abstraction is worth the ceremony. Let's get into it.


The Setup: Why This Book Exists

Architect: Harry Percival wrote the O'Reilly classic Test-Driven Development with Python (the "Testing Goat" book). After that book came out, readers kept asking him: okay, I can test my Django views — but how do I structure my application so that the core logic is testable without a Django test client? How do I write unit tests for business rules without spinning up a database? This book is the answer.

Django Dev: But Django already has a perfectly good ORM. Why add a repository layer on top of it? Why abstract the unit of work when Django's transaction management works fine?

Architect: Because those abstractions let you test your core logic without Django at all. The book's whole premise is that your business logic should be testable in pure Python — no ORM, no framework, no database. The day you want to migrate from Django to FastAPI, you change the adapter, not the domain. That's the payoff.


The Domain Model: Heart of the System

The book opens with a plain Python class. No inheritance from models.Model. No SQLAlchemy declarative_base(). Just a Batch entity and an OrderLine value object with pure business methods:

class Batch:
    def __init__(self, ref: str, sku: str, qty: int, eta: date):
        self.ref = ref
        self.sku = sku
        self.eta = eta
        self._purchased_quantity = qty
        self._allocations: set[OrderLine] = set()

    def allocate(self, line: OrderLine):
        if self.can_allocate(line):
            self._allocations.add(line)

    def can_allocate(self, line: OrderLine) -> bool:
        return (
            self.sku == line.sku
            and self.available_quantity >= line.qty
        )

Architect: This is the right place to start. No framework decisions yet. Just business language. "Can this batch allocate this order line?" — that's the ubiquitous language Evans talks about in the DDD blue book. The authors defer infrastructure decisions until the domain is solid.

Django Dev: But in Django, Batch would be a model, and those methods would be model methods. What's the advantage of keeping it pure?

Architect: Two things. First, test speed: you test can_allocate() in microseconds, no database. Second, separation: when your allocation logic gets complex (and it will — there are always business rules like "allocate from batches closest to their ETA first"), you want that complexity in one place, not scattered across your ORM callbacks and view functions.


Repository: The First Pattern

A repository mediates between the domain and data mapping layers. It acts like an in-memory collection:

class AbstractRepository(ABC):
    @abstractmethod
    def add(self, product):
        ...

    @abstractmethod
    def get(self, sku) -> Product:
        ...

Django Dev: So instead of Product.objects.get(sku=...), I call repo.get(sku) — what did I gain?

Architect: You gained the ability to test without a database. Look:

class FakeRepository(AbstractRepository):
    def __init__(self, products: dict):
        self._products = products

    def add(self, product):
        self._products[product.sku] = product

    def get(self, sku):
        return self._products.get(sku)

That's your test repository. No database setup, no fixtures, no transactions. And when you wire it into the service layer, the service layer itself becomes testable:

def test_allocate_returns_batch_ref():
    repo = FakeRepository({...})
    result = allocate(line, repo)
    assert result == "batch-ref-001"

Django Dev: Okay, that's genuinely useful. But it's also a lot of ceremony. How many of these abstractions does a real app need?

Architect: That's the thing — the book introduces them gradually. You don't implement all six patterns on day one. You start with just the repository. If your app stays simple, you stop there. The Unit of Work comes only when you need atomic multi-repo operations. The message bus comes only when side effects start tangling your service layer.


Unit of Work: Coordinating Transactions

The UoW pattern bundles multiple repository operations into a single transaction:

with uow:
    product = uow.products.get(sku)
    product.allocate(line)
    uow.commit()

Django Dev: This looks like a context manager wrapping a database transaction. Django's transaction.atomic() does this already.

Architect: Yes, but the UoW is an abstraction over transactions. The test version:

class FakeUnitOfWork:
    def __init__(self):
        self.products = FakeRepository()
        self.committed = False

    def commit(self):
        self.committed = True

    def rollback(self):
        pass

    def __enter__(self):
        return self

    def __exit__(self, *args):
        pass

Now your test can assert that commit() was called. You verify transactional behavior without a real database. Django's transaction.atomic() commits when the block exits — you cannot easily assert that it committed in a unit test.


The Message Bus: Events as First-Class Citizens

Part II introduces the message bus — the most impactful pattern in the book, in my opinion. Here is the flow:

  1. A command arrives (e.g., Allocate(order_id, sku, qty))
  2. The bus finds the handler (allocate_handler)
  3. The handler calls domain logic, which emits events (Allocated, OutOfStock)
  4. The bus collects those events and routes them to event handlers (e.g., send_email_on_allocated)
  5. Those handlers may emit new events, and the loop continues

Django Dev: So my allocation function doesn't send emails directly?

Architect: Correct. The allocation function just allocates. If sending an email fails, the allocation succeeded — the email is a side effect. The domain stays pure. You can test the email handler separately. You can add or remove handlers without touching the domain model.

Django Dev: But the bus is in-process. What if an event handler crashes?

Architect: That's the book's biggest limitation. In production, you would persist events to a database (outbox pattern) and use a message broker (Redis, RabbitMQ, Kafka) to guarantee delivery. The book keeps it in-process for simplicity. It flags this as a known limitation. Part II is titled "Event-Driven Architecture" but it is really "event-driven within a single process." Distributed event driven is a different book.


CQRS: Separating Reads from Writes

The CQRS chapter is short but important. The key insight: your domain model is optimized for enforcing business invariants during writes. It is often terrible for reads. The read side should be simple:

def allocations_view(database, sku):
    return database.execute(
        "SELECT batch_ref, sku, qty "
        "FROM allocations_view "
        "WHERE sku = :sku",
        dict(sku=sku),
    )

Architect: A direct SQL query. No aggregates, no events, no UoW. Just data. The read model is updated by an event handler that runs after the write model commits.

Django Dev: This sounds like eventual consistency. The user writes something and the read side might not see it immediately.

Architect: Exactly. And that is the trade-off. CQRS buys you performance and simplicity on the read side but costs you immediate consistency. You apply it only when the read side truly benefits. The book recommends you start without CQRS and refactor to it when the pain of mixing reads and writes exceeds the pain of eventual consistency.


The Verdict

Django Dev: I came in skeptical. A lot of this feels like unnecessary abstraction if you are happy with Django's ORM. But I can see the value for complex domains. The repository pattern alone is worth the price if you care about fast unit tests.

Architect: I've been using these patterns in production — at companies much smaller than MADE.com — and they pay for themselves the first time you need to change the database or add a new integration. The book is not for simple CRUD apps. But if your domain has real business rules, the small upfront cost of these abstractions is repaid many times over.

The book's greatest achievement is proving that Python can do DDD without Java-level ceremony. Protocols, context managers, and simple dicts as handler registries — this is Pythonic architecture, not translated architecture.

Django Dev: One concern: the book teaches you how to build the architecture, but it doesn't teach you when to stop adding patterns. Every Python developer who reads this will want to add a message bus to their To-Do app.

Architect: That's fair. The authors address this in chapter 11: "don't do DDD if you don't need it." The patterns exist to solve specific problems — slow tests, tangled side effects, implicit transactions. If you don't have those problems, don't apply the pattern. Architecture is debt, even good architecture. Incur it only when the return is clear.


Final Thoughts

Architecture Patterns with Python is the best book I have read on applying DDD and hexagonal architecture to Python. It is not a comprehensive guide to distributed systems, event sourcing, or CQRS at scale. But it is the book every intermediate Python developer should read before building their second nontrivial application.

The patterns survive framework changes. The test-first approach builds genuine confidence. And the code on cosmicpython.com will still be relevant long after the next Django or FastAPI version arrives.

This has been a BookAtlas narration of Architecture Patterns with Python by Harry Percival and Bob Gregory. Thanks for listening.