
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:
The read model and write model can use entirely different data stores, schemas, and optimization strategies, allowing each to excel at its specific responsibility.
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.
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.
Read models can be denormalized and optimized specifically for query patterns without compromising the integrity of the write model.
Business rules, validations, and fraud detection logic can be encapsulated in the command side without impacting read performance.
Natural integration with event sourcing, enabling real-time notifications, analytics, and integration with downstream systems.
Centralized command validation ensures all writes go through proper authorization, fraud checks, and business rule validation.
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 ────────┘
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; }
}
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
};
}
}
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);
}
}
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;
}
}
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; }
}
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()
};
}
}
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; }
}
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);
}
}
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; }
}
[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);
}
}
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;
}
}
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());
}
}
}
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;
}
}
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;
}
}
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
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
Payment Validation Fails
↓
PaymentFailedEvent Published
↓
NotificationHandler Sends Alert
↓
AuditLogHandler Records Failure
↓
FraudAnalysisHandler Updates Risk Score
[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);
}
[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);
}
[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);
}
✅ 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
❌ 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
// 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; }
}
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 };
}
}
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);
}
}
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;
}
}
}
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);
}
}
}
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();
}
}
public class EncryptedTransaction : Transaction
{
private readonly IEncryptionService _encryption;
public string EncryptedAccountNumber
{
get => _encryption.Encrypt(AccountNumber);
set => AccountNumber = _encryption.Decrypt(value);
}
}
Command → Validation → Business Logic → Write DB → Event → Projection
Query → Read DB → DTO
Write Operation → Event Published → Multiple Handlers Subscribe
// Register in Startup.cs
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
// Use in controllers
var result = await _mediator.Send(new ProcessPaymentCommand());
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.
Microsoft
Amazon Web Services (AWS)
Google Cloud
Martin Fowler
Greg Young
Udi Dahan
ThoughtWorks Technology Radar
DDD Community
Microservices.io
Essential Reading
Microservices and Event-Driven Architecture
Conference Archives
Technical Blogs
Weekly Newsletters
Microsoft Reference Implementations
Community Examples
Discussion Forums
This write up draws upon industry best practices and patterns established by: