Skip to main content

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

TipWhy
Use read modelsDenormalized for speed
Filter in DBDon't fetch, then filter in memory
Project fieldsOnly select needed columns
Use paginationDon't fetch 10K rows for showing 10
Add indexesOn frequently queried fields
Use async/awaitDon't block threads
Cache resultsFor 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