dotnet-cqrs-api/SKILL.md
Guide for building .NET APIs using CQRS pattern with MediatR, FluentValidation, and Carter modules
npx skillsauth add alanben/claudeskills dotnet-cqrs-apiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
Result<T> for explicit success/failure handling.Adapt<T>() pattern)/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
Before writing code, establish:
Define three types of DTOs:
Request DTOs (for commands):
Response DTOs (for queries):
Domain Models (internal):
For each operation, define:
Establish URL patterns following RESTful conventions:
/{feature-plural} or /Features/{FeatureName}/{feature-plural}/{id} or /Features/{FeatureName}/{id}Use when: Creating new entities
Structure:
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:
ISenderUse when: Retrieving a specific entity by identifier
Structure:
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:
NotFound for missing entitiesUse when: Retrieving multiple entities with filtering
Structure:
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:
Use when: Modifying existing entities
Structure:
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()
.WithMessage("No {feature} data provided.");
RuleFor(x => x.{Feature}!.ID)
.GreaterThan(0)
.WithMessage("ID cannot be zero.");
// Additional field validations
}
}
// 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("Update{Feature}.Validation", validationResult.ToString()));
}
try {
// Verify entity exists
var existing{Feature} = await _data.Get{Feature}(request.{Feature}!.ID);
if (existing{Feature} is null) {
return Result.Failure<int>(new Error("Update{Feature}", "The {feature} with the specified ID was not found"));
}
// Perform update
await _data.Update{Feature}(/* updated values */);
return existing{Feature}.ID;
} catch (Exception ex) {
return Result.Failure<int>(new Error("Update{Feature}.Exception", ex.Message));
}
}
}
}
Key Patterns:
namespace {ProjectName}.Features.{FeatureName};
public class {Feature}AddRequest {
public required string PropertyName { get; init; }
public int NumericProperty { get; init; }
// Only properties needed for creation
// Exclude: ID, CreatedDate, UpdatedDate
}
public class {Feature}UpdateRequest {
public required int ID { get; init; } // Identity required
public required string PropertyName { get; init; }
public int NumericProperty { get; init; }
// Properties that can be updated
}
Best Practices:
init accessors for immutabilityrequired (C# 11+)namespace {ProjectName}.Features.{FeatureName};
public class {Feature}Response {
public int ID { get; init; }
public string PropertyName { get; init; } = string.Empty;
public int NumericProperty { get; init; }
public DateTime CreatedDate { get; init; }
// Include all properties consumers need
// Can include computed properties
}
Best Practices:
string.EmptyRequired Field Validation:
RuleFor(x => x.Property)
.NotNull()
.WithMessage("Property is required.");
RuleFor(x => x.StringProperty)
.NotEmpty()
.WithMessage("StringProperty cannot be empty.");
Range Validation:
RuleFor(x => x.Age)
.GreaterThan(0)
.WithMessage("Age must be greater than zero.")
.LessThanOrEqualTo(150)
.WithMessage("Age must be realistic.");
RuleFor(x => x.Percentage)
.InclusiveBetween(0, 100)
.WithMessage("Percentage must be between 0 and 100.");
Format Validation:
RuleFor(x => x.Email)
.EmailAddress()
.WithMessage("Invalid email format.");
RuleFor(x => x.DateString)
.Matches(@"\d{4}-\d{2}-\d{2}")
.WithMessage("Date must be in yyyy-MM-dd format.");
RuleFor(x => x.PhoneNumber)
.Matches(@"^\+?[1-9]\d{1,14}$")
.WithMessage("Invalid phone number format.");
Nested Object Validation:
RuleFor(x => x.Address)
.NotNull()
.WithMessage("Address is required.")
.SetValidator(new AddressValidator()); // Use separate validator
Conditional Validation:
RuleFor(x => x.CompanyName)
.NotEmpty()
.When(x => x.CustomerType == CustomerType.Business)
.WithMessage("Company name is required for business customers.");
Cross-Field Validation:
RuleFor(x => x.EndDate)
.GreaterThan(x => x.StartDate)
.WithMessage("End date must be after start date.");
Custom Validation:
RuleFor(x => x.Username)
.Must(BeUniqueUsername)
.WithMessage("Username already exists.");
private bool BeUniqueUsername(string username) {
// Custom validation logic
return !_userRepository.UsernameExists(username);
}
The templates use a Result<T> pattern for explicit error handling:
// Success case
return Result.Success(value);
return value; // Implicit conversion
// Failure case
return Result.Failure<T>(new Error("Category.Operation", "Description"));
// Checking results
if (result.IsFailure) {
return Results.BadRequest(result.Error);
}
return Results.Ok(result.Value);
Organize errors by category for better diagnostics:
"{Feature}.Validation" - Input validation failures"{Feature}.Null" or "{Feature}.NotFound" - Entity not found"{Feature}.Exception" - Unexpected errors"{Feature}.BusinessRule" - Business rule violations200 OK: Successful query/command400 Bad Request: Validation failure, business rule violation404 Not Found: Entity not found401 Unauthorized: Authentication failure403 Forbidden: Authorization failure500 Internal Server Error: Unhandled exceptions (should be rare)For each feature, register:
// MediatR handlers (usually auto-registered)
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
// FluentValidation validators
services.AddValidatorsFromAssembly(assembly);
// Carter modules
services.AddCarter();
// Data access services
services.AddScoped<I{Feature}Data, {Feature}Data>();
// Logging is automatically available via ILogger<T>
public {Feature}Handler(
IValidator<Command> validator, // FluentValidation validator
ILogger<{Feature}Handler> logger, // Structured logging
I{Feature}Data data // Data access
// Add other dependencies as needed
) {
_validator = validator;
_logger = logger;
_data = data;
}
Best Practices:
public class {Feature}Endpoint : ICarterModule {
public void AddRoutes(IEndpointRouteBuilder app) {
app.MapPost("{route}", handler)
.Produces<TResponse>() // Documents response type
.ProducesProblem(400) // Documents error responses
.RequireAuthorization("{Policy}") // Authorization policy
.WithMetadata(new RouteMetadata { // OpenAPI metadata
Tags = new[] { "{TagName}" }
})
.WithName("{OperationId}") // OpenAPI operation ID
.WithDescription("{Description}"); // OpenAPI description
}
}
RESTful routes:
GET /api/{features} - Get collectionGET /api/{features}/{id} - Get single itemPOST /api/{features} - Create new itemPUT /api/{features} - Update item (ID in body)PUT /api/{features}/{id} - Update item (ID in route)DELETE /api/{features}/{id} - Delete itemNested resources:
GET /api/{parent}/{parentId}/{features} - Get child collectionGET /api/{parent}/{parentId}/{features}/{id} - Get child itemTest handlers in isolation:
[Fact]
public async Task Handle_ValidCommand_ReturnsSuccess() {
// Arrange
var validator = new Add{Feature}.Validator();
var logger = new Mock<ILogger<Add{Feature}.{Feature}Handler>>();
var data = new Mock<I{Feature}Data>();
data.Setup(x => x.Add{Feature}(It.IsAny<{Feature}Model>()))
.ReturnsAsync(new {Feature}Model { ID = 1 });
var handler = new Add{Feature}.{Feature}Handler(validator, logger.Object, data.Object);
var command = new Add{Feature}.Command { /* ... */ };
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value);
}
[Fact]
public async Task Handle_InvalidCommand_ReturnsValidationFailure() {
// Test validation failures
}
[Fact]
public async Task Handle_DataLayerException_ReturnsFailure() {
// Test exception handling
}
Test full request/response cycle:
[Fact]
public async Task Post_{Feature}_ValidRequest_Returns200() {
// Arrange
var client = _factory.CreateClient();
var request = new {Feature}AddRequest { /* ... */ };
// Act
var response = await client.PostAsJsonAsync("/api/{features}", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var id = await response.Content.ReadFromJsonAsync<int>();
id.Should().BeGreaterThan(0);
}
public class {Feature}Query : IRequest<Result<PagedResult<{Feature}Response>>> {
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 20;
// Filter properties
}
public class PagedResult<T> {
public IEnumerable<T> Items { get; init; } = Enumerable.Empty<T>();
public int TotalCount { get; init; }
public int PageNumber { get; init; }
public int PageSize { get; init; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => PageNumber > 1;
public bool HasNext => PageNumber < TotalPages;
}
public class {Feature}Query : IRequest<Result<IEnumerable<{Feature}Response>>> {
public string? SortBy { get; set; }
public bool SortDescending { get; set; }
public string? SearchTerm { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
}
public class Delete{Feature}.Command : IRequest<Result<bool>> {
public int ID { get; set; }
}
// Handler marks as deleted rather than removing
await _data.SoftDelete{Feature}(request.ID);
public abstract class AuditableEntity {
public DateTime CreatedDate { get; init; }
public string CreatedBy { get; init; } = string.Empty;
public DateTime? ModifiedDate { get; set; }
public string? ModifiedBy { get; set; }
}
// Apply in handler
entity.CreatedBy = _currentUserService.Username;
entity.CreatedDate = DateTime.UtcNow;
For queries that need additional data:
private async Task<{Feature}Response> Enrich{Feature}Response({Feature}Model {feature}) {
var response = {feature}.Adapt<{Feature}Response>();
// Add related data
response.RelatedItems = await _data.GetRelatedItems({feature}.ID);
// Add computed properties
response.DisplayName = $"{feature.FirstName} {feature.LastName}";
return response;
}
Solution: Centralize all validation in FluentValidation validators within handlers
Solution: Add behavior methods to domain models, not just properties
Solution: Use repository interfaces, inject via constructor
Solution: Use nullable reference types, validate with FluentValidation
Solution: Provide context in error messages - what failed and why
Solution: Keep handlers focused, inject dependencies, use interfaces
Solution: Implement pagination, filtering, and projection at data layer
Solution: Follow the error handling patterns consistently
When implementing a new feature, verify:
See the provided templates (AddXxxx.cs, GetXxxx.cs, GetXxxxs.cs, UpdateXxxx.cs) for complete, working examples of each pattern.
This skill provides a structured approach to building maintainable, testable .NET APIs using:
Follow these patterns consistently to create a cohesive, predictable API surface that's easy to understand, maintain, and extend.
development
Update and maintain core repository documentation files (README.md, CHANGELOG.md, LICENSE, CONTRIBUTING.md) before commits or releases. Use when users need to update documentation to reflect code changes, prepare for releases, or ensure documentation consistency.
development
Re-index all PDF and HTML documents, update index.html, and commit/push changes to the repository
tools
Generate .NET Core data API code from model definitions using the Maker XML specification and CLI. Use when users need to: (1) Create Maker XML models from JSON objects, SQL scripts, or database schemas, (2) Generate .NET Core CRUD API endpoints from Maker XML, (3) Understand or validate Maker XML model definitions, or (4) Work with the Maker CLI tool for code generation.
development
Comprehensive skill for manipulating Microsoft PowerPoint presentations using Aspose.Slides.NET library with modern C# patterns