Once a CQRS architecture is stable, the natural next step is to refine it through extensions and patterns that make it more robust, more testable, and easier to maintain. At this stage, we focus on three key directions: decorators over MediatR, event publishing, and audit logging. All of these address common needs of enterprise applications without compromising the clear separation between commands and queries.
๐ฏ 1. Decorators over MediatR for Logging, Retry, and Validation
One of the most elegant ways to introduce common behaviors into the application pipeline is through decorators. In the MediatR ecosystem, this is achieved by implementing the interface IPipelineBehavior<TRequest, TResponse>.
This interface acts as middleware that intercepts all requests sent to MediatR, allowing the addition of cross-cutting logic โ that is, functionalities that span multiple components, such as:
-
Logging โ tracking requests and responses for debugging and monitoring;
-
Retry โ re-executing a command in case of transient errors;
-
Validation โ validating requests before processing.
Simple example of a logging decorator:
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
=> _logger = logger;
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;
}
}
This approach allows adding behaviors without "polluting" individual handlers. With a simple services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));, all commands and queries automatically benefit from centralized logging.
๐ข 2. Event Publishing: Domain Events vs Integration Events
Events are a natural component of the Domain-Driven Design model, and in the CQRS context, they take on a major role. Essentially, we talk about two categories:
-
Domain Events โ represent something that happened inside the domain. For example, PostCreatedDomainEvent can be triggered when a new post is created.
-
Integration Events โ are used to notify other external systems or microservices about a change. They cross application boundaries.
For Domain Events, MediatR offers elegant integration through the INotification interface and the associated handlers INotificationHandler<TNotification>.
public class PostCreatedDomainEvent : INotification
{
public Guid PostId { get; }
public string Title { get; }
public PostCreatedDomainEvent(Guid postId, string title)
{
PostId = postId;
Title = title;
}
}
public class PostCreatedEventHandler : INotificationHandler<PostCreatedDomainEvent>
{
private readonly ILogger<PostCreatedEventHandler> _logger;
public PostCreatedEventHandler(ILogger<PostCreatedEventHandler> logger) => _logger = logger;
public Task Handle(PostCreatedDomainEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("New post created: {Title}", notification.Title);
return Task.CompletedTask;
}
}
Thus, the domain remains consistent, and the event reaction logic can be extended without affecting the core of the application.
For Integration Events, an asynchronous approach is recommended โ for example, publishing to RabbitMQ, Azure Service Bus, or Kafka โ to completely decouple communication between applications.
๐งพ 3. Audit Logging for Commands
In many enterprise applications, tracking user actions is a mandatory requirement, especially in financial, medical, or governmental environments. In CQRS, audit logging can be elegantly implemented either through a pipeline behavior or directly in command handlers.
A simple approach involves creating an AuditLog entity and saving events in an auxiliary table or in a dedicated Event Store.
public class AuditBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
private readonly IAuditService _auditService;
public AuditBehavior(IAuditService auditService)
=> _auditService = auditService;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var response = await next();
await _auditService.SaveAsync(request, response);
return response;
}
}
By this method, every command sent through MediatR leaves a trace in the database โ with details about who performed the action, when, and what was changed.
This transparency helps with debugging, security, and compliance with standards such as GDPR or ISO 27001.
๐ Conclusion
The extensions and patterns in this stage bring the CQRS architecture to a mature level.
Through decorators, events, and audit logging, the application becomes:
-
Scalable โ common logic is centralized;
-
Observable โ all actions can be tracked and analyzed;
-
Easy to extend โ adding a new global behavior does not affect existing handlers.
CQRS does not only mean separating commands and queries but also creating a solid framework that facilitates maintenance, auditing, and long-term extensibility.