Accepted
In layered architectures, presentation and application layers need a mechanism to invoke use cases without tight coupling to handler implementations:
Challenges:
- Endpoints should not directly instantiate command/query handlers
- Cross-cutting concerns (validation, retry, timeout, logging) would be duplicated across handlers
- Changing handler implementation requires modifying endpoint code
- Testing endpoints requires mocking every handler dependency
- No centralized place to apply pipeline behaviors
Requirements:
- Decouple request senders (endpoints) from handlers
- Support cross-cutting concerns via pipeline behaviors
- Enable consistent request/response patterns
- Maintain single responsibility (handlers focus on business logic)
- Support both synchronous (commands/queries) and asynchronous (events) patterns
Adopt bITdevKit's Requester/Notifier pattern, an implementation of the Mediator pattern, for all command, query, and event handling.
IRequester: Synchronous request/response (Commands & Queries)
public interface IRequester
{
Task<TResponse> SendAsync<TResponse>(
IRequest<TResponse> request,
SendOptions options = null,
CancellationToken cancellationToken = default);
}INotifier: Asynchronous publish/subscribe (Domain Events)
public interface INotifier
{
Task PublishAsync<TNotification>(
TNotification notification,
PublishOptions options = null,
CancellationToken cancellationToken = default);
}Behaviors execute in order around each handler:
- ModuleScopeBehavior: Sets current module context
- ValidationPipelineBehavior: Validates request using FluentValidation
- RetryPipelineBehavior: Retries transient failures (configurable)
- TimeoutPipelineBehavior: Enforces operation timeout
Endpoint
→ IRequester.SendAsync(command)
→ ModuleScopeBehavior
→ ValidationPipelineBehavior
→ RetryPipelineBehavior
→ TimeoutPipelineBehavior
→ Handler.HandleAsync()
← Result<T>
← (timeout enforcement)
← (retry on failure)
← (validation errors)
← (module context)
← Result<T>
builder.Services.AddRequester()
.AddHandlers()
.WithDefaultBehaviors();
builder.Services.AddNotifier()
.AddHandlers()
.WithDefaultBehaviors();- Decoupling: Endpoints depend on
IRequester, not concrete handlers - Cross-Cutting Concerns: Pipeline behaviors apply consistently to all requests
- Single Responsibility: Handlers focus on business logic, not validation/retry/timeout
- Testability: Can test handlers independently or through requester
- Consistency: All commands/queries follow the same invocation pattern
- Extensibility: Easy to add new pipeline behaviors for new concerns
- Module Scoping: Module context automatically set for multi-module scenarios
- Endpoints have minimal dependencies (just
IRequester) - Cross-cutting concerns centralized in pipeline behaviors (no duplication)
- Consistent validation, retry, and timeout logic across all requests
- Handlers are testable in isolation (no mediator dependency)
- Easy to add new behaviors without modifying handlers
- Clear separation between request definition and handling
- Module context automatically tracked for logging and filtering
- Indirection through mediator (one extra hop)
- Request/handler types must be registered explicitly
- Developers must understand pipeline behavior order
- Stack traces include pipeline behavior frames
- Commands/Queries implement
IRequest<TResponse> - Handlers implement
IRequestHandler<TRequest, TResponse> - Behaviors wrap all handlers uniformly
- Assembly scanning automatically discovers handlers
-
Alternative 1: Direct Handler Injection in Endpoints
- Rejected because endpoints would need dependencies on every handler
- Cross-cutting concerns duplicated in every handler
- Violates Open/Closed Principle (adding concern requires modifying handlers)
-
Alternative 2: MediatR Library
- Considered but bITdevKit Requester/Notifier provides similar functionality
- bITdevKit integrates better with other framework features (modules, Result pattern)
- Keeping dependencies consistent within bITdevKit ecosystem
-
Alternative 3: Service Layer with Manual Validation/Retry
- Rejected because it requires manual cross-cutting concern implementation
- No standardized request/response patterns
- More boilerplate in every service method
- ADR-0002: Handlers return Results through requester
- ADR-0009: ValidationPipelineBehavior uses FluentValidation
- ADR-0011: Handlers contain application logic
- bITdevKit Requester/Notifier Documentation
- README - Requester/Notifier Pattern
- README - Pipeline Behaviors
public class CustomerEndpoints : EndpointsBase
{
public override void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/coremodule/customers")
.WithTags("CoreModule.Customers");
group.MapPost("",
async (IRequester requester, CustomerModel model, CancellationToken ct) =>
(await requester.SendAsync(new CustomerCreateCommand(model), cancellationToken: ct))
.MapHttpCreated(v => $"/api/coremodule/customers/{v.Id}"))
.WithName("CoreModule.Customers.Create");
}
}public class CustomerCreateCommand(CustomerModel model) : RequestBase<CustomerModel>
{
public CustomerModel Model { get; set; } = model;
public class Validator : AbstractValidator<CustomerCreateCommand>
{
public Validator()
{
this.RuleFor(c => c.Model).NotNull();
this.RuleFor(c => c.Model.FirstName).NotNull().NotEmpty();
this.RuleFor(c => c.Model.Email).EmailAddress();
}
}
}public class CustomerCreateCommandHandler(
ILogger<CustomerCreateCommandHandler> logger,
IGenericRepository<Customer> repository,
...)
: RequestHandlerBase<CustomerCreateCommand, CustomerModel>(logger)
{
protected override async Task<Result<CustomerModel>> HandleAsync(
CustomerCreateCommand request,
SendOptions options,
CancellationToken cancellationToken)
{
// Business logic here
// Validation already executed by ValidationPipelineBehavior
// Retry/Timeout managed by respective behaviors
}
}Behaviors execute in the order they're registered:
builder.Services.AddRequester()
.AddHandlers() // Scans assemblies for IRequestHandler implementations
.WithDefaultBehaviors(); // Adds ModuleScope, Validation, Retry, Timeoutpublic class LoggingPipelineBehavior<TRequest, TResponse> :
IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
_logger.LogInformation("Handling {Request}", typeof(TRequest).Name);
var response = await next();
_logger.LogInformation("Handled {Request}", typeof(TRequest).Name);
return response;
}
}
// Register
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));The default behavior order is intentional:
- ModuleScope: Sets context for logging/filtering in subsequent behaviors
- Validation: Fails fast before expensive operations
- Retry: Retries after validation passes
- Timeout: Innermost to measure actual handler execution
// Publish event
await notifier.PublishAsync(new CustomerCreatedDomainEvent(customer), cancellationToken: ct);
// Handler
public class CustomerCreatedDomainEventHandler :
DomainEventHandlerBase<CustomerCreatedDomainEvent>
{
public override async Task Process(
CustomerCreatedDomainEvent notification,
CancellationToken ct)
{
// React to domain event (send email, update read model, etc.)
}
}Unit Test Handler Directly (no mediator):
var handler = new CustomerCreateCommandHandler(logger, repository, ...);
var result = await handler.Handle(command, CancellationToken.None);
result.ShouldBeSuccess();Integration Test Through Requester (with behaviors):
var requester = serviceProvider.GetRequiredService<IRequester>();
var result = await requester.SendAsync(command, cancellationToken: CancellationToken.None);
// Validation, retry, timeout behaviors all execute- Requester setup:
src/Presentation.Web.Server/Program.cs - Command example:
src/Modules/CoreModule/CoreModule.Application/Commands/CustomerCreateCommand.cs - Handler example:
src/Modules/CoreModule/CoreModule.Application/Commands/CustomerCreateCommandHandler.cs - Endpoint usage:
src/Modules/CoreModule/CoreModule.Presentation/Web/Endpoints/CustomerEndpoints.cs