Skip to content

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, and DeleteCommand.
  • Queries are represented by request records such as ListQuery and GetByIdQuery.
  • 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:

  • ISender and IPublisher are the request and notification entry points.
  • Dispatcher resolves 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

  1. Clearer intent per endpoint. Commands and queries are explicit request types, so each API operation states whether it modifies data or only reads data.

  2. Better separation of concerns. Minimal API endpoints in ModuleEndpoints stay thin and focus on HTTP concerns while handlers own business behavior.

  3. Consistent cross-cutting behavior. Logging, validation, caching, and transaction boundaries are applied uniformly through pipeline behaviors instead of duplicated code in endpoints or handlers.

  4. 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.

  5. Testability. Individual handlers and behaviors can be tested in isolation because they are small units behind stable interfaces (IRequestHandler<,>, IPipelineBehavior<,>).

  6. Extensible side effects. Notifications allow post-command concerns such as audit logging to be added as separate handlers without modifying core command handlers.

  7. 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:

  1. Logging
  2. Validation
  3. Caching
  4. Transaction

Execution order is outermost to innermost in the same order above, then the request handler.

High-Level Flow

mermaid
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.

mermaid
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 Created

Query Flow Example

Get module by id query with cache participation.

mermaid
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 NoContent

Request 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:

text
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:

csharp
public sealed record CreateCommand(string Name) : IRequest<Guid>, ITransactional;

Queries express read intent and can opt into caching:

csharp
public sealed record GetByIdQuery(Guid Id) : IRequest<ModuleDto?>, ICacheable
{
   public string CacheKey => $"module:{Id}";
   public TimeSpan Expiration => TimeSpan.FromMinutes(5);
}

2. Command Handler Example

csharp
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

csharp
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

csharp
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

csharp
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.

csharp
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

csharp
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.

mermaid
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 --> T

Outside-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.
mermaid
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

Resources