TDSoftware
TDSoftware
Innovation Hub
CQRS Pattern: Complete Guide for Developers

CQRS Pattern: Complete Guide for Developers

15 min read

What is CQRS?

CQRS (Command Query Responsibility Segregation) is a design pattern that separates read operations (Queries) from write operations (Commands) into different models. Instead of using one model for both reading and writing data, CQRS divides your application into two distinct parts.

Core Principle:

  • **Commands**: Change the state of the application (Create, Update, Delete)
  • **Queries**: Retrieve data without modifying it (Read operations)

Think of it like a restaurant:

  • **Command side** = Kitchen (prepares orders, changes inventory)
  • **Query side** = Menu display (shows what's available, doesn't change anything)

Why Do We Need CQRS?

Problems with Traditional Architecture

In traditional applications, we use the same model for both reading and writing:

    // Traditional approach - same model for everything
    public class ProductService
    {
        public void CreateProduct(Product product) 
        {
            // Write operation
            _dbContext.Products.Add(product);
            _dbContext.SaveChanges();
        }
        
        public Product GetProduct(int id) 
        {
            // Read operation - same model
            return _dbContext.Products
                .Include(p => p.Category)
                .Include(p => p.Reviews)
                .FirstOrDefault(p => p.Id == id);
        }
    }

Problems:

1. Performance issues - Complex joins slow down reads

2. Scalability challenges - Can't scale reads and writes separately

3. Lock contention - Multiple operations on same data cause conflicts

4. Complex queries - One model trying to serve all purposes becomes complicated

CQRS Architecture

Basic Architecture

    ┌─────────────┐
    │   Client    │
    └──────┬──────┘
        │
        ├─────────► Commands (Write)
        │           ├─ CreateOrder
        │           ├─ UpdateProduct
        │           └─ DeleteUser
        │
        └─────────► Queries (Read)
                    ├─ GetOrderById
                    ├─ SearchProducts
                    └─ GetUserProfile

Two Main Approaches

1. Logical Separation (Same Database)

    ┌──────────────────────────────────┐
    │        Single Database           │
    │  ┌────────────┐  ┌────────────┐ │
    │  │  Commands  │  │  Queries   │ │
    │  │   Logic    │  │   Logic    │ │
    │  └────────────┘  └────────────┘ │
    └──────────────────────────────────┘

2. Physical Separation (Different Databases)

    ┌─────────────┐                 ┌─────────────┐
    │  Command    │    Events       │    Query    │
    │  Database   │ ───────────────►│  Database   │
    │  (Write)    │                 │   (Read)    │
    └─────────────┘                 └─────────────┘

Real-World Example: E-Commerce Order System

Let's build a complete example using C# and MediatR.

Step 1: Install Required Packages

    dotnet add package MediatR
    dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

Step 2: Define Commands

Commands represent actions that change the state.

    using MediatR;

    // Command to create an order
    public class CreateOrderCommand : IRequest<CreateOrderResult>
    {
        public string CustomerId { get; set; }
        public List<OrderItem> Items { get; set; }
        public string ShippingAddress { get; set; }
    }

    public class OrderItem
    {
        public int ProductId { get; set; }
        public int Quantity { get; set; }
        public decimal Price { get; set; }
    }

    public class CreateOrderResult
    {
        public bool Success { get; set; }
        public int OrderId { get; set; }
        public string Message { get; set; }
    }

Step 3: Create Command Handler

The handler contains the business logic for the command.

    public class CreateOrderCommandHandler 
        : IRequestHandler<CreateOrderCommand, CreateOrderResult>
    {
        private readonly IOrderRepository _orderRepository;
        private readonly IInventoryService _inventoryService;
        private readonly IEventPublisher _eventPublisher;

        public CreateOrderCommandHandler(
            IOrderRepository orderRepository,
            IInventoryService inventoryService,
            IEventPublisher eventPublisher)
        {
            _orderRepository = orderRepository;
            _inventoryService = inventoryService;
            _eventPublisher = eventPublisher;
        }

        public async Task<CreateOrderResult> Handle(
            CreateOrderCommand request, 
            CancellationToken cancellationToken)
        {
            try
            {
                // 1. Validate inventory
                foreach (var item in request.Items)
                {
                    var available = await _inventoryService
                        .CheckAvailability(item.ProductId, item.Quantity);
                    
                    if (!available)
                    {
                        return new CreateOrderResult
                        {
                            Success = false,
                            Message = $"Product {item.ProductId} is out of stock"
                        };
                    }
                }

                // 2. Calculate total
                var total = request.Items.Sum(i => i.Price * i.Quantity);

                // 3. Create order entity
                var order = new Order
                {
                    CustomerId = request.CustomerId,
                    OrderDate = DateTime.UtcNow,
                    TotalAmount = total,
                    ShippingAddress = request.ShippingAddress,
                    Status = OrderStatus.Pending,
                    Items = request.Items
                };

                // 4. Save to database
                await _orderRepository.CreateAsync(order);

                // 5. Publish event for query side to update
                await _eventPublisher.PublishAsync(new OrderCreatedEvent
                {
                    OrderId = order.Id,
                    CustomerId = order.CustomerId,
                    TotalAmount = order.TotalAmount,
                    CreatedAt = order.OrderDate
                });

                return new CreateOrderResult
                {
                    Success = true,
                    OrderId = order.Id,
                    Message = "Order created successfully"
                };
            }
            catch (Exception ex)
            {
                // Log error
                return new CreateOrderResult
                {
                    Success = false,
                    Message = $"Error creating order: {ex.Message}"
                };
            }
        }
    }

Step 4: Define Queries

Queries retrieve data without changing anything.

    // Query to get order details
    public class GetOrderByIdQuery : IRequest<OrderDetailsDto>
    {
        public int OrderId { get; set; }
    }

    // DTO for query result - optimized for display
    public class OrderDetailsDto
    {
        public int OrderId { get; set; }
        public string CustomerName { get; set; }
        public string CustomerEmail { get; set; }
        public DateTime OrderDate { get; set; }
        public string Status { get; set; }
        public decimal TotalAmount { get; set; }
        public string ShippingAddress { get; set; }
        public List<OrderItemDto> Items { get; set; }
    }

    public class OrderItemDto
    {
        public string ProductName { get; set; }
        public int Quantity { get; set; }
        public decimal Price { get; set; }
        public decimal Subtotal { get; set; }
    }

Step 5: Create Query Handler

Query handlers fetch data efficiently without business logic.

    public class GetOrderByIdQueryHandler 
        : IRequestHandler<GetOrderByIdQuery, OrderDetailsDto>
    {
        private readonly IReadOnlyDbContext _readDbContext;
        // Or use Dapper for raw SQL queries

        public GetOrderByIdQueryHandler(IReadOnlyDbContext readDbContext)
        {
            _readDbContext = readDbContext;
        }

        public async Task<OrderDetailsDto> Handle(
            GetOrderByIdQuery request, 
            CancellationToken cancellationToken)
        {
            // Direct SQL query for better performance
            var query = @"
                SELECT 
                    o.OrderId,
                    o.OrderDate,
                    o.Status,
                    o.TotalAmount,
                    o.ShippingAddress,
                    c.Name as CustomerName,
                    c.Email as CustomerEmail,
                    oi.ProductName,
                    oi.Quantity,
                    oi.Price,
                    (oi.Quantity * oi.Price) as Subtotal
                FROM Orders o
                INNER JOIN Customers c ON o.CustomerId = c.CustomerId
                INNER JOIN OrderItems oi ON o.OrderId = oi.OrderId
                WHERE o.OrderId = @OrderId";

            // Using Dapper for efficient data retrieval
            using var connection = _readDbContext.CreateConnection();
            
            var orderDictionary = new Dictionary<int, OrderDetailsDto>();
            
            var result = await connection.QueryAsync<OrderDetailsDto, OrderItemDto, OrderDetailsDto>(
                query,
                (order, item) =>
                {
                    if (!orderDictionary.TryGetValue(order.OrderId, out var orderEntry))
                    {
                        orderEntry = order;
                        orderEntry.Items = new List<OrderItemDto>();
                        orderDictionary.Add(order.OrderId, orderEntry);
                    }
                    
                    orderEntry.Items.Add(item);
                    return orderEntry;
                },
                new { OrderId = request.OrderId },
                splitOn: "ProductName"
            );

            return orderDictionary.Values.FirstOrDefault();
        }
    }

Step 6: Setup in API Controller

    [ApiController]
    [Route("api/[controller]")]
    public class OrdersController : ControllerBase
    {
        private readonly IMediator _mediator;

        public OrdersController(IMediator mediator)
        {
            _mediator = mediator;
        }

        // Command endpoint - Write operation
        [HttpPost]
        public async Task<IActionResult> CreateOrder(
            [FromBody] CreateOrderCommand command)
        {
            var result = await _mediator.Send(command);
            
            if (result.Success)
            {
                return Ok(result);
            }
            
            return BadRequest(result.Message);
        }

        // Query endpoint - Read operation
        [HttpGet("{id}")]
        public async Task<IActionResult> GetOrder(int id)
        {
            var query = new GetOrderByIdQuery { OrderId = id };
            var result = await _mediator.Send(query);
            
            if (result == null)
            {
                return NotFound();
            }
            
            return Ok(result);
        }
    }

Step 7: Configure Services

    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            // Add MediatR
            services.AddMediatR(cfg => 
                cfg.RegisterServicesFromAssembly(typeof(Startup).Assembly));
            
            // Add repositories
            services.AddScoped<IOrderRepository, OrderRepository>();
            
            // Add database contexts
            services.AddDbContext<WriteDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("WriteDb")));
            
            services.AddDbContext<ReadDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("ReadDb")));
            
            // Add other services
            services.AddScoped<IInventoryService, InventoryService>();
            services.AddScoped<IEventPublisher, EventPublisher>();
        }
    }

Synchronizing Read and Write Models

Using Event-Driven Communication

    // Event definition
    public class OrderCreatedEvent
    {
        public int OrderId { get; set; }
        public string CustomerId { get; set; }
        public decimal TotalAmount { get; set; }
        public DateTime CreatedAt { get; set; }
    }

    // Event handler to update read model
    public class OrderCreatedEventHandler : INotificationHandler<OrderCreatedEvent>
    {
        private readonly IReadModelRepository _readModelRepository;

        public OrderCreatedEventHandler(IReadModelRepository readModelRepository)
        {
            _readModelRepository = readModelRepository;
        }

        public async Task Handle(
            OrderCreatedEvent notification, 
            CancellationToken cancellationToken)
        {
            // Update the read model (denormalized view)
            var orderView = new OrderReadModel
            {
                OrderId = notification.OrderId,
                CustomerId = notification.CustomerId,
                TotalAmount = notification.TotalAmount,
                CreatedAt = notification.CreatedAt,
                LastUpdated = DateTime.UtcNow
            };

            await _readModelRepository.UpsertAsync(orderView);
        }
    }

Real-World Use Cases

1. E-Commerce Platform

Command Side:

  • Process orders
  • Update inventory
  • Handle payments

Query Side:

  • Display product catalogs
  • Show order history
  • Generate reports

2. Banking System

Command Side:

  • Transfer money
  • Create accounts
  • Process transactions

Query Side:

  • View account balance
  • Display transaction history
  • Generate statements

3. Social Media Platform

Command Side:

  • Create posts
  • Add comments
  • Like/share content

Query Side:

  • Display news feed
  • Search posts
  • Show user profiles

Best Practices

✅ Do's

1. Keep Commands Simple

    // Good - Clear intent
    public class ActivateUserCommand : IRequest<bool>
    {
        public int UserId { get; set; }
    }

    // Avoid - Too generic
    public class UpdateUserCommand : IRequest<bool>
    {
        public int UserId { get; set; }
        public Dictionary<string, object> Changes { get; set; }
    }

2. Optimize Query Models

    // Good - Denormalized for fast reads
    public class ProductListDto
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public string CategoryName { get; set; } // Denormalized
        public int ReviewCount { get; set; } // Pre-calculated
        public double AverageRating { get; set; } // Pre-calculated
    }

3. Use Async Communication

    // Good - Non-blocking
    public async Task<Result> Handle(
        UpdateInventoryCommand request, 
        CancellationToken cancellationToken)
    {
        await _repository.UpdateAsync(request);
        
        // Publish event asynchronously
        await _eventBus.PublishAsync(new InventoryUpdatedEvent
        {
            ProductId = request.ProductId,
            NewQuantity = request.Quantity
        });
        
        return Result.Success();
    }

❌ Don'ts

1. Don't Mix Responsibilities

    // Bad - Query shouldn't modify data
    public class GetUserQuery : IRequest<UserDto>
    {
        public int UserId { get; set; }
        public bool UpdateLastAccess { get; set; } // Wrong!
    }

2. Don't Over-Normalize Read Models

    // Bad - Too many joins needed
    public class OrderDto
    {
        public int OrderId { get; set; }
        public int CustomerId { get; set; } // Requires join
        public int ProductId { get; set; } // Requires join
        // Should include denormalized data instead
    }

3. Don't Use CQRS for Simple CRUD

    // Overkill for simple operations
    public class GetUserByIdQuery : IRequest<User>
    {
        public int Id { get; set; }
    }

    // Just use:
    var user = await _dbContext.Users.FindAsync(id);

When to Use CQRS

✅ Good Fit

1. Complex Domain Logic

  • Multiple business rules
  • Different validation for reads and writes

2. High Read/Write Ratio

  • Many more reads than writes
  • Need to scale independently

3. Performance Critical

  • Need optimized queries
  • Different scaling requirements

4. Collaborative Systems

  • Multiple users working on same data
  • Need to prevent conflicts

❌ Not Suitable

1. Simple CRUD Applications

  • Basic create/read/update/delete
  • No complex business logic

2. Low Traffic Applications

  • Small user base
  • No scaling needs

3. Strict Consistency Requirements

  • Banking transactions (some scenarios)
  • Real-time critical systems

Common Pitfalls and Solutions

Pitfall 1: Eventual Consistency Issues

Problem:

    // User creates order
    await _mediator.Send(new CreateOrderCommand { ... });

    // Immediately try to read it - might not be there yet!
    var order = await _mediator.Send(new GetOrderByIdQuery { OrderId = newId });
    // order might be null due to replication lag

Solution:

    // Return the created data directly from command
    public class CreateOrderResult
    {
        public bool Success { get; set; }
        public int OrderId { get; set; }
        public OrderDetailsDto Order { get; set; } // Include full details
    }

    // Or use correlation IDs to track sync status
    public class CreateOrderCommand : IRequest<CreateOrderResult>
    {
        public Guid CorrelationId { get; set; } = Guid.NewGuid();
    }

Pitfall 2: Over-Engineering

Problem: Using CQRS for everything, even simple operations.

Solution: Use CQRS only where it adds value:

    // Simple operations - skip CQRS
    public class UserService
    {
        public async Task<User> GetByIdAsync(int id)
        {
            return await _context.Users.FindAsync(id);
        }
    }

    // Complex operations - use CQRS
    public class GetUserDashboardQuery : IRequest<DashboardDto>
    {
        public int UserId { get; set; }
    }

Pitfall 3: Neglecting Error Handling

Problem: Not handling synchronization failures.

Solution:

    public class OrderCreatedEventHandler : INotificationHandler<OrderCreatedEvent>
    {
        private readonly ILogger<OrderCreatedEventHandler> _logger;
        private readonly IReadModelRepository _repository;

        public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
        {
            try
            {
                await _repository.UpsertAsync(notification);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to update read model for order {OrderId}", 
                    notification.OrderId);
                
                // Implement retry logic or dead letter queue
                throw; // Re-throw to trigger retry mechanism
            }
        }
    }

Benefits and Challenges

Benefits

Independent Scaling - Scale reads and writes separately

Optimized Performance - Different models for different needs

Clear Separation - Easier to understand and maintain

Flexible Data Models - Use best database for each side

Better Security - Control access to write operations

Challenges

Increased Complexity - More code and infrastructure

Eventual Consistency - Data may be temporarily out of sync

Learning Curve - Team needs to understand the pattern

More Infrastructure - May need message queues, multiple databases

Summary

CQRS is a powerful pattern for building scalable, high-performance applications. Key takeaways:

1. Separate reads from writes for better optimization

2. Use commands for state changes with business logic

3. Use queries for data retrieval without side effects

4. Apply selectively - not every application needs CQRS

5. Handle eventual consistency with proper UI/UX design

6. Start simple with logical separation before physical separation

Remember: CQRS is a tool, not a silver bullet. Use it when the benefits outweigh the added complexity.

Additional Resources

  • Microsoft Docs: CQRS Pattern
  • Greg Young: CQRS Documents
  • Martin Fowler: CQRS Pattern
  • MediatR Documentation

Related Posts

No image
Test Post