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.
Related¶
- Validation — Request validation
- Caching — Server-side caching
- Architecture — System design