Skip to content

CQRS Pattern

Command Query Responsibility Segregation using MediatR.

Overview

CQRS separates read operations (Queries) from write operations (Commands):

Type Purpose Returns Side Effects
Query Read data Data No
Command Write data Result/void Yes

File Structure

Features/{Feature}/
├── Commands/
│   ├── CreateFeatureCommand.cs
│   └── UpdateFeatureCommand.cs
├── Queries/
│   ├── GetFeatureQuery.cs
│   └── GetFeaturesQuery.cs
├── Models/
│   ├── FeatureRequest.cs
│   └── FeatureResponse.cs
└── Validators/
    └── FeatureRequestValidator.cs

Command Example

public sealed record CreateMarkerCommand(CreateMarkerRequest Request)
    : IRequest<MarkerResponse>, IRequestWithUserId, IRequestWithGroupId
{
    public Guid UserId { get; set; }
    public Guid GroupId { get; set; }
}

internal sealed class CreateMarkerCommandHandler(
    DataContext context,
    IDateTimeProvider dateTimeProvider)
    : IRequestHandler<CreateMarkerCommand, MarkerResponse>
{
    public async Task<MarkerResponse> Handle(
        CreateMarkerCommand request,
        CancellationToken cancellationToken)
    {
        var marker = new Marker
        {
            Title = request.Request.Title,
            Latitude = request.Request.Latitude,
            Longitude = request.Request.Longitude,
            UserId = request.UserId,
            GroupId = request.GroupId,
            CreatedDate = dateTimeProvider.UtcNow
        };

        context.Markers.Add(marker);
        await context.SaveChangesAsync(cancellationToken);

        return marker.ToResponse();
    }
}

Query Example

public sealed record GetMarkersQuery
    : IRequest<List<MarkerResponse>>, IRequestWithGroupId, ICacheable
{
    public Guid GroupId { get; set; }

    public string CacheKey => CacheConstants.Markers.List(GroupId);
    public TimeSpan CacheDuration => CacheConstants.DefaultDuration;
}

internal sealed class GetMarkersQueryHandler(DataContext context)
    : IRequestHandler<GetMarkersQuery, List<MarkerResponse>>
{
    public async Task<List<MarkerResponse>> Handle(
        GetMarkersQuery request,
        CancellationToken cancellationToken)
    {
        return await context.Markers
            .Where(m => m.GroupId == request.GroupId)
            .Where(m => !m.IsDeleted)
            .Select(m => m.ToResponse())
            .ToListAsync(cancellationToken);
    }
}

Pipeline Behaviors

MediatR pipeline handles cross-cutting concerns:

flowchart TB
    A[HTTP Request] --> B[Controller]
    B --> C[MediatR]
    C --> D[ValidationBehavior]
    D -->|Invalid| X[400 Error]
    D -->|Valid| E[UserIdBehavior]
    E --> F[GroupIdBehavior]
    F --> G[CachingBehavior]
    G -->|Cache Hit| Y[Cached Response]
    G -->|Cache Miss| H[Handler]
    H --> I[Response]
Behavior Purpose
ValidationBehavior Runs FluentValidation
CachingBehavior Caches query responses
UserIdBehavior Injects current user ID
GroupIdBehavior Injects current group ID

Marker Interfaces

See Pipeline Behaviors for the full list of marker interfaces.