When Regular CRUD Stops Working: Introduction to CQRS and Event Sourcing
\n\nImagine you are writing a system for a bank. Every second, hundreds of users check balances, transfer money, and open deposits. Your application in C# runs on standard Entity Framework with typical CRUD operations. And suddenly — the database crashes. Why? Because one heavy report locked the table while a transaction was being written. Sound familiar?
Developers of complex systems in C# eventually hit the ceiling of classical architecture. Two powerful patterns come to the rescue: CQRS (Command Query Responsibility Segregation) and Event Sourcing. Let's break them down in practice — with real examples, not dry theory.
\n\nWhat is CQRS and Why Is It Not "Just Separation"?
\n\nCQRS is not about putting methods into different folders. It is a philosophy: commands (change state) and queries (read state) should live separately. In classic CRUD, you have one data model for both reading and writing. In CQRS — at least two.
The Problem with the Classic Approach
\nSuppose you have a UserRepository with a GetUserById method. When you read a user to display on the screen, you need 3 fields. When you update — 10 fields and validations. As a result, one model turns into a monster with null fields and confusing mappers.
In CQRS, you separate responsibilities:
\n- \n
- Command — performs an action (e.g.,
CreateOrderCommand) \n - Query — returns data (e.g.,
GetOrderQuery) \n - Read Model — a flat DTO tailored to a specific screen \n
- Write Model — a rich domain model with business logic \n
Writing CQRS in C# with MediatR
\nThe most popular way to implement CQRS in C# is to use the MediatR library. It implements the Mediator pattern and fits perfectly with the concept of commands and queries.
Step 1: Define the Command
\npublic record CreateOrderCommand(\n Guid CustomerId,\n List<OrderItemDto> Items,\n string ShippingAddress\n) : IRequest<Guid>; // Returns the ID of the created order\n\nStep 2: Command Handler
\npublic class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>\n{\n private readonly IOrderRepository _orderRepository;\n private readonly IUnitOfWork _unitOfWork;\n\n public CreateOrderCommandHandler(IOrderRepository orderRepository, IUnitOfWork unitOfWork)\n {\n _orderRepository = orderRepository;\n _unitOfWork = unitOfWork;\n }\n\n public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken cancellationToken)\n {\n // Business logic: validation, creation\n var order = new Order(request.CustomerId, request.ShippingAddress);\n \n foreach (var item in request.Items)\n {\n order.AddItem(item.ProductId, item.Quantity, item.Price);\n }\n\n await _orderRepository.AddAsync(order);\n await _unitOfWork.SaveChangesAsync(cancellationToken);\n \n return order.Id;\n }\n}\n\nStep 3: Define the Query
\npublic record GetOrderSummaryQuery(Guid OrderId) : IRequest<OrderSummaryDto>;\n\nStep 4: Query Handler (reads from a separate database!)
\npublic class GetOrderSummaryQueryHandler : IRequestHandler<GetOrderSummaryQuery, OrderSummaryDto>\n{\n private readonly IReadDbContext _readDbContext; // Separate context for reading\n\n public GetOrderSummaryQueryHandler(IReadDbContext read