CQRS and the Mediator Pattern
CQRS Architecture
Purpose
This API implements in-process CQRS using a custom dispatcher. Endpoints send commands and queries through a shared sender abstraction, while pipeline behaviors handle cross-cutting concerns such as logging, validation, caching, and transaction boundaries.
Scope
This document covers:
- Request and handler dispatching.
- Command vs query responsibilities.
- Pipeline behavior ordering.
- Notification publishing and handling.
This document does not describe:
- Distributed messaging.
- Physical read/write database separation.
What Is CQRS?
CQRS stands for Command Query Responsibility Segregation.
- Commands change state.
- Queries read state.
- Each request type has one focused handler for one use case.
CQRS does not require separate databases. In this project, command and query models are separated in code while using the same in-memory source during this stage.
CQRS In This Project
- Commands are represented by request records such as
CreateCommand,UpdateCommand, andDeleteCommand. - Queries are represented by request records such as
ListQueryandGetByIdQuery. - Endpoints delegate both command and query execution to
ISender, keeping transport logic separate from business logic.
What Is Mediator?
The Mediator pattern reduces direct coupling between components by routing requests through a single mediator instead of having callers depend on concrete handlers.
In this project, the mediator role is implemented by the custom dispatcher stack:
ISenderandIPublisherare the request and notification entry points.Dispatcherresolves and invokes the correct request or notification wrapper.- Wrappers compose pipeline behaviors and call the final handler.
This gives the same usage style teams expect from mediator libraries while keeping full control over implementation details and performance characteristics.
Why CQRS + Mediator Benefits Imparta.iCoachAi.Api
Clearer intent per endpoint. Commands and queries are explicit request types, so each API operation states whether it modifies data or only reads data.
Better separation of concerns. Minimal API endpoints in
ModuleEndpointsstay thin and focus on HTTP concerns while handlers own business behavior.Consistent cross-cutting behavior. Logging, validation, caching, and transaction boundaries are applied uniformly through pipeline behaviors instead of duplicated code in endpoints or handlers.
Easier evolution of the data layer. The current in-memory module storage can be replaced by EF Core or other persistence with minimal endpoint changes because callers only depend on request contracts.
Testability. Individual handlers and behaviors can be tested in isolation because they are small units behind stable interfaces (
IRequestHandler<,>,IPipelineBehavior<,>).Extensible side effects. Notifications allow post-command concerns such as audit logging to be added as separate handlers without modifying core command handlers.
Predictable execution model. Request dispatch uses a startup-built frozen registry and strongly typed wrappers, yielding deterministic handler lookup and behavior composition.
Core Components
ISender: Sends a single request to a single handler.IPublisher: Publishes notifications to zero or more handlers.Dispatcher: Resolves wrapper by runtime request/notification type from a frozen registry.- Request wrappers: Build and execute the behavior pipeline around handlers.
- Notification wrappers: Resolve and invoke all notification handlers sequentially.
Registration Model
At startup, the application scans the API assembly for IRequestHandler<,> and INotificationHandler<> implementations, registers them, and builds wrapper registries once.
Pipeline behaviors are registered as open generics in this order:
- Logging
- Validation
- Caching
- Transaction
Execution order is outermost to innermost in the same order above, then the request handler.
High-Level Flow
flowchart LR
A[HTTP Endpoint] --> B[ISender.Send]
B --> C[Dispatcher]
C --> D[Request Wrapper lookup by request runtime type]
D --> E[Pipeline Behaviors]
E --> F[Request Handler]
F --> G[Response]
H[HTTP Endpoint] --> I[IPublisher.Publish]
I --> J[Dispatcher]
J --> K[Notification Wrapper lookup by notification runtime type]
K --> L[Notification Handlers sequential]Command Flow Example
Create module command from endpoint to handler and notification publishing.
sequenceDiagram
participant Client
participant Endpoint as ModuleEndpoints
participant Sender as ISender
participant Disp as Dispatcher
participant Pipe as Behaviors
participant Handler as CreateCommandHandler
participant Pub as IPublisher
participant NHandlers as Notification Handlers
Client->>Endpoint: POST /api/modules
Endpoint->>Sender: Send(CreateCommand)
Sender->>Disp: Route by request type
Disp->>Pipe: Logging -> Validation -> Caching -> Transaction
Pipe->>Handler: Handle(CreateCommand)
Handler-->>Endpoint: Guid id
Endpoint->>Pub: Publish(ModuleCreatedNotification)
Pub->>Disp: Route by notification type
Disp->>NHandlers: Invoke handlers sequentially
Endpoint-->>Client: 201 CreatedQuery Flow Example
Get module by id query with cache participation.
sequenceDiagram
participant Client
participant Endpoint as ModuleEndpoints
participant Sender as ISender
participant Disp as Dispatcher
participant Cache as CachingBehaviour
participant Handler as GetByIdHandler
Client->>Endpoint: GET /api/modules/{id}
Endpoint->>Sender: Send(GetByIdQuery)
Sender->>Disp: Route by request type
Disp->>Cache: Check cache key module:{id}
alt Cache hit
Cache-->>Endpoint: ModuleDto
else Cache miss
Cache->>Handler: Handle(GetByIdQuery)
Handler-->>Cache: ModuleDto
Cache-->>Endpoint: ModuleDto and store in cache
end
Endpoint-->>Client: 200 OK or 204 NoContentRequest Pipeline Semantics
- The handler is the core delegate.
- Behaviors are wrapped around the handler in reverse enumeration.
- Because behaviors are reversed when composing the delegate, first registered behavior executes first.
Conceptually:
pipeline = Logging(Validation(Caching(Transaction(Handler))))Command and Query Boundaries
Commands
Commands represent intent to change state.
- Example:
CreateCommand,UpdateCommand,DeleteCommand. - Typical response shape: identity (
Guid) or status (bool/Unit). - Can be marked transactional through
ITransactional.
Queries
Queries read state and should avoid side effects.
- Example:
ListQuery,GetByIdQuery. - Query requests can opt into caching through
ICacheable.
Notifications
Notifications model in-process side effects after a command or event.
- Example:
ModuleCreatedNotification,ModuleDeletedNotification. - Multiple handlers are allowed.
- Current invocation model is sequential.
Current Implementation Notes
- The sample module feature currently uses in-memory data (
TempModules). - Transaction behavior is present but currently passes through to next.
- Notification dispatch is in-process only.
These choices keep the architecture simple while preserving the same CQRS abstractions for future persistence and messaging upgrades.
Code Examples
The snippets below show CQRS and mediator-style dispatch as implemented in this API.
1. Request Contracts For Commands And Queries
Commands express intent to change state:
public sealed record CreateCommand(string Name) : IRequest<Guid>, ITransactional;Queries express read intent and can opt into caching:
public sealed record GetByIdQuery(Guid Id) : IRequest<ModuleDto?>, ICacheable
{
public string CacheKey => $"module:{Id}";
public TimeSpan Expiration => TimeSpan.FromMinutes(5);
}2. Command Handler Example
public sealed class CreateCommandHandler() : IRequestHandler<CreateCommand, Guid>
{
public async ValueTask<Guid> Handle(
CreateCommand request,
CancellationToken cancellationToken
)
{
var module = new Module
{
Id = Guid.NewGuid(),
Name = request.Name,
};
// insert new module...
return Task.FromResult(module.Id).Result;
}
}3. Query Handler Example
public sealed class GetByIdHandler() : IRequestHandler<GetByIdQuery, ModuleDto?>
{
public async ValueTask<ModuleDto?> Handle(
GetByIdQuery request,
CancellationToken cancellationToken
)
{
var module = TempModules.modules.FirstOrDefault(m => m.Id == request.Id);
return Task.FromResult(module).Result;
}
}4. Minimal API Endpoints Calling ISender And IPublisher
private static async Task<IResult> Create(
CreateCommand command,
ISender sender,
IPublisher publisher,
CancellationToken cancellationToken
)
{
var id = await sender.Send(command, cancellationToken);
await publisher.Publish(new ModuleCreatedNotification(id, command.Name), cancellationToken);
return Results.Created($"/modules/{id}", new { id });
}
private static async Task<IResult> GetById(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var module = await sender.Send(new GetByIdQuery(id), cancellationToken);
return module is not null ? TypedResults.Ok(module) : TypedResults.NoContent();
}5. Dispatcher Send/Publish Entry Points
public ValueTask<TResponse> Send<TResponse>(
IRequest<TResponse> request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (!registry.RequestWrappers.TryGetValue(request.GetType(), out var wrapper))
{
throw new InvalidOperationException(
$"No handler registered for request type '{request.GetType().FullName}'.");
}
return ((RequestHandlerBase<TResponse>)wrapper).Handle(request, provider, cancellationToken);
}
public ValueTask Publish<TNotification>(
TNotification notification,
CancellationToken cancellationToken = default)
where TNotification : INotification
{
ArgumentNullException.ThrowIfNull(notification);
if (!registry.NotificationWrappers.TryGetValue(notification.GetType(), out var wrapper))
{
return ValueTask.CompletedTask;
}
return wrapper.Handle(notification, provider, cancellationToken);
}6. Service Registration For CQRS Components
Note that the pipeline behaviours need to be in the right order.
services.AddHybridCache();
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddDispatcher(Assembly.GetExecutingAssembly());
services.AddPipelineBehavior(typeof(LoggingBehaviour<,>));
services.AddPipelineBehavior(typeof(ValidationBehaviour<,>));
services.AddPipelineBehavior(typeof(CachingBehaviour<,>));
services.AddPipelineBehavior(typeof(TransactionBehaviour<,>));7. Notification Handler Example
public class AuditLogHandler(ILogger<AuditLogHandler> logger)
: INotificationHandler<ModuleCreatedNotification>,
INotificationHandler<ModuleDeletedNotification>
{
public ValueTask Handle(
ModuleCreatedNotification notification,
CancellationToken cancellationToken
)
{
logger.LogCritical(
"Notification for: Created a new module with id: {Id} and name: {Name}. Writing audit log.",
notification.Id,
notification.Name
);
return ValueTask.CompletedTask;
}
}Behaviour Pipeline Order Diagram
The diagram below shows the configured behavior execution order. The first registered behavior runs first and wraps everything inside it.
graph TD
P[pipeline invocation] --> L[Logging.Handle]
L --> V[Validation.Handle]
V --> C[Caching.Handle]
C --> T[Transaction.Handle]
T --> H[Handler.Handle]
subgraph Registration Order
R1[1. LoggingBehaviour]
R2[2. ValidationBehaviour]
R3[3. CachingBehaviour]
R4[4. TransactionBehaviour]
end
R1 --> L
R2 --> V
R3 --> C
R4 --> TOutside-In Execution View
This view makes the two phases explicit:
- Request phase: outside-in from the first registered behavior to the handler.
- Response phase: inside-out as control returns through the same behaviors in reverse.
sequenceDiagram
participant EP as Endpoint
participant L as LoggingBehaviour
participant V as ValidationBehaviour
participant C as CachingBehaviour
participant T as TransactionBehaviour
participant H as Handler
EP->>L: Send(request)
Note right of L: outside-in entry
L->>V: before + next()
V->>C: before + next()
C->>T: before + next()
T->>H: before + next()
H-->>T: response
T-->>C: after
C-->>V: after
V-->>L: after
Note right of L: inside-out unwind
L-->>EP: response