CQRS commands don’t actually reach the domain until they’ve been validated, and that validation is often the most surprising bottleneck in a CQRS system.
Let’s say we have a CreateOrderCommand that a client sends to our API.
{
"CommandType": "CreateOrderCommand",
"OrderId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"CustomerId": "f0e9d8c7-b6a5-4321-0fed-cba987654321",
"Items": [
{ "ProductId": "00000000-0000-0000-0000-000000000001", "Quantity": 2 },
{ "ProductId": "00000000-0000-0000-0000-000000000002", "Quantity": 1 }
],
"ShippingAddress": {
"Street": "123 Main St",
"City": "Anytown",
"PostalCode": "12345",
"Country": "US"
}
}
Here’s how it flows, and where the validation magic happens:
- API Gateway/Controller: Receives the HTTP request. It’s primarily concerned with transport – deserializing the JSON into a
CreateOrderCommandobject. It might do very basic structural checks (e.g., is the JSON valid?). - Command Bus/Dispatcher: This is the central nervous system. It takes the
CreateOrderCommandand figures out who should handle it. In CQRS, this is typically a dedicated command handler. - Validation Layer: Before the command handler even gets the command, a dedicated validation component intercepts it. This is crucial. It’s not part of the domain logic itself, but a gatekeeper to the domain. It checks:
- Presence and Format: Are all required fields present? Are GUIDs valid GUIDs? Are quantities positive integers? Is the postal code format correct for the specified country?
- Business Rules (Pre-Domain): Is the
CustomerIdvalid? Does theProductIdexist? Is theShippingAddressdeliverable to the specifiedCountry? (Note: Checking if a customer exists might involve a quick query to a read model or a dedicated lookup service, but it’s not a deep dive into the customer’s domain state). - State-Agnostic Checks: Does the command make sense in isolation, without needing to know the current state of any aggregate? For example, checking if a product quantity is non-negative is state-agnostic. Checking if we have enough stock is not, and belongs in the domain handler.
If validation fails, the command is rejected immediately with a 400 Bad Request and a clear error message detailing which field failed and why. The domain logic is never invoked.
If validation passes, the command is handed off to the appropriate command handler. The handler then loads the Order aggregate (or creates it if it’s a new order) and calls methods on it, like order.AddItems(...) or order.SetShippingAddress(...). The domain logic then performs state-dependent validation (e.g., "can we actually add this item? Is the order already in a state where shipping address cannot be changed?").
The system in action, using FluentValidation in .NET as an example:
Command:
public class CreateOrderCommand
{
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
public List<OrderItemDto> Items { get; set; }
public AddressDto ShippingAddress { get; set; }
}
public class OrderItemDto
{
public Guid ProductId { get; set; }
public int Quantity { get; set; }
}
public class AddressDto
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
public string Country { get; set; }
}
Validator:
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
// Assume these are injected or available via a service locator
private readonly ICustomerRepository _customerRepository;
private readonly IProductRepository _productRepository;
private readonly IAddressValidationService _addressValidationService;
public CreateOrderCommandValidator(ICustomerRepository customerRepository, IProductRepository productRepository, IAddressValidationService addressValidationService)
{
_customerRepository = customerRepository;
_productRepository = productRepository;
_addressValidationService = addressValidationService;
RuleFor(command => command.OrderId).NotEmpty().NotEqual(Guid.Empty);
RuleFor(command => command.CustomerId).NotEmpty().MustAsync(CustomerIdExists); // Async check against DB/read model
RuleFor(command => command.Items).NotEmpty().Must(items => items.All(item => item.Quantity > 0)).WithMessage("Item quantity must be positive.");
RuleForEach(command => command.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).NotEmpty().MustAsync(ProductIdExists); // Async check
});
RuleFor(command => command.ShippingAddress).NotNull();
RuleFor(command => command.ShippingAddress).SetValidator(new AddressDtoValidator(_addressValidationService)); // Nested validator
}
private async Task<bool> CustomerIdExists(Guid customerId, CancellationToken cancellationToken)
{
// Simulate checking if customer exists
return await _customerRepository.ExistsAsync(customerId, cancellationToken);
}
private async Task<bool> ProductIdExists(Guid productId, CancellationToken cancellationToken)
{
// Simulate checking if product exists
return await _productRepository.ExistsAsync(productId, cancellationToken);
}
}
public class AddressDtoValidator : AbstractValidator<AddressDto>
{
private readonly IAddressValidationService _addressValidationService;
public AddressDtoValidator(IAddressValidationService addressValidationService)
{
_addressValidationService = addressValidationService;
RuleFor(address => address.Street).NotEmpty();
RuleFor(address => address.City).NotEmpty();
RuleFor(address => address.PostalCode).NotEmpty();
RuleFor(address => address.Country).NotEmpty().MustAsync(IsCountryValid);
RuleFor(address => address.PostalCode).MustAsync(IsPostalCodeValidForCountry);
}
private Task<bool> IsCountryValid(string country, CancellationToken cancellationToken)
{
// Basic check, e.g., "US", "CA", "GB"
return Task.FromResult(new[] { "US", "CA", "GB" }.Contains(country));
}
private async Task<bool> IsPostalCodeValidForCountry(string postalCode, ValidationContext<AddressDto> context)
{
// More complex check, e.g., using an external service or regex based on context.Country
return await _addressValidationService.IsValidPostalCodeAsync(context.InstanceToValidate.Country, postalCode);
}
}
Integration Point (e.g., in a MediatR pipeline or similar):
public class ValidationPipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationPipelineBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var failures = _validators
.SelectMany(v => v.Validate(request).Errors)
.ToList();
if (failures.Any())
{
// This is where the 400 Bad Request is generated.
// The actual exception type and response format depend on your framework.
throw new ValidationException(failures);
}
return await next();
}
}
The ValidationException would be caught by a global exception handler in your API, which would then return an appropriate HTTP 400 response. The key is that the next() delegate (which would eventually call your command handler) is never reached if failures.Any().
This separation ensures your domain objects remain pure and focused solely on business logic, without being burdened by concerns like "is this a valid GUID?" or "does this customer ID exist in the database?". These are infrastructure or application concerns, best handled before the command interacts with the core domain.
The next problem you’ll run into is handling concurrent command execution for the same aggregate.