Skip to content

CQRS Pattern

Command Query Responsibility Segregation using MiniatR.

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 class CreateMarkerCommand : IRequest<MarkerDetailResponse>, IRequestWithCurrentGroupId, ICacheInvalidatingCommand
{
    public CreateMarkerCommand(MarkerRequest model)
    {
        Model = model;
    }

    public MarkerRequest Model { get; }
    public Guid CurrentGroupId { get; set; }
    public IReadOnlyList<string> TagsToInvalidate => [CacheTags.Group(CurrentGroupId), CacheTags.Markers];
}

internal sealed class CreateMarkerCommandHandler : IRequestHandler<CreateMarkerCommand, MarkerDetailResponse>
{
    private readonly DataContext _context;

    public CreateMarkerCommandHandler(DataContext context)
    {
        _context = context;
    }

    public async Task<MarkerDetailResponse> Handle(CreateMarkerCommand request, CancellationToken cancellationToken)
    {
        var marker = new Marker
        {
            Title = request.Model.Title,
            Latitude = request.Model.Latitude,
            Longitude = request.Model.Longitude,
            GroupId = request.GroupId
        };

        _context.Add(marker);
        await _context.SaveChangesAsync(cancellationToken);

        return new MarkerDetailResponse { MarkerId = marker.MarkerId, ... };
    }
}

Query Example

public sealed record GetMarkersQuery
    : IRequest<List<MarkerResponse>>, IRequestWithCurrentGroupId, ICacheableQuery
{
    public Guid CurrentGroupId { get; set; }

    public string CacheKey => CacheKeys.Markers(CurrentGroupId);
    public TimeSpan CacheDuration => CacheDurations.Default;
    public IReadOnlyList<string> Tags => [CacheTags.Group(CurrentGroupId), CacheTags.Markers];
}

internal sealed class GetMarkersQueryHandler : IRequestHandler<GetMarkersQuery, List<MarkerResponse>>
{
    private readonly DataContext _context;

    public GetMarkersQueryHandler(DataContext context)
    {
        _context = context;
    }

    public async Task<List<MarkerResponse>> Handle(GetMarkersQuery request, CancellationToken cancellationToken)
    {
        return await _context.Markers
            .AsNoTracking()
            .Select(m => m.ToResponse())
            .ToListAsync(cancellationToken);
    }
}

Group scoping is applied by the named GroupScope query filter — handlers never add GroupId == predicates (see Group Scoping).

Pipeline Behaviors

MiniatR pipeline handles cross-cutting concerns. Validation is handled separately via ASP.NET Core auto-validation before the request reaches the controller.

flowchart TB
    A[HTTP Request] --> V[ASP.NET Validation]
    V -->|Invalid| X[400 Error]
    V -->|Valid| B[Controller]
    B --> C[MiniatR]
    C --> D[CurrentUserIdBehavior]
    D --> E[CurrentGroupIdBehavior]
    E --> F[CachingBehavior]
    F -->|Cache Hit| Y[Cached Response]
    F -->|Cache Miss| G[Handler]
    G --> H[CacheInvalidationBehavior]
    H --> I[Response]
Behavior Purpose
CurrentUserIdBehavior Injects current user ID
CurrentGroupIdBehavior Injects current group ID
CachingBehavior Caches query responses
CacheInvalidationBehavior Invalidates cache tags after commands

Marker Interfaces

See Pipeline Behaviors for the full list of marker interfaces.