Application Commands and Queries Feature Documentation¶
Separate application writes and reads into focused handlers with shared behaviors and clear boundaries.
Overview¶
Background¶
The Command Query Separation (CQS) principle, introduced by Bertrand Meyer, divides operations into commands, which modify system state, and queries, which retrieve data without side effects. This separation enhances code clarity, predictability and maintainability by ensuring methods have distinct roles. By moving away from bloated application services that centralize all logic, commands and queries encapsulate specific business operations in smaller, focused units. This reduces the number of dependencies injected into each handler, improves testability by allowing isolated testing and promotes a cleaner architecture.
- Commands: Perform state-changing actions such as creating or updating data. They typically return
Result<Unit>for actions with no meaningful return orResult<T>for minimal data such as an identifier or summary model. - Queries: Retrieve data without altering state. They return
Result<T>with the requested data and are idempotent.
In Domain-Driven Design (DDD), commands and queries align with application services, encapsulating business logic and data access. The Requester feature in bITDevKit implements CQS using a mediator-like pattern, dispatching requests to handlers with type-safe Result<T> outcomes and extensible pipeline behaviors such as validation, retries, and timeouts. This reduces coupling, as callers are unaware of handler implementations, minimizes dependency injection in handlers, and enables consistent handling of cross-cutting concerns, making the codebase more modular and testable.
Many handlers also depend on the shared mapping abstraction to translate between request models, domain objects, and response DTOs; see Common Mapping.
For application-layer pub/sub workflows that do not fit a single request/response interaction, see Application Events.
Challenges¶
- Inconsistent Handling: Ad hoc implementations lead to unpredictable behavior.
- Mixed Concerns: Combining state changes and data retrieval causes unintended side effects.
- Extensibility: Adding concerns like logging or validation requires modifying core logic.
- Error Propagation: Preserving error context across layers is complex.
Solution¶
The Requester system provides:
- Requests: Source-generated command and query types authored as
partialclasses with[Command]or[Query]. - Handlers: Business logic written inline with a single instance
[Handle]method. - Dispatching: Via
IRequester.SendAsync(), routing requests through a pipeline of behaviors.
Behaviors such as ValidationPipelineBehavior and RetryPipelineBehavior handle concerns without altering business logic.
Flow Diagram¶
The following Mermaid diagram illustrates the command/query flow:
sequenceDiagram
participant Client
participant Requester as IRequester
participant Pipeline as Pipeline Behaviors
participant Handler as RequestHandler
participant Repository as IGenericRepository
participant Database
Client->>Requester: SendAsync(Request)
Requester->>Pipeline: Apply Behaviors (Validation, Retry, etc.)
Pipeline->>Handler: HandleAsync(Request)
Handler->>Repository: Perform Operation (e.g., Insert, Find)
Repository->>Database: Execute (e.g., Save, Query)
Database-->>Repository: Result
Repository-->>Handler: Result<T>
Handler-->>Pipeline: Result<T>
Pipeline-->>Requester: Result<T>
Requester-->>Client: Result<T>
Setup¶
Register the Requester in the dependency injection container:
services.AddRequester()
.AddHandlers()
.WithBehavior<ValidationPipelineBehavior<,>>()
.WithBehavior<RetryPipelineBehavior<,>>();
Add the code generation package to the project that contains the commands and queries:
<PackageReference Include="BridgingIT.DevKit.Common.Utilities.CodeGen"
Version="x.y.z"
PrivateAssets="all" />
Basic Usage¶
Defining a Command¶
Commands modify state and return Result<Unit> or Result<T>.
[Command] // Marker attribute to indicate this is a command
public partial class CustomerCreateCommand
{
public string FirstName { get; init; } // Properties are defined normally
public string LastName { get; init; }
public string Email { get; init; }
[Handle]
private async Task<Result<Customer>> HandleAsync(
// DI services declared as parameters are resolved automatically
IGenericRepository<Customer> repository,
CancellationToken cancellationToken)
{
var customer = mapper.Map<CustomerCreateCommand, Customer>(this);
await repository.InsertAsync(customer, cancellationToken);
// Returning Success with a value, which will be the Result<Customer> type of the command
return Success(customer);
}
}
Validating a Command¶
For simple cases, place validation directly on the properties:
[Command]
public partial class CustomerRenameCommand
{
[ValidateNotEmptyGuid("CustomerId is required.")]
public string CustomerId { get; init; }
[ValidateNotEmpty("Display name is required.")]
[ValidateLength(3, 100, "Display name must be between 3 and 100 characters.")]
public string DisplayName { get; init; }
[Handle]
private Result<Unit> Handle()
{
return Success();
}
}
For more complex rules, the [Validate] marker can be used:
[Command] // Marker attribute to indicate this is a command
public partial class CustomerImportCommand
{
[ValidateNotEmpty("At least one email address is required.")]
[ValidateEachNotEmpty("Email entries cannot be empty.")]
public List<string> Emails { get; init; }
[Validate]
private static void Validate(InlineValidator<CustomerImportCommand> validator)
{
validator.RuleFor(x => x.Emails) // regular fluent validation
.Must(x => x.Count <= 100).WithMessage("A maximum of 100 email addresses is allowed.");
}
[Handle]
private Result<Unit> Handle()
{
return Success();
}
}
Defining a Query¶
Queries retrieve data and return Result<T>.
[Query] // Marker attribute to indicate this is a query
public partial class CustomerFindOneQuery
{
[ValidateNotEmptyGuid("CustomerId is required.")]
public string CustomerId { get; }
[Handle]
private async Task<Result<Customer>> HandleAsync(
IMapper mapper,
IGenericRepository<Customer> repository,
CancellationToken cancellationToken)
{
var customer = await repository.FindOneAsync(CustomerId, cancellationToken: cancellationToken);
// Returning Success with a value, which will be the Result<Customer> type of the query
return customer != null
? Success(customer)
: Failure($"Customer with ID {CustomerId} was not found.");
}
}
Dispatching¶
Inject and use IRequester:
// In a controller, service, or any class with DI
var requester = serviceProvider.GetRequiredService<IRequester>();
var command = new CustomerCreateCommand
{
FirstName = "John",
LastName = "Doe",
Email = "john.doe@example.com"
};
var commandResult = await requester.SendAsync(command); // Returns Result<Customer>
if (commandResult.IsSuccess)
{
Console.WriteLine($"Created customer: {commandResult.Value.Id}");
}
else
{
Console.WriteLine($"Errors: {string.Join(", ", commandResult.Errors.Select(e => e.Message))}");
}
var query = new CustomerFindOneQuery("some-guid");
var queryResult = await requester.SendAsync(query); // Returns Result<Customer>
if (queryResult.IsSuccess)
{
Console.WriteLine($"Found customer: {queryResult.Value.FirstName}");
}
Notes¶
- The response type is inferred from the
Result<T>returned by[Handle]. Success(...)andFailure(...)can be used directly inside[Handle].- DI services can be declared as parameters on
[Handle]and are resolved automatically. CancellationTokenandSendOptionscan also be declared as[Handle]parameters when needed.- Handler policy attributes such as retry, timeout, authorization, and transactions can be applied at the command or query definition.
See features-requester-notifier.md for more details (Appendix D: Source-Generated Commands, Queries, and Events).