| name | dotnet-cqrs-feature |
| description | Create CQRS command/query handlers with vertical slice architecture using MediatR and FluentValidation. Use when user says "add feature", "create command", "create query", "add CQRS handler", "vertical slice", "new use case", or asks to implement business operations. Do NOT use for domain entities or infrastructure. |
| metadata | {"author":"Claude-DotNet-Ultimate","version":"1.0.0","category":"application-layer"} |
CQRS Feature Creation (Vertical Slices)
File Structure
Each feature gets its own folder under src/Application/Features/:
Features/{Aggregate}/
├── Commands/
│ └── Create{Aggregate}/
│ ├── Create{Aggregate}Command.cs # Record + Handler
│ └── Create{Aggregate}CommandValidator.cs
└── Queries/
└── Get{Aggregate}/
├── Get{Aggregate}Query.cs # Record + Handler
└── Get{Aggregate}QueryValidator.cs
Creating a Command
Step 1: Command Record + DTOs + Handler
All in one file for cohesion:
namespace ClaudeDotNetUltimate.Application.Features.Products.Commands.CreateProduct;
public sealed record CreateProductCommand(
string Name,
decimal Price,
string Currency,
string? Description) : IRequest<Result<ProductDto>>;
public sealed record ProductDto(
Guid Id,
string Name,
decimal Price,
string Currency,
DateTime CreatedAt);
public sealed class CreateProductCommandHandler(
IUnitOfWork unitOfWork,
IMessageBus messageBus,
ILogger<CreateProductCommandHandler> logger) : IRequestHandler<CreateProductCommand, Result<ProductDto>>
{
public async Task<Result<ProductDto>> Handle(
CreateProductCommand request,
CancellationToken cancellationToken)
{
var price = Money.From(request.Price, request.Currency);
var product = Product.Create(request.Name, price);
await unitOfWork.Products.AddAsync(product, cancellationToken);
var saveResult = await unitOfWork.SaveChangesWithResultAsync(cancellationToken);
if (saveResult.IsFailure)
return Result<ProductDto>.Failure(saveResult.Error!);
foreach (var domainEvent in product.ExtractDomainEvents())
await messageBus.PublishAsync(domainEvent, cancellationToken);
logger.LogInformation("Product {ProductId} created: {Name}", product.Id, product.Name);
return new ProductDto(
product.Id.Value, product.Name,
product.Price.Amount, product.Price.Currency,
product.CreatedAt);
}
}
Step 2: Validator
namespace ClaudeDotNetUltimate.Application.Features.Products.Commands.CreateProduct;
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Product name is required")
.MaximumLength(200).WithMessage("Name cannot exceed 200 characters");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be greater than zero");
RuleFor(x => x.Currency)
.NotEmpty().WithMessage("Currency is required")
.Length(3).WithMessage("Currency must be 3 characters (ISO 4217)");
}
}
Creating a Query
namespace ClaudeDotNetUltimate.Application.Features.Products.Queries.GetProduct;
public sealed record GetProductQuery(Guid ProductId) : IRequest<Result<ProductDto>>;
public sealed class GetProductQueryHandler(
IUnitOfWork unitOfWork,
ICacheService cacheService,
ILogger<GetProductQueryHandler> logger) : IRequestHandler<GetProductQuery, Result<ProductDto>>
{
public async Task<Result<ProductDto>> Handle(
GetProductQuery request,
CancellationToken cancellationToken)
{
var cacheKey = $"product-{request.ProductId}";
var dto = await cacheService.GetOrCreateAsync(
cacheKey,
async ct =>
{
var product = await unitOfWork.Products.GetByIdAsync(
ProductId.From(request.ProductId), ct);
if (product is null)
return default;
return new ProductDto(
product.Id.Value, product.Name,
product.Price.Amount, product.Price.Currency,
product.CreatedAt);
},
cancellationToken);
return dto is null
? Result<ProductDto>.Failure(Error.NotFound("Product.NotFound", $"Product {request.ProductId} not found"))
: dto;
}
}
MediatR Pipeline Behaviors (Already Configured)
Requests automatically pass through:
- LoggingBehavior — Logs entry/exit/errors
- ValidationBehavior — Runs FluentValidation validators
- PerformanceBehavior — Detects slow requests (>500ms)
Rules
- Commands return
Result<TDto> — never throw for business failures
- Queries check cache first, then repository
- All commands have a corresponding validator
- Use primary constructors for DI
- DTOs are
sealed record types
- Handler publishes domain events after successful save
- Cache invalidation happens in command handlers
Checklist