| name | dotnet-cqrs-api |
| description | Guide for building .NET APIs using CQRS pattern with MediatR, FluentValidation, and Carter modules |
.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:
- Feature Name: Clear, business-focused identifier (e.g., "Order", "Customer", "Invoice")
- Business Context: What problem does this feature solve?
- 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)
- Domain Model: What properties define this entity?
- Validation Rules: What business rules must be enforced?
- 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:
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:
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
NotFoundfor 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:
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
) - Validation includes format checks (dates, etc.)
- Consider pagination for large datasets
Command Pattern (Update)
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:
- Verify entity exists before update
- Update request includes entity ID
- Returns updated entity ID
- BadRequest for validation failures
- Consider optimistic concurrency with version tokens
DTOs and Contracts
Request DTOs (Commands)
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:
- Use
initaccessors for immutability - Mark non-nullable properties as
required(C# 11+) - Exclude computed/system properties
- Consider separate DTOs for different update scenarios
Response DTOs (Queries)
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:
- Inherit from domain model if structures align
- Add view-specific computed properties
- Use init-only properties
- Default string properties to
string.Empty
Validation Strategies
FluentValidation Patterns
Required 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);
}
Error Handling Patterns
Result Pattern Implementation
The templates use a Result
// 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);
Error Categories
Organize errors by category for better diagnostics:
- Validation:
"{Feature}.Validation"- Input validation failures - NotFound:
"{Feature}.Null"or"{Feature}.NotFound"- Entity not found - Exception:
"{Feature}.Exception"- Unexpected errors - Business:
"{Feature}.BusinessRule"- Business rule violations
HTTP Status Code Mapping
200 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)
Dependency Injection Patterns
Required Registrations
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>
Handler Constructor Pattern
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:
- Constructor injection only
- Inject interfaces, not concrete types
- Use scoped lifetime for data services
- Use singleton for stateless services
Carter Module Configuration
Endpoint Registration
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
}
}
Routing Conventions
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 item
Nested resources:
GET /api/{parent}/{parentId}/{features}- Get child collectionGET /api/{parent}/{parentId}/{features}/{id}- Get child item
Testing Strategies
Unit Testing Handlers
Test 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
}
Integration Testing Endpoints
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);
}
Best Practices Summary
Do's ✓
- Organize by feature, not technical layer
- Validate in handlers, not endpoints
- Use Result
for explicit error handling - Separate commands and queries clearly
- Make DTOs immutable with init-only properties
- Use meaningful error messages with context
- Log exceptions with structured logging
- Document with OpenAPI metadata
- Test handlers independently from endpoints
- Follow RESTful conventions for routing
- Use CancellationToken for all async operations
- Apply authorization policies at endpoint level
Don'ts ✗
- Don't bypass validation "for convenience"
- Don't return domain models directly from endpoints
- Don't catch and swallow exceptions without logging
- Don't mix query and command logic in handlers
- Don't use primitive obsession - use value objects
- Don't skip null checks on nullable properties
- Don't hardcode strings - use constants or enums
- Don't forget CancellationToken parameter
- Don't create anemic DTOs - include business logic where appropriate
- Don't ignore validation errors - always check IsValid
Advanced Patterns
Pagination Support
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;
}
Sorting and Filtering
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; }
}
Soft Delete Pattern
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);
Audit Trail
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;
Enrichment Pattern
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;
}
Common Pitfalls and Solutions
Problem: Validation in Multiple Places
Solution: Centralize all validation in FluentValidation validators within handlers
Problem: Anemic Domain Models
Solution: Add behavior methods to domain models, not just properties
Problem: Tight Coupling to Data Layer
Solution: Use repository interfaces, inject via constructor
Problem: Missing Null Checks
Solution: Use nullable reference types, validate with FluentValidation
Problem: Poor Error Messages
Solution: Provide context in error messages - what failed and why
Problem: Testing Difficulties
Solution: Keep handlers focused, inject dependencies, use interfaces
Problem: Performance Issues with Large Collections
Solution: Implement pagination, filtering, and projection at data layer
Problem: Inconsistent HTTP Status Codes
Solution: Follow the error handling patterns consistently
Configuration Checklist
When implementing a new feature, verify:
- Feature organized in dedicated namespace/folder
- Request DTOs defined (Add, Update as needed)
- Response DTO defined
- Carter endpoint module created with correct HTTP verb
- Command/Query class defined with IRequest<Result
> - Validator class defined with validation rules
- Handler class implements IRequestHandler
- Handler validates input using validator
- Handler implements error handling with Result
- Handler returns appropriate result types
- Endpoint maps failures to HTTP status codes
- Authorization policy applied
- OpenAPI metadata configured
- Data interface and implementation created
- Dependencies registered in DI container
- Unit tests written for handler
- Integration tests written for endpoint
Reference: Full Feature Example
See the provided templates (AddXxxx.cs, GetXxxx.cs, GetXxxxs.cs, UpdateXxxx.cs) for complete, working examples of each pattern.
Summary
This skill provides a structured approach to building maintainable, testable .NET APIs using:
- CQRS for clear separation of reads and writes
- MediatR for loose coupling and testability
- FluentValidation for declarative validation rules
- Carter for clean endpoint organization
- Result pattern for explicit error handling
Follow these patterns consistently to create a cohesive, predictable API surface that's easy to understand, maintain, and extend.