Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to the course.
In our previous lesson, we conducted a thorough analysis of the CQRS pattern, establishing a framework for when its benefits—like independent scaling and optimized data schemas—justify its inherent complexity. We concluded that CQRS is a strategic choice, not a default architecture.
Today, we transition from the "why" to the "how." This lesson directly addresses the learning outcome: Design separate write (command) and read (query) models for the financial scenario modeled in the previous module.
We will take the theoretical separation of commands and queries and make it concrete. Using a familiar financial scenario, we will design the data structures and models for both the write side, which prioritizes consistency and business rule enforcement, and the read side, which is tailored for high-performance, specific queries. This process is fundamental to building a scalable and maintainable distributed system.
1. The Scenario: An FX Trade Lifecycle
To ground our design, let's use a scenario you'll be very familiar with from your time at Revolut: the lifecycle of a retail FX trade.
Imagine a simplified workflow:
- A user requests a quote for a currency pair (e.g., GBP/USD).
- The system provides a quote that is valid for a short period.
- The user accepts the quote, triggering a trade execution command.
- The system executes the trade, records it, and schedules it for settlement.
- Later, the trade is settled.
This workflow generates different operational needs (commands) and informational needs (queries) for various actors:
-
Commands (Write Operations): These are intent-driven actions that change the state of the system.
ExecuteTrade: The core command to lock in a trade based on an accepted quote.SettleTrade: A command, perhaps triggered by a batch process, to mark a trade as settled.
-
Queries (Read Operations): These are requests for information that do not change state. Notice how different users need different "views" of the same underlying data.
GetTradeDetails: A user wants to see the full details of a specific trade they made.ListUserTradeHistory: A user wants to see a summary list of all their past trades.GetOpenPositions: An internal trading desk needs a real-time, aggregated view of the firm's net exposure for each currency pair.FindTradeForAudit: A compliance officer needs to retrieve a trade by its ID for auditing purposes.
The fundamental challenge that CQRS addresses is that a single data model is ill-suited to serve both the transactional ExecuteTrade command and the varied, high-volume query needs efficiently.
2. Designing the Write Model
The write model's primary responsibility is to be the single source of truth. It must rigorously enforce all business invariants and ensure the system's state transitions are always valid. It is optimized for transactional consistency, not for querying.
In an Event Sourcing context (which we covered in Module 5), the write model is the aggregate. The aggregate processes commands, validates them against its current state, and produces events if the command is successful.
Let's define our Trade aggregate:
- State: The aggregate only needs to hold the minimal state required to make decisions. For a trade, this might include:
tradeId: Unique identifier.status: (e.g.,Executed,Settled).currencyPair,amount,executedPrice.userId.version: For optimistic concurrency control.
- Commands: These are the messages that instruct the aggregate to do something.
ExecuteTrade(tradeId, userId, currencyPair, amount, price, quoteId)
- Events: These are the records of what happened. They are the source of truth.
TradeExecuted(tradeId, userId, currencyPair, amount, price, executedAt)TradeSettled(tradeId, settledAt, confirmationNumber)
The write model is designed to be robust. As discussed in the Azure architecture article, it is focused on handling commands, validation, and domain logic.
CQRS Pattern - Azure Architecture Center
To reinforce the characteristics of a write model, let's revisit the 'CQRS Pattern' article from Microsoft's Azure Architecture Center.
Please re-read the first bullet point under the 'Separate models in a single data store' section, which describes the 'write model'. Focus on its role in handling commands, validation, and ensuring data consistency.
This write model, with its rich behavior and focus on consistency, is often difficult to query directly. For example, to get a user's trade history, you would have to load every single Trade aggregate for that user, which is highly inefficient. This is where read models come in.
3. Designing the Read Models
Read models are the other half of CQRS. Their sole purpose is to serve specific queries as efficiently as possible. They are projections of the events generated by the write model, often denormalized and tailored to a particular UI component or API endpoint. A single aggregate (like Trade) can be the source for many different read models.
Let's design the read models needed for our FX scenario.

Read Model 1: TradeDetailsView
This model serves the GetTradeDetails query, for when a user clicks on a trade to see everything about it.
- Purpose: Provide a complete, user-friendly view of a single trade.
- Storage: Could be a single row in a SQL table or a document in a document database, indexed by
tradeId. - Schema:
{ "trade_id": "...", "user_id": "...", "executed_at": "2023-10-27T10:00:05Z", "settled_at": "2023-10-29T15:30:00Z", "currency_pair": "GBP/USD", "direction": "BUY", "amount_gbp": 1000.00, "executed_price": 1.2150, "total_cost_usd": 1215.00, "status": "Settled", "confirmation_number": "..." } - Design Notes:
- Denormalized: Fields like
directionandtotal_cost_usdare calculated when the model is created/updated, not at query time. - Tailored: The field names are clear and ready for display. The data types are simple primitives.
- Denormalized: Fields like
The following video provides a practical demonstration of creating such a simple, flat read model.
The INSANE Performance Boost Of CQRS Read Models
This video from Milan Jovanović, 'The INSANE Performance Boost Of CQRS Read Models', shows how to define a simple read model and contrasts it with the more complex write model.
Watch the section 'Defining a Read Model' (00:00 - 01:37). Notice how the OrderSummary read model contains only the essential, pre-calculated data needed for a specific query, making it simple and efficient.
Read Model 2: UserTradeHistoryItem
This model serves the ListUserTradeHistory query. The key here is that a list view doesn't need all the details. We want a lightweight object to minimize payload size and database load.
- Purpose: Display a summary list of trades for a user.
- Storage: A SQL table indexed by
user_idandexecuted_at. - Schema:
{ "trade_id": "...", "executed_at": "2023-10-27T10:00:05Z", "currency_pair": "GBP/USD", "summary": "Bought 1,000 GBP", "status": "Settled" } - Design Notes:
- Optimized for Lists: Contains only the data needed for a list item. The
summaryfield is pre-formatted on the write-to-read-model path to save client-side logic.
- Optimized for Lists: Contains only the data needed for a list item. The
Read Model 3: TradingDeskPositionView
This model serves the GetOpenPositions query for an internal trading desk. This is a completely different shape of data; it's an aggregation, not a direct representation of a single trade.
- Purpose: Provide a real-time aggregated view of net currency exposure.
- Storage: Could be a Redis hash or a simple SQL table indexed by
currency_pair. - Schema:
{ "currency_pair": "GBP/USD", "net_position": -550250.75, // Net amount of base currency (GBP) "total_volume_usd": 150300000.00, "last_updated": "2023-10-27T12:30:15Z" } - Design Notes:
- Aggregated: This model is not updated on a 1-to-1 basis with
TradeExecutedevents. Instead, each event causes a read-modify-write operation on this model (e.g.,net_position += trade.amount). - Different Consumer: This model is for an internal system, not the end-user. Its design is driven by the needs of the trading desk.
- Aggregated: This model is not updated on a 1-to-1 basis with
This video goes into more detail on the technical separation of read and write models, including using different database contexts and optimizations.
Using Separate Read/Write Models with EF Core and CQRS
This follow-up video, 'Using Separate Read/Write Models with EF Core and CQRS', provides a deeper look at the structural differences between read and write models.
Watch the sections 'Creating the Read Database Context and Models' (04:22 - 05:58) and 'Configuring Read Model Relationships and Query Behavior' (05:58 - 08:39). Observe how the read models use simple primitive types and how the read database context is configured for performance (e.g., 'NoTracking').
4. The Bridge: Connecting Write to Read
We have designed two separate worlds: the write model, which is a stream of events, and the read models, which are query-optimized tables or documents. How do we connect them?
As briefly mentioned, this is done via a process often called a projection or projector. A projection is a consumer that:
- Subscribes to the stream of events from the write model (e.g.,
TradeExecuted,TradeSettled). - Processes each event.
- Updates the relevant read model(s).
For example, when a TradeExecuted event occurs:
- A projection for
TradeDetailsViewwould create a new document/row. - A projection for
UserTradeHistoryItemwould create a new summary row. - A projection for
TradingDeskPositionViewwould update the existing row for that currency pair.
This update process is asynchronous, which leads to the eventual consistency we discussed in the last lesson.
The Confluent article provides a good description of this process.
Command Query Responsibility Segregation (CQRS)
The 'Command Query Responsibility Segregation (CQRS)' article from Confluent explains how events form the bridge between the write and read models.
Please read the section 'Events and Event Sourcing'. Focus on how it describes a separate process consuming the event log to create and update the read models (projections).
Conclusion
In this lesson, we have moved CQRS from an abstract pattern to a concrete design. We took a familiar financial scenario and designed the distinct models required to implement it in a scalable way.
Key Takeaways:
- Write Model Design: The write model (aggregate) is designed for transactional integrity and business rule enforcement. It holds the minimal state necessary to validate commands and produces events as its output.
- Read Model Design: Read models are designed for specific query use cases. They are denormalized, optimized for read performance, and can have varied structures (detailed views, list items, aggregations) derived from the same event stream.
- One-to-Many Relationship: A single business concept, represented by one write model (e.g.,
Tradeaggregate), often serves as the source for multiple, distinct read models tailored to different consumers and use cases. - Design is Driven by Use Case: The structure of each model is dictated by its job. The write model's structure is determined by business invariants, while a read model's structure is determined by the API response or UI component it needs to serve.
Preview of the Next Lesson:
We have now designed what our read models should look like. The next logical step is to build the mechanism that creates and maintains them. In the next lesson, we will implement a projection that consumes an event stream to build and maintain a denormalized read model, bringing the designs we created today to life.