When building large-scale software systems, the greatest challenge is managing complexity over time, not writing the code itself. Without deliberate design boundaries, software inevitably decays into a “big ball of mud.” Changes in one part of the codebase ripple out to cause unexpected failures in completely unrelated modules.
Domain-Driven Design (DDD) gives you the strategic and tactical patterns to combat this decay.
Foundations of modular design
Understanding the core engineering objectives of modularity is essential before applying Domain-Driven Design patterns. David Parnas 1 Parnas, D.L. On the Criteria to Be Used in Decomposing Systems into Modules (1972) and Sam Newman 2 Newman, Sam. Building Microservices: Designing Fine-Grained Systems (2021) defined modularity around three interlinked concepts.
Information hiding
In 1972, David Parnas published a seminal paper introducing Information Hiding 1. He argued that developers should define modules not by the sequential steps of execution, but by the design decisions they hide from the rest of the system.
Developers often confuse information hiding with encapsulation (making fields private). True information hiding shields external clients from how the database stores data, the algorithms the system uses to calculate values, and any third-party vendor integrations on which the module depends. Hide a volatile decision behind a stable interface, and changing it has zero impact on external consumers.
High cohesion
Cohesion measures how closely related the responsibilities inside a single module are. Sam Newman summarizes this with a clean guideline: “The code that changes together, stays together.” 2
Cohesive code reduces the surface area of changes. When business rules evolve, you modify a single module rather than executing “shotgun surgery.” Shotgun surgery is a code smell coined by Martin Fowler in Refactoring. A single logical change (adding a field, renaming a method) forces edits in many scattered classes that all depend on that change.
A high-cohesion module has all its internal elements serving one well-defined business purpose. Change a business rule, and you only need to touch one module. Low cohesion is the opposite (a single requirement change requires modifying multiple files across the codebase).
Loose coupling
Coupling measures the degree of dependency between modules. In a loosely coupled system, a change to one module does not require changes to others.
Tight coupling happens when one module knows too much about the internal workings of another. This coupling creates fragile systems where modifying a database field in the catalog module breaks the checkout module.
By hiding design decisions inside highly cohesive boundaries, you’ll likely produce a loosely coupled architecture. Sometimes you won’t. Coupling has many forms, so be aware of the consumer patterns of your services.
Domain-Driven Design: strategic & tactical boundaries
Domain-Driven Design, pioneered by Eric Evans 3 Evans, Eric. Domain-Driven Design: Tackling Complexity in the Heart of Software (2003) , applies these modularity principles in practice. It aligns the technical architecture with the business domain.
Ubiquitous language
At the heart of DDD is the Ubiquitous Language, a shared vocabulary co-created by software developers and domain experts. The Ubiquitous Language is not a translation layer. The source code must reflect it directly: variable names, class names, database tables, and API endpoints all carry the same terms. One term, one meaning, everywhere it shows up.
A term in a Ubiquitous Language should only have a single meaning. If a term has multiple meanings, you are likely looking at a boundary between two distinct Bounded Contexts.
Domain decomposition
Splitting a domain into smaller, independent pieces is a critical step in the architecture phase. Common strategies include decomposing by business capability or by sub-domain.
Once boundaries are established, the next challenge is defining how the system behaves. Chris Richardson’s Microservices Patterns recommends a formal operation contract: Operation, Returns, Preconditions, and Postconditions.7 Richardson, Chris. Microservices Patterns: With Examples in Java (2018) State what must be true before an operation (preconditions) and what is guaranteed after (postconditions), and the contract leaves little room for misinterpretation.
Bounded contexts
A business domain is too complex to be represented with a single, unified model. For example, a “Product” means very different things to the Inventory team (weight, dimensions, warehouse location) than to the Sales team (price, marketing description, discount rules).
A Bounded Context draws an explicit boundary around a specific domain model. Inside that boundary, every term in the Ubiquitous Language has one and only one meaning.
Separating Inventory and Sales into distinct Bounded Contexts achieves:
- Information hiding: the inventory layer tracks weight and dimensions, preventing the sales layer from learning these physical details.
- High cohesion: keeping all warehouse stock rules together in a dedicated module.
A “Conway-aligned” team means the code’s module boundaries mirror the team’s ownership boundaries, so the architecture degrades slowly as the org changes. A Bounded Context is first an organizational boundary (a Conway-aligned team that owns the language) and second a code boundary. Vaughn Vernon’s Balancing Coupling in Software Design and Sam Newman’s Monolith to Microservices both argue that the right number of Bounded Contexts is one per team,8 Vernon, Vaughn. Balancing Coupling in Software Design: Universal Design Principles for Architecting Modular Software Systems (2020); Newman, Sam. Monolith to Microservices: Evolutionary Patterns to Transform Your Monolith (2019) and the technical boundary can be a package in a single deployable. The two-context diagram above is one of three valid deployment topologies:
- Two packages, one deployable, one database (the modular monolith). The contexts communicate via in-process calls or an in-memory event bus. Cheapest to operate, easiest to refactor. The right starting point.
- Two services, shared database. The contexts communicate via HTTP or a message bus. Useful when the two teams need independent deploy cadences but not independent data ownership.
- Two services, two databases, async messaging. The contexts communicate via Kafka or a similar broker. The right answer when the two teams have different data residency, regulatory, or scaling requirements.
Pick the organizational boundary first; the technical topology follows. Enforcing these package boundaries in a modular monolith is the harder problem, and the next post in this series covers the import-linter tooling that helps with this.
Aggregates and aggregate roots
A Bounded Context contains entities and value objects. To maintain integrity, group them into aggregates.
An Aggregate is a cluster of domain objects that you can treat as a single unit for data changes. Every Aggregate has an Aggregate Root (an Entity). The Root is the sole gateway to the aggregate:
- Clients must route all external communication through the Aggregate Root.
- External objects reference only the Aggregate Root’s ID.
- The Root enforces all business invariants (rules that must always be true) within the aggregate boundary.
Vaughn Vernon reframes the aggregate boundary as a scalability knob, not a domain concept. An aggregate is the transaction scope (the set of rows a single database transaction locks).
The Vernon “small aggregates” rule is grounded in lock contention, not in abstract cleanliness. The eventsourcing library (John Bywater) goes further: an aggregate is a command-handler scope, the unit of work that one command handler writes, not a consistency boundary.
The “anemic domain model” anti-pattern is a real risk in Python because @dataclass makes it cheap to write a class with no methods. If your Order has no methods beyond __init__ and __eq__, your domain logic has leaked into a service class. That leaves a 1500-line service class. A better approach would be to put the behavior on the aggregate root.
A typical boundary leakage in standard Python
To explore how to enforce business rules in a standard Python application, consider the task of building an Order aggregate for an e-commerce checkout. The rules of the order are strict: the order can only be paid once, a failed payment must not silently re-charge the customer, and the line items must never exceed available stock.
A naive implementation using a mutable Python class illustrates this:
class NaiveOrder: def __init__(self, order_id: str): self.id = order_id self.status = "PENDING" self.line_items = []Client code easily bypasses validation rules. Because Python lists are mutable, calling order.line_items.append(...) allows external consumers to push arbitrary line items directly into the order, even ones with negative quantities or out-of-stock SKUs. It might seem that wrapping the list inside a custom collection class with validation would protect the boundary.
Second, the codebase suffers from primitive obsession. Developers can accidentally pass CustomerId strings to parameters expecting ProductId strings. Since both variables are plain strings, mypy does not raise any errors, leading to subtle runtime bugs in the test suite.
Securing domain boundaries with modern Python
Declare branded types using NewType and define an immutable Value Object with validation:
from typing import NewTypefrom dataclasses import dataclass
CustomerId = NewType("CustomerId", str)ProductId = NewType("ProductId", str)OrderId = NewType("OrderId", str)
@dataclass(frozen=True, slots=True)class Money: cents: int
def __post_init__(self) -> None: if self.cents < 0: raise ValueError("Money cannot be negative")The NewType wrappers create distinct types at static analysis time, ensuring mypy catches mismatched ID parameters. The frozen=True and slots=True configuration makes the Money class immutable and memory-efficient.
The frozen=True flag only generates a __setattr__ that raises FrozenInstanceError. A vanilla @dataclass(frozen=True) still has a __dict__ that you can access to bypass the guard. With slots=True, the dict is removed. For production-grade immutability, @attrs.frozen and msgspec.Struct have no dict by design.
Next, define the order states:
from enum import StrEnum, auto
class OrderStatus(StrEnum): PENDING = auto() PAID = auto() FAILED = auto() CANCELLED = auto()Use this enumeration to define the aggregate root. The class hides the internal list of line items from external modification, so callers can only change an order through its methods:
from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True)class Order: id: OrderId customer_id: CustomerId total: Money status: OrderStatus = OrderStatus.PENDING _line_items: tuple[ProductId, ...] = ()
@property def line_items(self) -> tuple[ProductId, ...]: return self._line_itemsThe private _line_items field uses a tuple instead of a list. The line_items property exposes this tuple to external consumers. Because tuples are immutable, external clients cannot add or remove line items directly. The boundary holds.
Implement the state transition methods. The aggregate root enforces invariants and returns a new copied instance (note how the method never mutates self):
from typing import Selffrom copy import replace
def mark_as_paid(self) -> Self: if self.status != OrderStatus.PENDING: raise ValueError(f"Cannot pay order in status {self.status}") return replace(self, status=OrderStatus.PAID)
def add_line_item(self, product_id: ProductId, stock_available: int) -> Self: if self.status != OrderStatus.PENDING: raise ValueError("Cannot modify a finalized order") if stock_available <= 0: raise ValueError(f"Out of stock: {product_id}") return replace( self, _line_items=(*self._line_items, product_id), )Self replaces the string forward-reference "Order" and resolves correctly under attrs.frozen and msgspec.Struct subclasses. copy.replace() is the modern stdlib alternative to dataclasses.replace() and works on any class with an __init__, not just @dataclass-decorated ones.
add_line_item enforces the “out-of-stock” invariant at the aggregate boundary. Returning a new instance guarantees atomic, side-effect-free updates. Each transition either completes fully or raises; partial state and torn writes are impossible.
These patterns map closely to Eric Evans’ tactical components 3. Vaughn Vernon 4 Vernon, Vaughn. Implementing Domain-Driven Design (2013) condenses the aggregate design into four rules: enforce business invariants at the Aggregate Root boundary, keep Aggregates small to avoid transaction lock contention, reference other Aggregates by identity rather than by direct object pointers, and coordinate updates across multiple aggregates using eventual consistency.
To prevent leaking the domain model into database schemas or HTTP routers, keep the domain layer pure. Vlad Khononov 5 Khononov, Vlad. Learning Domain-Driven Design: Aligning Software Architecture and Business Strategy (2021) emphasizes separating subdomains to maintain clear context mapping. In a complete application, use the Repository and Unit of Work patterns as described by Harry Percival and Bob Gregory 6 Percival, Harry, & Gregory, Bob. Architecture Patterns with Python: Enabling Test-Driven Development, Domain-Driven Design, and Event-Driven Microservices (2020) to load and persist these aggregates without exposing their internal storage details. The Composition Root pattern is discussed in the Dependency Injection post.
Protecting and validating value objects
stdlib @dataclass(frozen=True, slots=True) is the domain default. It has a small footprint, and supports pickle, copy, and deepcopy. It does not validate types at runtime, which is the right trade-off inside the domain layer where the static checker owns types.
At a wire boundary (an HTTP request body, a Kafka message) the bytes come from outside the type checker, and that is where msgspec.Struct takes over. The same frozen type decodes, validates, and encodes in a single pass, then stays cheap and immutable in the domain. attrs plus cattrs covers the same ground in two libraries; pydantic validates at construction, the wrong place for data your own system already vetted.
The eventsourcing library is the one place the default shifts. It is the only Python-native library I’ve found with an explicit DDD-framed API; the Order aggregate is the right shape whether you store it in SQL or rebuild it from a log of domain events.
The Immutability and Defensive State Design post works through the primitive choice in depth.
When DDD is overkill
A CRUD app with no business logic has no domain. Its “ubiquitous language” is the database schema, its “bounded contexts” are the tables, its “aggregates” are the rows. The DDD overhead (Protocol ports, value objects, repository adapters, command handlers) adds complexity for no benefit.
Use DDD for the parts of your app that have business rules; use CRUD for the parts that don’t. A payment service with “refund window expires after 30 days” has a domain. A user-profile service with first name, last name, email is CRUD. The same codebase can have both: the payment service uses DDD tactical patterns, the user-profile service uses @dataclass(frozen=True, slots=True) and SQLAlchemy Mapped[T] directly, no ports, no aggregates, no Protocol.
Closing thoughts
Immutability in Python domain models is a trade-off. The design improves reliability, but it costs you CPU and memory, especially in a language not designed with these concepts in mind, like Python.
Added complexity
Instantiating new objects and copying tuples for every state change incurs a small overhead. In high-frequency loop operations, this churn can impact performance, though database or network I/O typically bounds standard Python applications.
This approach also requires significant boilerplate compared to standard active record models (such as Django ORM). If the application consists of simple CRUD operations without complex business logic, implementing aggregates and value objects adds unnecessary complexity.
Operational observability
When running this model in production, monitor domain validations. Log all validation failures (such as a ValueError that allocation checks raise). A high volume of these warnings indicates client integration bugs or malicious requests.
logger.warning( "order_validation_failed", order_id=order.id, customer_id=order.customer_id, reason="out_of_stock",)Publish domain events to monitor business throughput. Tracking the ratio of successful payments to failed checkouts provides real-time visibility into store health.
The post’s mark_as_paid() is a direct method call. That is the right pattern within an aggregate and within a Bounded Context. The rule of thumb:
- Inside an aggregate: call directly. Faster, simpler, type-checked.
- Inside a Bounded Context: call the application service (the
OrderProcessorServiceshape), or one command handler per use case. - Across Bounded Contexts: publish domain events. An in-process event bus for a modular monolith, Redis Streams or Kafka for separate services. The
eventsourcinglibrary models these asDomainEventsubclasses with amutate(state, event)projector that rebuilds the aggregate from the log.
References and additional resources
- Conway, Melvin E. How Do Committees Invent? (1968)
- Evans, Eric. Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)
- Fowler, Martin. Refactoring: Improving the Design of Existing Code (2018)
- Khononov, Vlad. Learning Domain-Driven Design: Aligning Software Architecture and Business Strategy (2021)
- Newman, Sam. Building Microservices: Designing Fine-Grained Systems (2021)
- Newman, Sam. Monolith to Microservices: Evolutionary Patterns to Transform Your Monolith (2019)
- Parnas, David L. On the Criteria to Be Used in Decomposing Systems into Modules (1972)
- Percival, Harry, & Gregory, Bob. Architecture Patterns with Python: Enabling Test-Driven Development, Domain-Driven Design, and Event-Driven Microservices (2020)
- Richardson, Chris. Microservices Patterns: With Examples in Java (2018)
- Vernon, Vaughn. Implementing Domain-Driven Design (2013)
- Vernon, Vaughn. Balancing Coupling in Software Design: Universal Design Principles for Architecting Modular Software Systems (2020)