CQRS Operations Deep Dive
Mastering commands and queries in practice.
Command Best Practices
1. Make Commands Specific
❌ Generic command:
public record UpdateCommand(Guid Id, Dictionary<string, object> Changes) : ICommand;
✅ Specific commands:
public record UpdateEquipmentNameCommand(Guid EquipmentId, string NewName) : ICommand;
public record UpdateEquipmentStatusCommand(Guid EquipmentId, EquipmentStatus Status) : ICommand;
Why: Clear intent, easier to validate, better error messages.
2. Validate Input Early
public class UpdateEquipmentNameValidator : AbstractValidator<UpdateEquipmentNameCommand>
{
public UpdateEquipmentNameValidator()
{
RuleFor(x => x.EquipmentId)
.NotEmpty().WithMessage("Equipment ID is required");
RuleFor(x => x.NewName)
.NotEmpty().WithMessage("Name cannot be empty")
.MaximumLength(200).WithMessage("Name too long");
}
}
The handler receives only valid input.
3. Use Result Monads
Return structured errors instead of throwing:
// Bad ❌
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Name required");
// Good ✅
if (string.IsNullOrEmpty(name))
return Result.Fail("Name cannot be empty");
if (!isArchived)
return Result.Fail("Cannot update archived equipment");
return Result.Ok();
4. Load Aggregates Correctly
// Find existing aggregate
var equipmentMaybe = await this.Repository.FindOne(
new ByIdSpecification<EquipmentAggregate>(request.EquipmentId),
cancellationToken);
if (equipmentMaybe.HasNoValue)
{
return CommandResult<Unit>.Failed(
new BluQubeErrorData("NOT_FOUND", "Equipment not found"));
}
var equipment = equipmentMaybe.Value;
// Now safely update...
5. Publish Events
Domain events for immediate consistency:
var equipment = EquipmentAggregate.Create(name, category);
equipment.AddDomainEvent(
new EquipmentCreatedDomainEvent(equipment.Id, name, category));
this.Repository.Add(equipment);
await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
// Integration events for eventual consistency (async)
await integrationEventPublisher.PublishAsync(
new EquipmentCreatedIntegrationEvent(...));
Query Best Practices
1. Use Read Models, Not Aggregates
❌ Wrong - querying write model:
var equipment = await writeDbContext.Equipment
.Include(e => e.Checkouts)
.ThenInclude(c => c.User)
.FirstOrDefaultAsync();
✅ Right - querying read model:
var equipment = await readModels.Equipment
.Where(e => e.Id == id)
.Select(e => new EquipmentDetailDto(...))
.FirstOrDefaultAsync();
Why: Read models are optimized, no unnecessary includes.
2. Project Early
Never return aggregates from queries:
// Bad ❌
return QueryResult<EquipmentAggregate>.Succeeded(equipment);
// Good ✅
return QueryResult<EquipmentDetailDto>.Succeeded(new EquipmentDetailDto(
equipment.Id,
equipment.Name,
equipment.Category
));
3. Handle Not Found Gracefully
var result = await queryRunner.Send(
new GetEquipmentByIdQuery(equipmentId));
if (!result.IsSuccessful)
{
// Handle not found, don't throw
return "Equipment not found";
}
var equipment = result.Value;
// Safe to use
4. Filter by Current User/Tenant
Always apply security filters:
var currentUserId = httpContextAccessor.HttpContext?
.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var equipment = await readModels.Equipment
.Where(e => e.OwnerId == currentUserId) // Filter by owner
.ToListAsync();
5. Use Pagination for Large Lists
public record ListEquipmentQuery(int Page, int PageSize) : IQuery;
var equipment = await readModels.Equipment
.OrderBy(e => e.Name)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToListAsync();
Combining Commands & Queries
Pattern: Command then Query
@code {
async Task CreateAndDisplay()
{
// 1. Execute command
var createResult = await commandRunner.Send(
new CreateEquipmentCommand("Projector", "Electronics"));
if (!createResult.IsSuccessful)
{
ShowError(createResult.Errors);
return;
}
// 2. Query for new data
var queryResult = await queryRunner.Send(
new GetEquipmentByIdQuery(createResult.Value.EquipmentId));
if (queryResult.IsSuccessful)
{
equipment = queryResult.Value; // Display new data
}
}
}
Error Handling
From Commands
var result = await commandRunner.Send(command);
if (result.IsFailed)
{
foreach (var error in result.Errors)
{
Console.WriteLine($"{error.Code}: {error.Message}");
}
}
From Queries
var result = await queryRunner.Send(query);
if (!result.IsSuccessful)
{
Console.WriteLine("Query failed or returned no data");
}
Performance Tips
| Tip | Why |
|---|---|
| Use read models | Denormalized for speed |
| Filter in DB | Don't fetch, then filter in memory |
| Project fields | Only select needed columns |
| Use pagination | Don't fetch 10K rows for showing 10 |
| Add indexes | On frequently queried fields |
| Use async/await | Don't block threads |
| Cache results | For frequently accessed data |
Testing Commands & Queries
// Test command
[Fact]
public async Task CreateEquipmentCommand_ShouldAddToRepository()
{
var command = new CreateEquipmentCommand("Projector", "Electronics");
var handler = new CreateEquipmentCommandHandler(
validators,
logger,
commandStore,
repositoryFactory);
var result = await handler.Handle(command, CancellationToken.None);
Assert.True(result.IsSuccessful);
Assert.NotEqual(Guid.Empty, result.Value.EquipmentId);
}
// Test query
[Fact]
public async Task GetEquipmentByIdQuery_ShouldReturnCorrectData()
{
var readModels = CreateMockReadModels();
var handler = new GetEquipmentByIdQueryHandler(readModels);
var result = await handler.Handle(
new GetEquipmentByIdQuery(equipmentId),
CancellationToken.None);
Assert.True(result.IsSuccessful);
Assert.Equal("Projector", result.Value.Name);
}
Next Steps
- Your First Feature - Complete working example
- Common Patterns - Real-world recipes