Software Architecture

CQRS Pattern

In our quest to make software more versatile, maintainable, and scalable, we sometimes forget that we're also making it harder to explain to our future selves. CQRS is a perfect example. When you're deep in architectural discussions, sketching diagrams on whiteboards, and solving complex scalability problems, separating reads and writes into distinct models feels elegant and obvious. Six months later, debugging at 2 AM, that same separation can feel like unnecessary complexity.

The irony is real: we adopt CQRS to make our systems more maintainable and flexible in the long run, yet we often create codebases that require extensive documentation and tribal knowledge to understand. Moving parts—commands, events, handlers, and projections—mean that even a simple change can ripple across the system, demanding careful coordination.

This is not to say you shouldn't use CQRS—it's genuinely powerful when applied correctly. Think of it like separating your pantry and fridge: for a large kitchen with hundreds of ingredients, it makes everything organized and efficient. For a small setup with just a few cans of soup, it's overkill. Use CQRS when the problem truly demands it, not because it sounds impressive in technical conversations or because you want your system to feel "enterprise-grade."

For instance: A high-volume payment processing system handling millions of transactions daily, where users constantly check balances but rarely transfer money, is an ideal CQRS candidate. A simple blog CMS where you create and display posts? Traditional CRUD will serve you better.

Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates read and write operations into different models. Unlike traditional CRUD applications where the same model handles both reads and writes, CQRS uses distinct models optimized for their specific purposes.

In CQRS:

  1. Commands modify state (writes) - e.g., ProcessPayment, CreateAccount
  2. Queries retrieve state (reads) - e.g., GetTransaction, GetAccountStatement
  3. Events communicate state changes between the command and query sides

The read model and write model can use entirely different data stores, schemas, and optimization strategies, allowing each to excel at its specific responsibility.


Why Use CQRS?

1. Scalability for Read-Heavy Workloads

Applications often have disproportionate read vs write ratios. For instance, users check balances and statements far more often than they make transactions. CQRS allows you to scale read and write operations independently.

2. Audit Trail and Compliance

The write side maintains an immutable ledger of all transactions, critical for regulatory compliance (SOX, PCI-DSS, GDPR). Every state change is captured as an event.

3. Optimized Query Performance

Read models can be denormalized and optimized specifically for query patterns without compromising the integrity of the write model.

4. Complex Domain Logic Isolation

Business rules, validations, and fraud detection logic can be encapsulated in the command side without impacting read performance.

5. Event-Driven Architecture

Natural integration with event sourcing, enabling real-time notifications, analytics, and integration with downstream systems.

6. Security and Validation

Centralized command validation ensures all writes go through proper authorization, fraud checks, and business rule validation.


How CQRS Works

Basic Architecture

Traditional Architecture:

Client → Controller → Service → Database
                                   ↓
Client ← Controller ← Service ← Database

CQRS Architecture:

WRITE SIDE                          READ SIDE
Client → Command → Handler          Client → Query → Handler
           ↓                                    ↓
      Validation                           Read DB
           ↓                                   ↑
      Write DB → Events → Projections ────────┘

Data Flow

  1. Command Path:
    • Client sends command (e.g., ProcessPaymentCommand)
    • Command handler validates and executes business logic
    • Data written to write database (source of truth)
    • Event published (e.g., PaymentProcessed)
    • Event handler updates read database
  2. Query Path:
    • Client sends query (e.g., GetAccountStatementQuery)
    • Query handler retrieves from optimized read database
    • No business logic, pure data retrieval
    • Returns denormalized DTOs

CQRS Components

1. Commands (Write Operations)

Commands represent user intentions to change system state. They are imperative and can be rejected.

Example Commands:

// Payment Processing
public class ProcessPaymentCommand : IRequest<PaymentResult>
{
    public Guid FromAccountId { get; set; }
    public Guid ToAccountId { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; }
    public string Reference { get; set; }
}

// Account Management
public class CreateAccountCommand : IRequest<AccountResult>
{
    public string AccountHolderName { get; set; }
    public string AccountType { get; set; }
    public decimal InitialDeposit { get; set; }
}

// Card Operations
public class BlockCardCommand : IRequest<bool>
{
    public Guid CardId { get; set; }
    public string Reason { get; set; }
}

2. Command Handlers

Process commands with business logic, validation, and persistence.

public class ProcessPaymentCommandHandler 
    : IRequestHandler<ProcessPaymentCommand, PaymentResult>
{
    private readonly ITransactionRepository _repository;
    private readonly IFraudDetectionService _fraudService;
    private readonly IMediator _mediator;
    
    public async Task<PaymentResult> Handle(
        ProcessPaymentCommand command, 
        CancellationToken cancellationToken)
    {
        // 1. Validate command
        var validator = new PaymentValidator();
        await validator.ValidateAndThrowAsync(command);
        
        // 2. Fraud detection & AML checks
        var riskScore = await _fraudService
            .AssessRisk(command.FromAccountId, command.Amount);
        
        if (riskScore.IsHighRisk)
            throw new FraudDetectedException();
        
        // 3. Execute transaction
        var transaction = new Transaction
        {
            Id = Guid.NewGuid(),
            FromAccount = command.FromAccountId,
            ToAccount = command.ToAccountId,
            Amount = command.Amount,
            Status = TransactionStatus.Completed,
            Timestamp = DateTime.UtcNow
        };
        
        await _repository.SaveAsync(transaction);
        
        // 4. Publish event
        await _mediator.Publish(new PaymentProcessedEvent
        {
            TransactionId = transaction.Id,
            FromAccountId = command.FromAccountId,
            ToAccountId = command.ToAccountId,
            Amount = command.Amount,
            Timestamp = transaction.Timestamp
        });
        
        return new PaymentResult 
        { 
            Success = true, 
            TransactionId = transaction.Id 
        };
    }
}

3. Validators

Ensure data integrity before processing commands.

public class PaymentValidator : AbstractValidator<ProcessPaymentCommand>
{
    public PaymentValidator()
    {
        RuleFor(x => x.Amount)
            .GreaterThan(0)
            .WithMessage("Amount must be positive");
            
        RuleFor(x => x.Amount)
            .LessThanOrEqualTo(1000000)
            .WithMessage("Amount exceeds transaction limit");
            
        RuleFor(x => x.FromAccountId)
            .NotEmpty()
            .WithMessage("Source account required");
            
        RuleFor(x => x.ToAccountId)
            .NotEmpty()
            .WithMessage("Destination account required");
            
        RuleFor(x => x.Currency)
            .Must(BeValidCurrency)
            .WithMessage("Invalid currency code");
    }
    
    private bool BeValidCurrency(string currency)
    {
        return new[] { "USD", "EUR", "GBP", "KES" }
            .Contains(currency);
    }
}

4. Fraud Detection Service

Critical for applications to prevent fraud and ensure AML/CTF compliance.

public class FraudDetectionService : IFraudDetectionService
{
    public async Task<RiskAssessment> AssessRisk(
        Guid accountId, 
        decimal amount)
    {
        var assessment = new RiskAssessment();
        
        var recentTransactions = await GetRecentTransactions(accountId);
        if (recentTransactions.Count > 10) 
            assessment.AddFlag("High velocity");
        if (amount > 50000)
            assessment.AddFlag("Large transaction");
        var accountAge = await GetAccountAge(accountId);
        if (accountAge < TimeSpan.FromDays(30) && amount > 5000)
            assessment.AddFlag("New account, large amount");
        
        var location = await GetTransactionLocation();
        if (await IsAnomalousLocation(accountId, location))
            assessment.AddFlag("Unusual location");
        
        if (await IsOnWatchlist(accountId))
            assessment.AddFlag("AML watchlist match");
        
        return assessment;
    }
}

5. Queries (Read Operations)

Queries more often retrieve data without side effects. They are declarative and almost always succeed (returning empty if no data exists).

Example Queries:

// Transaction Details
public class GetTransactionByIdQuery : IRequest<TransactionDetailsDto>
{
    public Guid TransactionId { get; set; }
}

// Account Statement
public class GetAccountStatementQuery : IRequest<AccountStatementDto>
{
    public Guid AccountId { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
    public int PageNumber { get; set; } = 1;
    public int PageSize { get; set; } = 50;
}

// Balance Inquiry
public class GetAccountBalanceQuery : IRequest<BalanceDto>
{
    public Guid AccountId { get; set; }
}

6. Query Handlers

Retrieve data from optimized read models.

public class GetAccountStatementQueryHandler 
    : IRequestHandler<GetAccountStatementQuery, AccountStatementDto>
{
    private readonly IReadDbContext _readDb;
    
    public async Task<AccountStatementDto> Handle(
        GetAccountStatementQuery query, 
        CancellationToken cancellationToken)
    {
        // Query optimized read model - no business logic
        var account = await _readDb.AccountBalances
            .FirstOrDefaultAsync(a => a.Id == query.AccountId);
            
        var transactions = await _readDb.Transactions
            .Where(t => t.AccountId == query.AccountId)
            .Where(t => t.Timestamp >= query.StartDate)
            .Where(t => t.Timestamp <= query.EndDate)
            .OrderByDescending(t => t.Timestamp)
            .Skip((query.PageNumber - 1) * query.PageSize)
            .Take(query.PageSize)
            .ToListAsync();
        
        return new AccountStatementDto
        {
            AccountId = account.Id,
            AccountNumber = account.AccountNumber,
            CurrentBalance = account.Balance,
            Currency = account.Currency,
            StatementPeriod = new DateRange 
            { 
                Start = query.StartDate, 
                End = query.EndDate 
            },
            Transactions = transactions.Select(t => new TransactionSummaryDto
            {
                Id = t.Id,
                Date = t.Timestamp,
                Description = t.Description,
                Amount = t.Amount,
                Type = t.Type,
                Balance = t.RunningBalance
            }).ToList()
        };
    }
}

7. Events

Domain events represent facts about what happened in the system.

// Payment Events
public class PaymentProcessedEvent : INotification
{
    public Guid TransactionId { get; set; }
    public Guid FromAccountId { get; set; }
    public Guid ToAccountId { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; }
    public DateTime Timestamp { get; set; }
}

public class PaymentFailedEvent : INotification
{
    public Guid TransactionId { get; set; }
    public string Reason { get; set; }
    public DateTime Timestamp { get; set; }
}

// Account Events
public class AccountCreatedEvent : INotification
{
    public Guid AccountId { get; set; }
    public string AccountNumber { get; set; }
    public string AccountHolderName { get; set; }
}

public class AccountBlockedEvent : INotification
{
    public Guid AccountId { get; set; }
    public string Reason { get; set; }
    public DateTime BlockedAt { get; set; }
}

8. Event Handlers (Projections)

Update read models when events occur.

public class PaymentProjectionHandler 
    : INotificationHandler<PaymentProcessedEvent>
{
    private readonly IReadDbContext _readDb;
    
    public async Task Handle(
        PaymentProcessedEvent @event, 
        CancellationToken cancellationToken)
    {
        // Update sender's balance
        var senderAccount = await _readDb.AccountBalances
            .FindAsync(@event.FromAccountId);
        senderAccount.Balance -= @event.Amount;
        senderAccount.LastTransactionDate = @event.Timestamp;
        
        // Update receiver's balance
        var receiverAccount = await _readDb.AccountBalances
            .FindAsync(@event.ToAccountId);
        receiverAccount.Balance += @event.Amount;
        receiverAccount.LastTransactionDate = @event.Timestamp;
        
        // Add to transaction history
        _readDb.Transactions.Add(new TransactionReadModel
        {
            Id = @event.TransactionId,
            AccountId = @event.FromAccountId,
            Amount = [email protected],
            Type = "Debit",
            Description = "Transfer to account",
            Timestamp = @event.Timestamp,
            RunningBalance = senderAccount.Balance
        });
        
        _readDb.Transactions.Add(new TransactionReadModel
        {
            Id = @event.TransactionId,
            AccountId = @event.ToAccountId,
            Amount = @event.Amount,
            Type = "Credit",
            Description = "Transfer from account",
            Timestamp = @event.Timestamp,
            RunningBalance = receiverAccount.Balance
        });
        
        await _readDb.SaveChangesAsync(cancellationToken);
    }
}

9. Data Models

Write Model:

// Normalized, write-optimized
public class Transaction
{
    public Guid Id { get; set; }
    public Guid FromAccountId { get; set; }
    public Guid ToAccountId { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; }
    public TransactionStatus Status { get; set; }
    public DateTime Timestamp { get; set; }
    public string Reference { get; set; }
    
    // Audit fields
    public string CreatedBy { get; set; }
    public DateTime CreatedAt { get; set; }
}

Read Model:

// Denormalized, read-optimized
public class AccountBalanceReadModel
{
    public Guid Id { get; set; }
    public string AccountNumber { get; set; }
    public string AccountHolderName { get; set; }
    public decimal Balance { get; set; }
    public string Currency { get; set; }
    public DateTime LastTransactionDate { get; set; }
    
    // Denormalized for fast queries
    public int TotalTransactions { get; set; }
    public decimal MonthlySpending { get; set; }
    public List<string> RecentMerchants { get; set; }
}

public class TransactionReadModel
{
    public Guid Id { get; set; }
    public Guid AccountId { get; set; }
    public DateTime Timestamp { get; set; }
    public decimal Amount { get; set; }
    public string Type { get; set; } // Debit/Credit
    public string Description { get; set; }
    public decimal RunningBalance { get; set; }
    public string Category { get; set; }
    public string MerchantName { get; set; }
}

Implementation Architecture

API Endpoints

[ApiController]
[Route("api/payments")]
public class PaymentsController : ControllerBase
{
    private readonly IMediator _mediator;
    
    // Command endpoint
    [HttpPost("transfer")]
    public async Task<IActionResult> Transfer(
        [FromBody] ProcessPaymentCommand command)
    {
        var result = await _mediator.Send(command);
        return Ok(result);
    }
    
    // Query endpoints
    [HttpGet("transactions/{id}")]
    public async Task<IActionResult> GetTransaction(Guid id)
    {
        var query = new GetTransactionByIdQuery { TransactionId = id };
        var result = await _mediator.Send(query);
        return Ok(result);
    }
    
    [HttpGet("accounts/{id}/statement")]
    public async Task<IActionResult> GetStatement(
        Guid id,
        [FromQuery] DateTime startDate,
        [FromQuery] DateTime endDate)
    {
        var query = new GetAccountStatementQuery
        {
            AccountId = id,
            StartDate = startDate,
            EndDate = endDate
        };
        
        var result = await _mediator.Send(query);
        return Ok(result);
    }
}

Advanced CQRS Patterns

1. Event Sourcing Integration

Store all state changes as events instead of current state.

public class AccountAggregate
{
    private List<IEvent> _uncommittedEvents = new();
    
    public void ProcessPayment(decimal amount)
    {
        // Business logic validation
        if (Balance < amount)
            throw new InsufficientFundsException();
            
        // Apply event
        var @event = new PaymentProcessedEvent
        {
            Amount = amount,
            Timestamp = DateTime.UtcNow
        };
        
        Apply(@event);
        _uncommittedEvents.Add(@event);
    }
    
    private void Apply(PaymentProcessedEvent @event)
    {
        Balance -= @event.Amount;
        LastTransactionDate = @event.Timestamp;
    }
}

2. Saga Pattern for Distributed Transactions

public class PaymentSaga : INotificationHandler<PaymentInitiatedEvent>
{
    public async Task Handle(PaymentInitiatedEvent @event)
    {
        try
        {
            // Step 1: Debit sender
            await DebitAccount(@event.FromAccountId, @event.Amount);
            
            // Step 2: Credit receiver
            await CreditAccount(@event.ToAccountId, @event.Amount);
            
            // Step 3: Notify success
            await PublishEvent(new PaymentCompletedEvent());
        }
        catch (Exception ex)
        {
            // Compensating transactions
            await RollbackDebit(@event.FromAccountId, @event.Amount);
            await PublishEvent(new PaymentFailedEvent());
        }
    }
}

3. Eventual Consistency Handling

public class EventualConsistencyMiddleware
{
    public async Task<TResponse> Handle<TRequest, TResponse>(
        TRequest request,
        RequestHandlerDelegate<TResponse> next)
    {
        var response = await next();
        
        // For commands, return immediately but process async
        if (request is ICommand)
        {
            // Client gets fast response
            return response;
        }
        
        // For queries, ensure consistency if needed
        if (request is IQuery query && query.RequireConsistency)
        {
            await WaitForProjectionUpdates();
        }
        
        return response;
    }
}

4. Caching Strategy

public class CachedQueryHandler<TQuery, TResponse> 
    : IRequestHandler<TQuery, TResponse>
    where TQuery : IRequest<TResponse>, ICacheable
{
    private readonly IRequestHandler<TQuery, TResponse> _inner;
    private readonly IDistributedCache _cache;
    
    public async Task<TResponse> Handle(
        TQuery request, 
        CancellationToken cancellationToken)
    {
        var cacheKey = request.GetCacheKey();
        
        // Try cache first
        var cached = await _cache.GetAsync(cacheKey);
        if (cached != null)
            return JsonSerializer.Deserialize<TResponse>(cached);
        
        // Execute query
        var response = await _inner.Handle(request, cancellationToken);
        
        // Cache result
        await _cache.SetAsync(
            cacheKey, 
            JsonSerializer.SerializeToUtf8Bytes(response),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
            });
        
        return response;
    }
}

Common CQRS Workflows

1. Process Payment with Fraud Detection

Client Request
    ↓
POST /api/payments/transfer
    ↓
ProcessPaymentCommand
    ↓
ProcessPaymentCommandHandler
    ↓
PaymentValidator (Amount, Accounts, KYC)
    ↓
FraudDetectionService (AML/CTF, Velocity, Patterns)
    ↓
Transaction Written to Database
    ↓
PaymentProcessedEvent Published
    ↓
PaymentProjectionHandler Updates Database
    ↓
Client Can Query Updated Balance

2. Retrieve Account Statement

Client Request
    ↓
GET /api/accounts/{id}/statement?start=2024-01-01&end=2024-12-31
    ↓
GetAccountStatementQuery
    ↓
GetAccountStatementQueryHandler
    ↓
Query Optimized Database Read Model
    ↓
Return Denormalized AccountStatementDto

3. Handle Failed Transaction

Payment Validation Fails
    ↓
PaymentFailedEvent Published
    ↓
NotificationHandler Sends Alert
    ↓
AuditLogHandler Records Failure
    ↓
FraudAnalysisHandler Updates Risk Score

Testing CQRS Systems

1. Command Handler Tests

[Fact]
public async Task ProcessPayment_WithSufficientFunds_Succeeds()
{
    // Arrange
    var handler = new ProcessPaymentCommandHandler(
        _mockRepository.Object,
        _mockFraudService.Object,
        _mockMediator.Object);
        
    var command = new ProcessPaymentCommand
    {
        FromAccountId = Guid.NewGuid(),
        ToAccountId = Guid.NewGuid(),
        Amount = 100m
    };
    
    _mockFraudService
        .Setup(x => x.AssessRisk(It.IsAny<Guid>(), It.IsAny<decimal>()))
        .ReturnsAsync(new RiskAssessment { IsHighRisk = false });
    
    // Act
    var result = await handler.Handle(command, CancellationToken.None);
    
    // Assert
    Assert.True(result.Success);
    _mockRepository.Verify(
        x => x.SaveAsync(It.IsAny<Transaction>()), 
        Times.Once);
    _mockMediator.Verify(
        x => x.Publish(It.IsAny<PaymentProcessedEvent>(), default), 
        Times.Once);
}

2. Query Handler Tests

[Fact]
public async Task GetAccountStatement_ReturnsCorrectData()
{
    // Arrange
    var accountId = Guid.NewGuid();
    var mockDb = CreateInMemoryDatabase();
    
    mockDb.AccountBalances.Add(new AccountBalanceReadModel
    {
        Id = accountId,
        Balance = 1000m
    });
    
    var handler = new GetAccountStatementQueryHandler(mockDb);
    
    // Act
    var result = await handler.Handle(
        new GetAccountStatementQuery { AccountId = accountId },
        CancellationToken.None);
    
    // Assert
    Assert.Equal(1000m, result.CurrentBalance);
}

3. Event Handler Tests

[Fact]
public async Task PaymentProcessed_UpdatesAccountBalances()
{
    // Arrange
    var mockDb = CreateInMemoryDatabase();
    var handler = new PaymentProjectionHandler(mockDb);
    
    var fromAccountId = Guid.NewGuid();
    var toAccountId = Guid.NewGuid();
    
    mockDb.AccountBalances.Add(new AccountBalanceReadModel
    {
        Id = fromAccountId,
        Balance = 1000m
    });
    
    mockDb.AccountBalances.Add(new AccountBalanceReadModel
    {
        Id = toAccountId,
        Balance = 500m
    });
    
    var @event = new PaymentProcessedEvent
    {
        FromAccountId = fromAccountId,
        ToAccountId = toAccountId,
        Amount = 100m
    };
    
    // Act
    await handler.Handle(@event, CancellationToken.None);
    
    // Assert
    var fromAccount = await mockDb.AccountBalances.FindAsync(fromAccountId);
    var toAccount = await mockDb.AccountBalances.FindAsync(toAccountId);
    
    Assert.Equal(900m, fromAccount.Balance);
    Assert.Equal(600m, toAccount.Balance);
}

Important CQRS Rules

The Golden Rules of CQRS

  1. Commands can fail, queries cannot
    • Commands validate and may throw exceptions
    • Queries always return data (empty if nothing found)
  2. Never query from write model, never write to read model directly
    • Commands → Write DB only
    • Queries → Read DB only
    • Events → Bridge between them
  3. Accept eventual consistency
    • Read model may lag behind write model
    • Design UI to handle this gracefully
  4. Keep commands and queries simple
    • One command = one business operation
    • One query = one view requirement

When to Use CQRS

✅ Complex domain with different read/write requirements
✅ High-scale systems with unbalanced read/write ratios
✅ Systems requiring audit trails and event history
✅ Microservices with bounded contexts
✅ Real-time analytics alongside transactional processing

When NOT to Use CQRS

❌ Simple CRUD applications
❌ Small systems with low complexity
❌ When strong consistency is absolutely required everywhere
❌ When team lacks distributed systems experience
❌ When operational complexity is a major concern


Performance Optimization

1. Read Model Optimization

// Materialized views for common queries
public class MonthlySpendingSummaryReadModel
{
    public Guid AccountId { get; set; }
    public int Year { get; set; }
    public int Month { get; set; }
    public decimal TotalSpending { get; set; }
    public Dictionary<string, decimal> SpendingByCategory { get; set; }
    
    // Pre-calculated for instant queries
    public decimal AverageDailySpending { get; set; }
    public string TopCategory { get; set; }
}

2. Command Batching

public class BatchPaymentCommand : IRequest<BatchResult>
{
    public List<ProcessPaymentCommand> Payments { get; set; }
}

public class BatchPaymentHandler 
    : IRequestHandler<BatchPaymentCommand, BatchResult>
{
    public async Task<BatchResult> Handle(BatchPaymentCommand request)
    {
        // Process all payments in single transaction
        using var transaction = await _db.BeginTransactionAsync();
        
        var results = new List<PaymentResult>();
        foreach (var payment in request.Payments)
        {
            var result = await ProcessSinglePayment(payment);
            results.Add(result);
        }
        
        await transaction.CommitAsync();
        
        // Publish single batch event
        await _mediator.Publish(new BatchPaymentProcessedEvent
        {
            Results = results
        });
        
        return new BatchResult { Results = results };
    }
}

3. Database Sharding

public class ShardedReadDbContext
{
    private readonly Dictionary<int, IMongoDatabase> _shards;
    
    public IMongoCollection<T> GetCollection<T>(Guid accountId)
    {
        var shardKey = GetShardKey(accountId);
        var database = _shards[shardKey];
        return database.GetCollection<T>(typeof(T).Name);
    }
    
    private int GetShardKey(Guid accountId)
    {
        // Consistent hashing
        var hash = accountId.GetHashCode();
        return Math.Abs(hash % _shards.Count);
    }
}

Monitoring and Observability

1. Command Metrics

public class CommandMetricsMiddleware<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
{
    private readonly IMetricsCollector _metrics;
    
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var stopwatch = Stopwatch.StartNew();
        
        try
        {
            var response = await next();
            
            _metrics.RecordCommandSuccess(
                typeof(TRequest).Name,
                stopwatch.ElapsedMilliseconds);
            
            return response;
        }
        catch (Exception ex)
        {
            _metrics.RecordCommandFailure(
                typeof(TRequest).Name,
                ex.GetType().Name,
                stopwatch.ElapsedMilliseconds);
            
            throw;
        }
    }
}

2. Event Lag Monitoring

public class EventLagMonitor : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var writeDbLastEvent = await GetLastWriteEvent();
            var readDbLastEvent = await GetLastReadEvent();
            
            var lag = writeDbLastEvent.Timestamp - readDbLastEvent.Timestamp;
            
            _metrics.RecordEventLag(lag.TotalMilliseconds);
            
            if (lag.TotalSeconds > 60)
            {
                _alerts.SendAlert("Event projection lag exceeds threshold");
            }
            
            await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
        }
    }
}

Security Considerations

1. Command Authorization

public class AuthorizationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ICurrentUserService _currentUser;
    private readonly IAuthorizationService _authService;
    
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var user = _currentUser.GetUser();
        
        // Check permissions
        if (!await _authService.CanExecute(user, request))
        {
            throw new UnauthorizedException(
                $"User {user.Id} cannot execute {typeof(TRequest).Name}");
        }
        
        // Audit log
        await LogCommandExecution(user, request);
        
        return await next();
    }
}

2. Data Encryption

public class EncryptedTransaction : Transaction
{
    private readonly IEncryptionService _encryption;
    
    public string EncryptedAccountNumber
    {
        get => _encryption.Encrypt(AccountNumber);
        set => AccountNumber = _encryption.Decrypt(value);
    }
}

Best Practices

  1. Keep bounded contexts clear - Don't share models between command and query sides
  2. Design for idempotency - Commands should be safely retryable
  3. Use domain events - Capture business meaning, not technical details
  4. Monitor event lag - Alert when read model falls too far behind
  5. Version your events - Plan for schema evolution from day one
  6. Test projections independently - Ensure events correctly update read models
  7. Handle partial failures - Use sagas for distributed transactions
  8. Cache aggressively - Read models are perfect for caching
  9. Audit everything - Systems require complete audit trails
  10. Plan for scale - Separate databases can scale independently

Quick Reference

Command Pattern

Command → Validation → Business Logic → Write DB → Event → Projection

Query Pattern

Query → Read DB → DTO

Event Pattern

Write Operation → Event Published → Multiple Handlers Subscribe

MediatR Setup

// Register in Startup.cs
services.AddMediatR(cfg => 
    cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));

// Use in controllers
var result = await _mediator.Send(new ProcessPaymentCommand());

Conclusion

CQRS is a powerful architectural pattern that brings significant benefits to complex systems. By separating read and write concerns, you gain scalability, maintainability, and the ability to optimize each side for its specific purpose. The pattern works especially well with event-driven architectures and event sourcing, creating systems that are auditable, scalable, and resilient.


References and Further Reading

Official Documentation

Microsoft

Amazon Web Services (AWS)

Google Cloud

Foundational Articles

Martin Fowler

Greg Young

Udi Dahan

Industry Resources

ThoughtWorks Technology Radar

DDD Community

Microservices.io

Books

Essential Reading

  • Domain-Driven Design by Eric Evans (2003) - The foundational text
  • Implementing Domain-Driven Design by Vaughn Vernon (2013) - Practical implementation guide
  • Patterns, Principles, and Practices of Domain-Driven Design by Scott Millett & Nick Tune (2015)
  • Versioning in an Event Sourced System by Greg Young (2011)

Microservices and Event-Driven Architecture

  • Building Microservices by Sam Newman (2021)
  • Designing Data-Intensive Applications by Martin Kleppmann (2017)
  • Enterprise Integration Patterns by Gregor Hohpe & Bobby Woolf (2003)
  • Building Event-Driven Microservices by Adam Bellemare (2020)
  • Microservices Patterns by Chris Richardson (2018)

Conference Talks and Videos

Conference Archives

Blogs and Newsletters

Technical Blogs

Weekly Newsletters

Code Examples and Sample Projects

Microsoft Reference Implementations

Community Examples

Community and Forums

Discussion Forums


Attribution

This write up draws upon industry best practices and patterns established by:

  • Greg Young (CQRS pattern creator)
  • Martin Fowler (Architectural patterns)
  • Eric Evans (Domain-Driven Design)
  • Udi Dahan (Service-Oriented Architecture)
  • Microsoft Patterns & Practices Team