CQRS (Command Query Responsibility Segregation) is a pattern that separates read operations (Queries) from write operations (Commands). This separation allows you to optimize each side independently.
Bookify uses MediatR to implement CQRS. MediatR is a simple mediator implementation that supports request/response, commands, queries, notifications, and events.
// src/Bookify.Application/Apartments/SearchApartments/SearchApartmentsQueryHandler.csinternal sealed class SearchApartmentsQueryHandler( ISqlConnectionFactory sqlConnectionFactory) : IQueryHandler<SearchApartmentsQuery, IReadOnlyList<ApartmentResponse>>{ public async Task<Result<IReadOnlyList<ApartmentResponse>>> Handle( SearchApartmentsQuery request, CancellationToken cancellationToken) { using var connection = sqlConnectionFactory.CreateConnection(); const string sql = """ SELECT a.id AS Id, a.name AS Name, a.description AS Description, a.price_amount AS Price, a.price_currency AS Currency, a.address_country AS Country, a.address_state AS State, a.address_city AS City FROM apartments AS a WHERE NOT EXISTS ( SELECT 1 FROM bookings AS b WHERE b.apartment_id = a.id AND b.duration_start <= @EndDate AND b.duration_end >= @StartDate AND b.status = @BookingStatus ) """; var apartments = await connection.QueryAsync<ApartmentResponse, AddressResponse, ApartmentResponse>( sql, (apartment, address) => { apartment.Address = address; return apartment; }, new { request.StartDate, request.EndDate, BookingStatus = (int)BookingStatus.Reserved }, splitOn: "Country"); return apartments.ToList(); }}
This query uses Dapper for performance. Queries bypass the ORM and use raw SQL for optimal read performance.
// src/Bookify.Application/Apartments/SearchApartments/ApartmentResponse.cspublic sealed class ApartmentResponse{ public Guid Id { get; init; } public string Name { get; init; } public string Description { get; init; } public decimal Price { get; init; } public string Currency { get; init; } public AddressResponse Address { get; set; }}public sealed class AddressResponse{ public string Country { get; init; } public string State { get; init; } public string City { get; init; }}
// src/Bookify.Application/Abstractions/Behaviors/QueryCachingBehavior.csinternal sealed class QueryCachingBehavior<TRequest, TResponse>( ICacheService cacheService, ILogger<QueryCachingBehavior<TRequest, TResponse>> logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : ICachedQuery where TResponse : Result{ public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) { var cachedResult = await cacheService.GetAsync<TResponse>( request.CacheKey, cancellationToken); if (cachedResult is not null) { logger.LogInformation("Cache hit for {CacheKey}", request.CacheKey); return cachedResult; } logger.LogInformation("Cache miss for {CacheKey}", request.CacheKey); var response = await next(); if (response.IsSuccess) { await cacheService.SetAsync( request.CacheKey, response, request.Expiration, cancellationToken); } return response; }}
To enable caching for a query, implement ICachedQuery:
public sealed record GetBookingQuery(Guid BookingId) : IQuery<BookingResponse>, ICachedQuery{ public string CacheKey => $"booking-{BookingId}"; public TimeSpan? Expiration => TimeSpan.FromMinutes(5);}
public class ReserveBookingCommandHandlerTests{ [Fact] public async Task Handle_ShouldReturnSuccess_WhenBookingIsValid() { // Arrange var userRepositoryMock = new Mock<IUserRepository>(); var apartmentRepositoryMock = new Mock<IApartmentRepository>(); var bookingRepositoryMock = new Mock<IBookingRepository>(); var unitOfWorkMock = new Mock<IUnitOfWork>(); userRepositoryMock .Setup(x => x.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) .ReturnsAsync(UserData.Create()); apartmentRepositoryMock .Setup(x => x.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) .ReturnsAsync(ApartmentData.Create()); bookingRepositoryMock .Setup(x => x.IsOverlappingAsync( It.IsAny<Apartment>(), It.IsAny<DateRange>(), It.IsAny<CancellationToken>())) .ReturnsAsync(false); var handler = new ReserveBookingCommandHandler( userRepositoryMock.Object, apartmentRepositoryMock.Object, bookingRepositoryMock.Object, unitOfWorkMock.Object, new PricingService(), new FakeDateTimeProvider()); var command = new ReserveBookingCommand( Guid.NewGuid(), Guid.NewGuid(), DateOnly.FromDateTime(DateTime.UtcNow), DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7))); // Act var result = await handler.Handle(command, default); // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeEmpty(); bookingRepositoryMock.Verify(x => x.Add(It.IsAny<Booking>()), Times.Once); unitOfWorkMock.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once); }}
public class SearchApartmentsQueryHandlerTests{ [Fact] public async Task Handle_ShouldReturnApartments_WhenApartmentsExist() { // Arrange var sqlConnectionFactoryMock = new Mock<ISqlConnectionFactory>(); var connectionMock = new Mock<IDbConnection>(); sqlConnectionFactoryMock .Setup(x => x.CreateConnection()) .Returns(connectionMock.Object); var handler = new SearchApartmentsQueryHandler(sqlConnectionFactoryMock.Object); var query = new SearchApartmentsQuery( DateOnly.FromDateTime(DateTime.UtcNow), DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7))); // Act var result = await handler.Handle(query, default); // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); }}
Never put business logic in handlers. Handlers should orchestrate, domain entities should contain logic.
Keep commands and queries immutable by using records. This prevents accidental modifications.
When to use EF Core vs Dapper?
Commands: Use EF Core for change tracking and navigation property loading
Queries: Use Dapper for complex queries and better performance
Simple reads: EF Core is fine for simple Get by ID operations
Should every command have a validator?
Yes, even if it’s simple. Validation should happen before the handler executes. This catches errors early and provides clear error messages.
Can commands return data?
Commands should return minimal data - typically just an ID or a success indicator. If you need to return the full created entity, the client should follow up with a query.