Sunday, September 21, 2025

CQRS (Command Query Responsibility Segregation): Step-by-step Guide + Real-World .NET Example

 Introduction

CQRS (Command Query Responsibility Segregation) is a software design pattern that separates writes (commands) from reads (queries) into distinct models and often into separate data stores. The result: clearer responsibilities, better scaling for read-heavy systems, and a design that naturally fits event-driven and distributed architectures.

This article explains CQRS step-by-step and provides a concrete, real-world example using .NET Core Web APIs, MediatR style handlers, and a simple Angular client pattern. You’ll get code snippets, design decisions, trade-offs, and SEO-friendly headings to paste directly into your blog.


Why CQRS? Quick benefits

  • Scale reads and writes independently — tune each data store for its primary workload.

  • Optimized read models — denormalized views for fast queries (no costly joins).

  • Separation of concerns — simpler handlers for commands and queries.

  • Fits event-driven systems — easy to publish domain events and react with projections.

  • Enables advanced consistency strategies — eventual consistency, sagas for long-running transactions.

Trade-offs: added complexity, extra infrastructure (read DBs, message bus), eventual consistency challenges.


Step-by-step guide to implement CQRS

Step 1 — Evaluate whether you need CQRS

CQRS is not a silver bullet. Use it when:

  • You have heavy read traffic or complex read queries that hurt write performance.

  • You want to scale reads independently or serve different read models for different clients.

  • You need event-driven integrations or audit/history requirements.

Avoid CQRS for simple CRUD apps or small teams where complexity cost outweighs benefits.

Step 2 — Identify Commands and Queries

  • Commands (intent to change state): PlaceOrder, CancelOrder, UpdateInventory.

  • Queries (read-only): GetOrderSummary, ListProducts, SearchOrders.

Commands are usually POST/PUT operations; queries map to GET endpoints.

Step 3 — Design separate models

  • Write model (domain model) — authoritative, normalized, enforces business rules.

  • Read model (projection/view) — denormalized and optimized for queries (often stored in a different database or a different schema/table).

Step 4 — Implement command handlers

Commands go to handlers that validate, apply business logic, and persist to the write database. These handlers can publish domain events (in-process or to a message bus).

Step 5 — Publish domain events

After the write model changes, publish events (OrderPlaced, InventoryReserved) so projection handlers can update read models. Events should contain required projection data (or an aggregate id + version to fetch if necessary).

Step 6 — Build projections (read model updaters)

Projection handlers consume events and update the read store. This is where eventual consistency appears — the read model is updated after the write completes.

Step 7 — Implement query handlers

Query handlers read from the read model and return data shaped for the UI. Very fast — often a single table or precomputed view.

Step 8 — Handle consistency, retries and idempotency

  • Use idempotent event handlers.

  • Track processed event IDs to avoid double-processing.

  • Expose useful read-after-write guarantees (e.g., query the write DB directly for immediate read on the resource just created, or show a transient UI state).

Step 9 — Monitoring, testing and observability

  • Monitor event lag (time between write commit and projection update).

  • Test command handlers, event publication, and projection handlers separately.

  • Use tracing (for example, attach correlation IDs across command → event → projection flows).

Step 10 — Deploy and scale

  • Scale read DBs horizontally or use read replicas.

  • Command side may require stronger consistency and transactional guarantees.

  • Use message brokers (Kafka, RabbitMQ, Azure Service Bus) when crossing process or machine boundaries.


Real-world example: E‑commerce "Place Order" flow

We’ll show a compact example where placing an order is handled through CQRS. Simplified components:

  • Command: PlaceOrderCommand

  • Command Handler: writes Orders in the write DB and publishes OrderPlacedEvent

  • Projection Handler: consumes OrderPlacedEvent and updates OrderSummary table in the read DB

  • Query: GetOrderSummaryQuery reads from read DB (fast, denormalized)

Note: This example uses in-process publishing (MediatR style). In production you may replace in-process events with a durable message bus to ensure cross-process delivery and reliability.

Data model (simplified)

Write DB (normalized) — tables: Orders, OrderItems, Inventory
Read DB (denormalized) — tables: OrderSummaries (flattened, aggregated data optimized for UI)

SQL: write DB schema (simplified)

CREATE TABLE Orders (
Id UNIQUEIDENTIFIER PRIMARY KEY,
CustomerId UNIQUEIDENTIFIER,
TotalAmount DECIMAL(18,2),
Status VARCHAR(50),
CreatedAt DATETIME2,
RowVersion ROWVERSION
);

CREATE TABLE OrderItems (
Id UNIQUEIDENTIFIER PRIMARY KEY,
OrderId UNIQUEIDENTIFIER FOREIGN KEY REFERENCES Orders(Id),
ProductId UNIQUEIDENTIFIER,
Quantity INT,
UnitPrice DECIMAL(18,2)
);

SQL: read DB schema (denormalized)

CREATE TABLE OrderSummaries (
OrderId UNIQUEIDENTIFIER PRIMARY KEY,
CustomerName NVARCHAR(200),
TotalAmount DECIMAL(18,2),
TotalItems INT,
Status VARCHAR(50),
LastUpdated DATETIME2
);

C# — Command, Handler, Event, Projection (MediatR-style)

// DTOs / Commands
order.TotalAmount = order.Items.Sum(i => i.Quantity * i.UnitPrice);

_writeDb.Orders.Add(order);
await _writeDb.SaveChangesAsync(cancellationToken);

// publish domain event (in-process)
await _mediator.Publish(new OrderPlacedEvent(orderId, request.CustomerId, order.TotalAmount, order.Items.Select(it => new { it.ProductId, it.Quantity })), cancellationToken);

return orderId;
}
}

// Domain Event
public record OrderPlacedEvent(Guid OrderId, Guid CustomerId, decimal TotalAmount, IEnumerable<object> Items) : INotification;

// Projection Handler (updates read DB)
public class OrderPlacedProjectionHandler : INotificationHandler<OrderPlacedEvent>
{
private readonly ReadDbContext _readDb;

public OrderPlacedProjectionHandler(ReadDbContext readDb)
{
_readDb = readDb;
}

public async Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken)
{
var summary = new OrderSummary
{
OrderId = notification.OrderId,
CustomerName = "(lookup or cached name)",
TotalAmount = notification.TotalAmount,
TotalItems = notification.Items.Sum(x => (int) x.GetType().GetProperty("Quantity")!.GetValue(x)!),
Status = "Placed",
LastUpdated = DateTime.UtcNow
};

_readDb.OrderSummaries.Add(summary);
await _readDb.SaveChangesAsync(cancellationToken);
}
}

// Query + Handler
public record GetOrderSummaryQuery(Guid OrderId) : IRequest<OrderSummaryDto>;

public class GetOrderSummaryQueryHandler : IRequestHandler<GetOrderSummaryQuery, OrderSummaryDto>
{
private readonly ReadDbContext _readDb;

public GetOrderSummaryQueryHandler(ReadDbContext readDb) => _readDb = readDb;

public async Task<OrderSummaryDto> Handle(GetOrderSummaryQuery request, CancellationToken cancellationToken)
{
var row = await _readDb.OrderSummaries.FindAsync(new object[] { request.OrderId }, cancellationToken);
if (row == null) return null;
return new OrderSummaryDto(row.OrderId, row.CustomerName, row.TotalAmount, row.TotalItems, row.Status);
}
}

Tip: Keep events lightweight and include only data needed to update projections. For bigger payloads, consider storing the full aggregate snapshot or giving the projection handler a reliable way to fetch required details.

API endpoints (example)

  • POST /api/commands/place-order — receives PlaceOrderCommand DTO → returns orderId

  • GET /api/queries/order-summary/{orderId} — returns OrderSummaryDto

Angular (very small snippet)

// order.service.ts
placeOrder(payload: PlaceOrderDto) {
return this.http.post<{ orderId: string }>(`/api/commands/place-order`, payload);
}
getOrderSummary(orderId: string) {
return this.http.get<OrderSummaryDto>(`/api/queries/order-summary/${orderId}`);
}

UI flow: after POST returns orderId, call getOrderSummary(orderId). If projection lag is expected, show a transient "processing" state and retry or use WebSocket notifications when projection completes.

No comments:

Blog Archive

Don't Copy

Protected by Copyscape Online Plagiarism Checker

Pages