- name
- dotnet-cqrs-api
- description
- Guide for building .NET APIs using CQRS pattern with MediatR, FluentValidation, and Carter modules Use when this capability is needed.
- metadata
- {"author":"alanben"}
# .NET CQRS API Development Skill
## Purpose
This skill guides the specification and creation of .NET APIs using the CQRS pattern with MediatR, FluentValidation, and Carter modules. It provides a structured approach to building feature-based, vertically-sliced APIs with robust validation and clear separation of concerns.
## Core Principles
### Architectural Philosophy
- **Vertical Slice Architecture**: Features are self-contained units organized by business capability
- **CQRS Separation**: Commands (write operations) and Queries (read operations) are explicitly separated
- **Result Pattern**: All operations return `Result<T>` for explicit success/failure handling
- **Functional Composition**: Prefer declarative, immutable approaches over imperative mutations
### Technology Stack
- **MediatR**: Implements mediator pattern for decoupling endpoint handlers from business logic
- **FluentValidation**: Provides declarative, strongly-typed validation rules
- **Carter**: Enables minimal API-style endpoint definition with module organization
- **Mapster** (inferred): Used for object mapping (`.Adapt<T>()` pattern)
## Project Structure
```
/Features
/{FeatureName}
- Add{Feature}.cs # Command endpoint + handler
- Update{Feature}.cs # Command endpoint + handler
- Get{Feature}.cs # Single item query endpoint + handler
- Get{Feature}s.cs # Collection query endpoint + handler
- {Feature}AddRequest.cs # Command DTO
- {Feature}UpdateRequest.cs # Command DTO
- {Feature}Response.cs # Query response DTO
```
## Feature Specification Process
### Step 1: Define the Feature Domain
Before writing code, establish:
1. **Feature Name**: Clear, business-focused identifier (e.g., "Order", "Customer", "Invoice")
2. **Business Context**: What problem does this feature solve?
3. **Operations Needed**: Which CQRS operations are required?
- Create (Add)
- Read Single (Get)
- Read Collection (GetAll/List)
- Update
- Delete (not in templates, but may be needed)
4. **Domain Model**: What properties define this entity?
5. **Validation Rules**: What business rules must be enforced?
6. **Authorization Requirements**: Who can access these endpoints?
### Step 2: Design the Data Contract
Define three types of DTOs:
**Request DTOs** (for commands):
- Contain only the data needed to perform the operation
- Exclude computed or system-managed fields (IDs, timestamps)
- Should be immutable or follow init-only patterns
**Response DTOs** (for queries):
- May inherit from domain models
- Include data needed by consumers
- Can include computed/derived properties
**Domain Models** (internal):
- Represent the entity in the data layer
- Should not be directly exposed via API
### Step 3: Specify Validation Rules
For each operation, define:
- Required fields and null checks
- Range validations (GreaterThan, LessThan)
- Format validations (Regex patterns, date formats)
- Business rule validations (cross-field rules)
- Custom validation logic requirements
### Step 4: Define Routing Strategy
Establish URL patterns following RESTful conventions:
- Collection routes: `/{feature-plural}` or `/Features/{FeatureName}`
- Item routes: `/{feature-plural}/{id}` or `/Features/{FeatureName}/{id}`
- Consider route parameters vs query strings
- Maintain consistency across features
## Implementation Templates
### Command Pattern (Add/Create)
**Use when**: Creating new entities
**Structure**:
```csharp
namespace {ProjectName}.Features.{FeatureName};
// 1. Carter Endpoint Module
public class Add{Feature}Endpoint : ICarterModule {
public void AddRoutes(IEndpointRouteBuilder app) {
app.MapPost("{route}", async ({Feature}AddRequest request, ISender sender) => {
var command = new Add{Feature}.Command { {Feature} = request };
var result = await sender.Send(command);
if (result.IsFailure) {
return Results.BadRequest(result.Error);
}
return Results.Ok(result.Value);
})
.Produces<int>() // Or appropriate return type
.RequireAuthorization("{PolicyName}")
.WithMetadata(new RouteMetadata { Tags = new[] { "{TagName}" } });
}
}
// 2. Command Definition
public static partial class Add{Feature} {
public class Command : IRequest<Result<int>> { // Or TResult
public {Feature}AddRequest? {Feature} { get; set; }
}
// 3. Validation Rules
public class Validator : AbstractValidator<Command> {
public Validator() {
RuleFor(x => x.{Feature})
.NotNull()
.WithMessage("No {feature} data provided.");
// Add specific field validations
RuleFor(x => x.{Feature}!.PropertyName)
.GreaterThan(0)
.WithMessage("PropertyName cannot be zero.");
}
}
// 4. Command Handler
internal sealed partial class {Feature}Handler : IRequestHandler<Command, Result<int>> {
private readonly ILogger<{Feature}Handler> _logger;
private readonly IValidator<Command> _validator;
private readonly I{Feature}Data _data;
public {Feature}Handler(
IValidator<Command> validator,
ILogger<{Feature}Handler> logger,
I{Feature}Data data
) {
_validator = validator;
_logger = logger;
_data = data;
}
public async Task<Result<int>> Handle(Command request, CancellationToken cancellationToken) {
// Validate
var validationResult = _validator.Validate(request);
if (!validationResult.IsValid) {
return Result.Failure<int>(new Error("Add{Feature}.Validation", validationResult.ToString()));
}
try {
// Map and execute
{Feature}Model new{Feature} = request.{Feature}!.Adapt<{Feature}Model>();
var {feature} = await _data.Add{Feature}(new{Feature});
if ({feature} is null) {
return Result.Failure<int>(new Error("Add{Feature}", "Failed to add {feature}"));
}
return {feature}.ID;
} catch (Exception ex) {
return Result.Failure<int>(new Error("Add{Feature}.Exception", ex.Message));
}
}
}
}
```
**Key Patterns**:
- Endpoint delegates to MediatR command via `ISender`
- Validation happens in handler, not endpoint
- Result pattern for explicit error handling
- Exception handling wraps unexpected failures
- Returns entity ID on success
### Query Pattern (Get Single)
**Use when**: Retrieving a specific entity by identifier
**Structure**:
```csharp
namespace {ProjectName}.Features.{FeatureName};
// 1. Carter Endpoint Module
public class Get{Feature}Endpoint : ICarterModule {
public void AddRoutes(IEndpointRouteBuilder app) {
app.MapGet("{route}/{id:int}", async (int id, ISender sender) => {
var query = new Get{Feature}.{Feature}Query { ID = id };
var result = await sender.Send(query);
if (result.IsFailure) {
return Results.NotFound(result.Error);
}
return Results.Ok(result.Value);
})
.Produces<{Feature}Response>()
.RequireAuthorization("{PolicyName}")
.WithMetadata(new RouteMetadata { Tags = new[] { "{TagName}" } });
}
}
// 2. Query Definition
public static class Get{Feature} {
public class {Feature}Query : IRequest<Result<{Feature}Response>> {
public int ID { get; set; } = 0;
// Additional filter properties
}
// 3. Validation Rules
public class Validator : AbstractValidator<{Feature}Query> {
public Validator() {
RuleFor(x => x.ID)
.GreaterThan(0)
.WithMessage("ID cannot be zero.");
}
}
// 4. Query Handler
internal sealed class Handler : IRequestHandler<{Feature}Query, Result<{Feature}Response>> {
private readonly ILogger<Handler> _logger;
private readonly IValidator<{Feature}Query> _validator;
private readonly I{Feature}Data _data;
public Handler(
ILogger<Handler> logger,
IValidator<{Feature}Query> validator,
I{Feature}Data data
) {
_logger = logger;
_validator = validator;
_data = data;
}
public async Task<Result<{Feature}Response>> Handle({Feature}Query request, CancellationToken cancellationToken) {
// Validate
var validationResult = _validator.Validate(request);
if (!validationResult.IsValid) {
return Result.Failure<{Feature}Response>(new Error("Get{Feature}.Validation", validationResult.ToString()));
}
// Retrieve and map
var {feature} = await _data.Get{Feature}(request.ID);
if ({feature} is null) {
return Result.Failure<{Feature}Response>(new Error(
"Get{Feature}.Null",
"The {feature} with the specified ID was not found"));
}
var response = {feature}.Adapt<{Feature}Response>();
// Optional: Enrich response with additional data
return response;
}
}
}
```
**Key Patterns**:
- Returns `NotFound` for missing entities
- Validation ensures query parameters are valid
- Response mapping allows projection/transformation
- Separate method for complex retrieval logic
### Query Pattern (Get Collection)
**Use when**: Retrieving multiple entities with filtering
**Structure**:
```csharp
namespace {ProjectName}.Features.{FeatureName};
// 1. Carter Endpoint Module
public class Get{Feature}sEndpoint : ICarterModule {
public void AddRoutes(IEndpointRouteBuilder app) {
app.MapGet("{route}", async (int param1, string param2, ISender sender) => {
var query = new Get{Feature}s.{Feature}Query {
Param1 = param1,
Param2 = param2
};
var result = await sender.Send(query);
if (result.IsFailure) {
return Results.NotFound(result.Error);
}
return Results.Ok(result.Value);
})
.Produces<IEnumerable<{Feature}Response>>()
.RequireAuthorization("{PolicyName}")
.WithMetadata(new RouteMetadata { Tags = new[] { "{TagName}" } });
}
}
// 2. Query Definition
public static class Get{Feature}s {
public class {Feature}Query : IRequest<Result<IEnumerable<{Feature}Response>>> {
public int Param1 { get; set; } = 0;
public string Param2 { get; set; } = string.Empty;
// Filter/pagination properties
}
// 3. Validation Rules
public class Validator : AbstractValidator<{Feature}Query> {
public Validator() {
RuleFor(x => x.Param1)
.GreaterThan(0)
.WithMessage("Param1 cannot be zero.");
// Date format validation example
RuleFor(x => x.DateParam)
.Matches(@"\d{4}-\d{2}-\d{2}")
.WithMessage("DateParam must be in the format yyyy-MM-dd.");
}
}
// 4. Query Handler
internal sealed class Handler : IRequestHandler<{Feature}Query, Result<IEnumerable<{Feature}Response>>> {
private readonly ILogger<Handler> _logger;
private readonly IValidator<{Feature}Query> _validator;
private readonly I{Feature}Data _data;
public Handler(
ILogger<Handler> logger,
IValidator<{Feature}Query> validator,
I{Feature}Data data
) {
_logger = logger;
_validator = validator;
_data = data;
}
public async Task<Result<IEnumerable<{Feature}Response>>> Handle({Feature}Query request, CancellationToken cancellationToken) {
// Validate
var validationResult = _validator.Validate(request);
if (!validationResult.IsValid) {
return Result.Failure<IEnumerable<{Feature}Response>>(new Error("Get{Feature}s.Validation", validationResult.ToString()));
}
// Retrieve collection
var {feature}s = await _data.List{Feature}s(/* filter params */);
if ({feature}s is null) {
return Result.Failure<IEnumerable<{Feature}Response>>(new Error(
"Get{Feature}s.Null",
"Failed to get {feature}s"));
}
var response = {feature}s.Adapt<List<{Feature}Response>>();
return response;
}
}
}
```
**Key Patterns**:
- Query parameters come from route or query string
- Returns collections (IEnumerable<T>)
- Validation includes format checks (dates, etc.)
- Consider pagination for large datasets
### Command Pattern (Update)
**Use when**: Modifying existing entities
**Structure**:
```csharp
namespace {ProjectName}.Features.{FeatureName};
// 1. Carter Endpoint Module
public class Update{Feature}Endpoint : ICarterModule {
public void AddRoutes(IEndpointRouteBuilder app) {
app.MapPut("{route}", async ({Feature}UpdateRequest request, ISender sender) => {
var command = new Update{Feature}.Command { {Feature} = request };
var result = await sender.Send(command);
if (result.IsFailure) {
return Results.BadRequest(result.Error);
}
return Results.Ok(result.Value);
})
.Produces<int>()
.RequireAuthorization("{PolicyName}")
.WithMetadata(new RouteMetadata { Tags = new[] { "{TagName}" } });
}
}
// 2. Command Definition
public static partial class Update{Feature} {
public class Command : IRequest<Result<int>> {
public {Feature}UpdateRequest? {Feature} { get; set; }
}
// 3. Validation Rules
public class Validator : AbstractValidator<Command> {
public Validator() {
RuleFor(x => x.{Feature})
.NotNull()
عرض على GitHub