Hello! Welcome back to your course on Domain-Driven Design.
In our last lesson, we designated the Aggregate Root as the sole gatekeeper for an entire Aggregate. We established that all external requests to modify any part of the Aggregate must go through the Root. This design choice is fundamental to protecting the integrity of our domain model.
But what does this gatekeeper actually do? What logic should its methods contain? That's our focus today.
Introduction
This lesson is about embedding the "brains" of your domain into your Aggregates. Our learning outcome is to: Encapsulate business rules and invariants within an Aggregate Root's methods.
We will explore:
- What invariants are and why they are the heart of your domain logic.
- How the Aggregate Root is the perfect place to enforce these rules.
- Practical Python techniques for implementing methods that protect your Aggregate's consistency.
By the end of this lesson, you'll know how to transform your Aggregates from simple data containers into powerful, behavior-rich objects that actively enforce the rules of your business domain.
1. The Core of the Domain: Invariants
Before we can enforce rules, we need to understand what they are. In DDD, we have a specific term for the most critical business rules: invariants.
An invariant is a condition or rule that must always be true for an Aggregate at the end of any operation (or transaction). If an operation would cause an invariant to be violated, the operation must be rejected.
7. Aggregates and Consistency Boundaries
The book 'Cosmic Python' provides a clear distinction between constraints and invariants. Let's start there to build a solid definition.
Read the first few sections, starting from 'Why Not Just Run Everything in a Spreadsheet?' down to 'Invariants, Concurrency, and Locks'. Focus on the definitions of constraints and invariants, and the examples provided (e.g., negative salary, double-booking a hotel room).
To summarize the key idea from the reading:
- Constraint: A rule that restricts possible states (e.g., "double bookings are not allowed").
- Invariant: A condition that is always true as a result of those constraints (e.g., "a room has at most one booking per night").
The primary job of an Aggregate Root is to protect its invariants. It acts as a consistency boundary, ensuring that no matter what happens, the cluster of objects it manages is always in a valid, consistent state after every change.
This next video reinforces this idea, framing it as the most important concept to grasp about Aggregates.
How to design great Aggregate Roots in Domain-Driven Design
In 'How to design great Aggregate Roots in Domain-Driven Design', Milan Jovanović emphasizes that protecting invariants is the primary purpose of an Aggregate.
Watch the short segment from 05:38 to 06:18. Pay close attention to the statement that Aggregates are a 'consistency boundary' where invariants must always be true.
2. Implementing Invariants in the Aggregate Root
Now let's move from theory to practice. How do we write code that enforces these rules? The answer is to place the logic directly inside the Aggregate Root's methods. These methods become more than simple setters; they become guardians of the Aggregate's state.
Example 1: A Rule Spanning Multiple Objects
An invariant often involves checking the state of multiple objects within the Aggregate. The individual objects (Entities or Value Objects) might not have enough context to enforce the rule on their own. This is where the Root's authority is essential.
A classic example is a Shipment with multiple Stops (e.g., pickup, delivery).
- Invariant: A delivery cannot be marked as 'arrived' before the corresponding pickup has been marked as 'departed'.
The Delivery Stop object doesn't know the status of the Pickup Stop. Only the Shipment Aggregate Root has the full picture.
Aggregate Design: Using Invariants as a Guide
The video 'Aggregate Design: Using Invariants as a Guide' by CodeOpinion provides an excellent walkthrough of this exact scenario.
Watch from 02:36 to 06:03. Notice how the Shipment (the Aggregate Root) contains the logic to check the status of other stops before allowing a state change. It's the central coordinator that enforces the business rule.
Example 2: Refactoring to Encapsulate Logic
A common mistake is to put business logic in the Application Service layer (the layer that orchestrates use cases). This breaks encapsulation because it means the Aggregate Root is no longer in full control of its own rules.
The correct approach is to push this logic down into the domain model, specifically into the Aggregate Root's methods.
How to design great Aggregate Roots in Domain-Driven Design
Let's watch a practical refactoring exercise. Here, logic for removing an item from a collection is moved from a 'use case' into the Aggregate Root, where it belongs.
Watch the segment from 07:20 to 11:14. The code is in C#, but the principle is universal. Observe how the remove_exercise method is changed. Initially, the use case finds the exercise and passes the object to the root. After refactoring, the use case simply passes an ID, and the root is responsible for finding and removing the exercise, thereby fully encapsulating the operation.
This refactoring is a perfect example of today's learning outcome. The Aggregate Root's method now encapsulates the entire business rule: "To remove an exercise, it must first exist within this workout."
3. A Python Pattern for Non-Trivial Commands
When a command method involves conditional logic (checking invariants), we need a clean way to structure it, especially when using patterns like Event Sourcing. A powerful pattern is to have a public command method that performs checks and then calls a private, event-triggering method to apply the state change.
Let's look at an Order aggregate with the following invariant:
- Invariant: An order cannot be picked up until it has been confirmed.
from datetime import datetime
from eventsourcing.domain import Aggregate, event
class Order(Aggregate):
def __init__(self, name):
self.name = name
self.confirmed_at = None
self.pickedup_at = None
# This is a simple command that always succeeds
@event("Confirmed")
def confirm(self, at: datetime):
"""Applies the state change. The @event decorator turns this
into an event-triggering and state-mutating method."""
self.confirmed_at = at
# This is the public-facing command method
def pickup(self, at: datetime):
"""This method contains the business logic and enforces the invariant."""
# 1. Enforce the invariant
if self.confirmed_at is None:
raise AssertionError("Order is not confirmed")
# 2. If the check passes, call the private method to trigger the event
self._pickup(at)
# This is the private method that applies the state change
@event("PickedUp")
def _pickup(self, at: datetime):
"""This method's body is only concerned with the state change itself."""
self.pickedup_at = at
# --- How it's used ---
order = Order("my order")
# This will fail because the invariant is not met
try:
order.pickup(datetime.now())
except AssertionError as e:
print(e) # "Order is not confirmed"
# Now, let's meet the condition
order.confirm(datetime.now())
# This will now succeed
order.pickup(datetime.now())
assert order.pickedup_at is not None
This pattern cleanly separates the decision-making logic (in pickup) from the state transition logic (in _pickup). The public pickup method acts as the guard, while the private _pickup method, decorated with @event, handles the mechanics of creating an event and applying the change.
Domain models — eventsourcing 9.4.6 documentation
The eventsourcing library documentation, which we've used before, provides a detailed explanation of this exact pattern.
Read the section 'Non-trivial command methods'. It walks through the same Order example, explaining why this separation of concerns is so effective for implementing complex business rules.
Conclusion
Today, we moved beyond the structure of Aggregates to their behavior. We've seen that the core responsibility of an Aggregate Root is to protect the business rules—the invariants—that define a consistent and valid domain model.
Key Takeaways:
- Invariants are business rules that must always be true for an Aggregate after any operation.
- The Aggregate Root is the sole guardian of these invariants. It acts as a consistency boundary.
- Business logic and validation checks should be encapsulated within the Aggregate Root's public methods.
- These methods should reject any operation that would violate an invariant, often by raising an exception.
- A clean pattern in Python is to use a public method for validation and a private, event-triggering method for the actual state change.
Preview of the Next Lesson:
We now have a rich domain model with objects that enforce their own rules. But these objects only exist in memory. How do we save them to a database and retrieve them later, without letting database concerns leak into our pristine domain model?
In the next lesson, we will introduce a new pattern to solve this problem: Explain the role of the Repository pattern in abstracting persistence logic from the domain model.