Hello! Welcome back to our module on State Management with Event Sourcing.
In our previous lesson, we established the "what" and "why" of Event Sourcing, contrasting its history-centric approach with traditional state-oriented persistence. We saw that instead of storing the current state, we store an immutable log of events, which becomes our source of truth.
Today, we move from theory to practice. Our goal is to address the learning outcome: "Design aggregate roots and domain events for a financial scenario from your experience (e.g., FX trade lifecycle)." We will learn how to take a business process and model it using two fundamental building blocks from Domain-Driven Design (DDD): Aggregates and Domain Events. Given your background, we will use the lifecycle of an FX trade as our practical example, turning a familiar workflow into a robust, event-sourced model.
1. The Building Blocks: Aggregates and Domain Events
Event Sourcing doesn't exist in a vacuum; it's a pattern that works best within the context of Domain-Driven Design. DDD is an approach to software development that centers on creating a rich, expressive model of the underlying business domain. Two of its most important concepts for our purposes are Domain Events and Aggregates.
Let's start with a short video that provides a clear, visual introduction to these concepts.
This video from the 'Drawing Boxes' channel introduces the key building blocks of DDD that we'll be using today.
Please watch the sections on Entities, Domain Events, and Aggregate Roots (timestamps 01:32 - 03:44). Focus on: The definition of a Domain Event as an immutable record of something important that has happened. The role of the Aggregate Root as a single entry point for a cluster of related objects. The concept of a 'consistency boundary' that the aggregate enforces.
As the video explains, these concepts give us a structured way to think about our system:
- Domain Events: These are the heart of our event-sourced system. They are immutable facts about things that have occurred in the business domain. They are named in the past tense (e.g.,
OrderPlaced,PaymentProcessed) and contain all the relevant data about what happened. - Aggregates: An aggregate is a cluster of domain objects (entities and value objects) that can be treated as a single unit. Its purpose is to enforce business rules, or invariants, within a defined consistency boundary. The Aggregate Root is a specific entity within the aggregate that serves as the sole entry point for any command that modifies the aggregate's state.
In short: external clients issue commands to the Aggregate Root. The Aggregate Root validates these commands against its current state and business rules. If a command is valid, the Aggregate Root produces one or more Domain Events, which are then persisted.
2. Principles of Good Aggregate Design
Designing aggregates correctly is crucial for a maintainable and scalable system. A poorly designed aggregate can lead to performance bottlenecks or, worse, data inconsistency.
The following video goes deeper into the principles of effective aggregate design.
How to design great Aggregate Roots in Domain-Driven Design
The concept of the aggregate is central to maintaining data integrity in a complex system. This video by Milan Jovanović offers excellent guidelines on how to design them effectively.
Please watch the sections explaining the purpose of aggregates and the guidelines for designing them (timestamps 00:10 - 02:16 and 04:06 - 07:20). Focus on: The two primary reasons for using aggregates: guaranteeing consistency and enforcing business invariants. The key design rules: protect invariants, keep aggregates small, and reference other aggregates only by their ID. The idea of using domain events to achieve eventual consistency between aggregates.
Let's distill the key principles from the video:
- Protect Business Invariants: This is the aggregate's primary responsibility. An invariant is a rule that must always be true. For example, in a trading system, an invariant might be "the quantity of a booked trade can never be negative" or "a settled trade cannot be modified." The aggregate's logic must prevent any operation that would violate these rules.
- Enforce Transactional Consistency: All changes within a single aggregate are atomic. When you save an aggregate, all its changes are committed in a single transaction, or none are. This prevents the system from ever observing the aggregate in an intermediate, invalid state.
- Keep Aggregates Small: Large aggregates that encompass too many objects are a common anti-pattern. They increase the likelihood of concurrent modification conflicts and can become performance bottlenecks, as the entire aggregate must be loaded into memory to process any command.
- Reference Other Aggregates by ID: An aggregate should not hold a direct object reference to another aggregate. Instead, it should only store the other aggregate's unique ID. This enforces strong boundaries and decoupling between different parts of your domain. Communication between aggregates happens asynchronously via domain events (achieving eventual consistency).
3. Practical Design: The FX Trade Lifecycle
Now, let's apply these principles to a scenario from your own experience: the lifecycle of an FX spot trade. We'll design the FXTrade aggregate.
First, let's visualize what an aggregate boundary looks like.

Step 1: Identify the Aggregate Root and Boundary
In the FX trading domain, the most natural unit of consistency is the trade itself. A single trade has a clear lifecycle, a set of associated data (currency pair, amount, rate, etc.), and business rules that apply to it. Therefore, FXTrade is an excellent candidate for our Aggregate Root.
The aggregate boundary will contain the FXTrade entity itself, along with any internal objects that don't have a global identity, such as a list of settlement instruction steps or fee calculations. For our purposes, we'll focus on the root entity.
Step 2: Define the Domain Events
What are the key business moments in the life of an FX trade? Each of these will become a domain event. Let's map out a simplified lifecycle:
- A user requests to execute a trade. ->
FXTradeInitiated - The trade is booked against a quoted rate. ->
FXTradeBooked - The trade details are confirmed (e.g., via a matching service or direct confirmation). ->
FXTradeConfirmed - Instructions are sent to the settlement system. ->
SettlementInstructed - The trade is successfully settled. ->
FXTradeSettled - An error occurs during the process. ->
FXTradeFailed
This approach of modeling a process as a series of lifecycle events is a standard industry practice. For instance, the FINOS Common Domain Model (CDM), which you can explore in resource 07d15, defines standardized events for financial products like repos and bonds, including events like ReRate, Repricing, and CollateralSubstitution. This validates our event-driven approach as being aligned with modern financial technology architecture.
Step 3: Design the Event Payloads
Events must be self-contained and carry all the necessary data to understand what happened. Let's define the data for a couple of our key events.
-
FXTradeBooked:trade_id: The unique identifier for the trade.timestamp: When the event occurred.currency_pair: e.g., "EUR/USD".dealt_currency: e.g., "EUR".dealt_amount: The amount of the dealt currency.counter_currency: e.g., "USD".counter_amount: The calculated amount of the counter currency.rate: The exchange rate used.value_date: The date of settlement.counterparty_id: The identifier for the other party in the trade.
-
FXTradeSettled:trade_id: The unique identifier for the trade.timestamp: When the settlement was confirmed.settlement_date: The actual date of settlement.settlement_reference: A reference number from the settlement system.
Before we finalize the design, it's worth reviewing some best practices for designing events.
Mastering CQRS and Event Sourcing in .NET 8
When designing our events, there are some important best practices to follow. This article, 'Mastering CQRS and Event Sourcing in .NET 8', provides a concise list in its section on event design.
Please read the section 'Event Design Best Practices'. Focus on the four key principles: immutability, meaningful names, rich context, and designing for schema evolution.
Step 4: Design the Aggregate Root's State and Behavior
The FXTrade aggregate root needs to hold just enough state to enforce its invariants and make decisions. It also exposes methods that correspond to the commands it can process.
State:
id: The trade's unique ID.status: The current stage in the lifecycle (e.g.,INITIATED,BOOKED,CONFIRMED,SETTLED).version: An integer that increments with each new event, used for optimistic concurrency control (which we'll cover later).
Behavior (Commands):
The aggregate exposes methods that represent business actions. These methods contain the core business logic and, if successful, produce events.
Here is some Python-esque pseudo-code illustrating the FXTrade aggregate:
# --- Domain Events (Data Classes) ---
# class FXTradeInitiated: ...
# class FXTradeBooked: ...
# class FXTradeSettled: ...
class FXTrade:
def __init__(self):
# State is initialized by applying events
self.id = None
self.status = None
self.version = 0
self._uncommitted_events = []
# --- Command Methods ---
@staticmethod
def initiate(trade_id, currency_pair, dealt_amount, dealt_currency):
"""Factory method to create a new trade."""
trade = FXTrade()
# Invariant: A new trade must have a positive amount.
if dealt_amount <= 0:
raise ValueError("Trade amount must be positive.")
event = FXTradeInitiated(trade_id, currency_pair, dealt_amount, dealt_currency)
trade._apply(event)
return trade
def book(self, rate, counterparty_id, value_date):
"""Books the trade at a given rate."""
# Invariant: A trade can only be booked if it's in the 'INITIATED' state.
if self.status != 'INITIATED':
raise InvalidStateException("Trade must be INITIATED to be booked.")
# Other rules... e.g., check rate validity, value_date is not in the past.
event = FXTradeBooked(self.id, rate, counterparty_id, value_date, ...)
self._apply(event)
def settle(self, settlement_ref):
"""Marks the trade as settled."""
# Invariant: A trade must be 'CONFIRMED' to be settled.
if self.status != 'CONFIRMED':
raise InvalidStateException("Trade must be CONFIRMED to be settled.")
event = FXTradeSettled(self.id, settlement_ref)
self._apply(event)
# --- State Mutation Method ---
def _apply(self, event):
"""Applies an event to mutate the aggregate's state."""
if isinstance(event, FXTradeInitiated):
self.id = event.trade_id
self.status = 'INITIATED'
elif isinstance(event, FXTradeBooked):
self.status = 'BOOKED'
# other state updates...
elif isinstance(event, FXTradeSettled):
self.status = 'SETTLED'
self.version += 1
self._uncommitted_events.append(event)
def get_uncommitted_events(self):
return self._uncommitted_events
Notice the clear separation of concerns:
- Command methods (
book,settle) contain the business logic and validation (the invariants). They decide if something can happen. - The private
_applymethod is responsible for actually changing the state. It's purely a state transition function based on an event that has already been decided upon. It answers "what is the new state given that this event happened?"
Conclusion
In this lesson, we transitioned from the theory of event sourcing to the practical design of its core components. We used concepts from Domain-Driven Design to model a real-world financial process.
Key Takeaways:
- Domain Events are the immutable, factual records of your business process. They are the source of truth in an event-sourced system.
- Aggregates act as consistency boundaries, grouping related objects and protecting business rules (invariants) through a single point of entry—the Aggregate Root.
- The design process involves identifying the aggregate, modeling its lifecycle as a series of domain events, defining the data each event carries, and implementing the business logic within the aggregate root's command methods.
- A key pattern is to separate command validation logic from state transition logic. Commands validate rules and produce events; a dedicated
applymethod mutates state based on those events.
Preview of the Next Lesson:
We've now designed our FXTrade aggregate and the events it produces. We've also sketched out an _apply method. But how do we load a trade that already has a long history of events? In our next lesson, we will tackle the learning outcome: "Implement event replay to reconstruct the current state of an aggregate from its historical event stream." We will take the _apply logic we designed today and use it to "replay history," bringing any aggregate to its current state.
Can't find a good explanation? Sign up and we'll make it for you
Sign up