Showing posts with label Command Query Responsibility Segregation. Show all posts
Showing posts with label Command Query Responsibility Segregation. Show all posts

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.

Blog Archive

Don't Copy

Protected by Copyscape Online Plagiarism Checker

Pages