This solution showcases how to build efficient Minimal APIs in .NET 8 by combining the CQRS pattern with custom Mediator extensions. The focus is on two distinct styles of endpoint registration:
- Explicit Approach: Uses explicit route definitions with exception-based handling hooks for fine-grained control.
- Simple Approach: Employs a result-based pattern to implicitly manage errors and reduce boilerplate.
By comparing these methods side by side, you’ll understand the trade-offs in readability, error handling, and extensibility, while still generating the same OpenAPI definitions for seamless client generation.
This .NET 8 solution contains three projects:
-
MinimalApiCqrs.Core
Contains the sample endpoint registration. -
MinimalApiCqrs.Explicit
Demonstrates the explicit approach for Minimal APIs. Exceptions are thrown from handlers and caught on the route definitions. -
MinimalApiCqrs.Simple
Shows a simpler, more implicit style using a Result pattern with error classes instead of throwing.
Each project follows this layout:
/<ProjectName>
/Core ← "wiring" of app (startup-like)
/Todos ← sample CRUD endpoints for a "Todo" domain
The project follows the Command Query Responsibility Segregation (CQRS) pattern, which separates the operations that change data from the operations that read data. The CQRS pattern helps to create a more scalable, maintainable, and testable architecture.
The structure of a command or query follows the naming convention:
[NameOfOperation]Command.cs
[NameOfOperation]CommandHandler.cs
[NameOfOperation]CommandResponse.cs
(optional)[NameOfOperation]CommandValidator.cs
(optional)
For queries, only the validator is optional.
Commands represent operations that change data, such as creating, updating, or deleting entities. Each command consists of four parts:
-
Command: A class that encapsulates the data required for the operation. It should be a plain old C# object (POCO) with properties that match the data that needs to be sent.
-
Command Handler: A class that implements the
ICommandHandler<TCommand, TResult>
interface and contains the logic to perform the operation. The command handler is responsible for loading any necessary data, validating the input, and updating the data store. -
Command Response (optional): A class that encapsulates the result of the operation. It should be a plain old C# object (POCO) with properties that match the data that needs to be returned.
-
Command Validator (optional): A class that uses Fluent Validation, a popular .NET library for building strongly-typed validation rules, to validate the input data for the command. The validator should be a separate class to keep the validation logic separate from the command logic.
Queries represent operations that read data, such as retrieving a list of entities or a single entity. Each query consists of four parts:
-
Query: A class that encapsulates the data required for the operation. It should be a plain old C# object (POCO) with properties that match the data that needs to be sent.
-
Query Handler: A class that implements the
IQueryHandler<TQuery, TResult>
interface and contains the logic to perform the operation. The query handler is responsible for loading any necessary data, validating the input, and returning the result. -
Query Response: A class that encapsulates the result of the operation. It should be a plain old C# object (POCO) with properties that match the data that needs to be returned.
-
Query Validator (optional): A class that uses Fluent Validation to validate the input data for the query. The validator should be a separate class to keep the validation logic separate from the query logic.
The project uses FluentValidation, which is a popular .NET library for building strongly-typed validation rules. The project includes an example of a validator for the CreateTodoCommand
in the CreateTodoCommandValidator.cs
file.
internal class CreateTodoCommandValidator : AbstractValidator<CreateTodoCommand>
{
public CreateTodoCommandValidator()
{
this.RuleFor(t => t.Title)
.NotEmpty()
.MinimumLength(5)
.MaximumLength(20);
this.RuleFor(t => t.Description)
.NotEmpty()
.MaximumLength(100);
}
}
In the Explicit project, each endpoint definition wires up success and exception handlers by name. Exceptions thrown in handlers propagate to the route, where .OnException()
catches them:
public class TodoEndpoints : IEndpointMapper
{
public static void MapEndpoints(IEndpointRouteBuilder app)
{
var group = app
.MapGroup("/explicit/todos")
.WithTags("Explicit Todos");
group
.RegisterGetRoute<GetTodosQuery, GetTodosQueryResponse[]>("")
.OnSuccess(SuccessHandler.Default)
.OnException(ExceptionHandler.Default)
.WithName("GetExplicitTodos");
group
.RegisterGetRoute<GetTodoQuery, GetTodoQueryResponse>("{todoId}")
.OnSuccess(SuccessHandler.Default)
.OnException(ExceptionHandler.Default)
.OnException(ExceptionHandler.NotFound)
.WithName("GetExplicitTodo");
group
.RegisterPostFromBodyRoute<CreateTodoCommand, CreateTodoCommandResponse>("")
.OnSuccess(SuccessHandler.Default)
.OnException(ExceptionHandler.Default)
.WithName("CreateExplicitTodo");
group
.RegisterPostRoute<CompleteTodoCommand>("{todoId}/complete")
.OnSuccess(SuccessHandler.Default)
.OnException(ExceptionHandler.Default)
.OnException(ExceptionHandler.NotFound)
.WithName("CompleteExplicitTodo");
group
.RegisterDeleteRoute<DeleteTodoCommand>("{todoId}")
.OnSuccess(SuccessHandler.Default)
.OnException(ExceptionHandler.Default)
.WithName("DeleteExplicitTodo");
}
}
In the Simple project, you register CQRS routes that return a Result<T>
or Result
object. Flow errors are wrapped in Result
and handled automatically:
public class TodoEndpoints : IEndpointMapper
{
public static void MapEndpoints(IEndpointRouteBuilder app)
{
var group = app
.MapGroup("/simple/todos")
.WithTags("Simple Todos");
group.MapGetCQRS<GetTodosQuery, GetTodosQueryResponse[]>("");
group.MapGetCQRS<GetTodoQuery, GetTodoQueryResponse>("{todoId}");
group.MapPostCQRSFromBody<CreateTodoCommand, CreateTodoCommandResponse>("");
group.MapPostCQRS<CompleteTodoCommand>("{todoId}/complete");
group.MapDeleteCQRS<DeleteTodoCommand>("{todoId}");
}
}
Aspect | Explicit Approach | Simple Approach |
---|---|---|
Error Handling | Throws exceptions; caught per-route via .OnException |
Uses Result pattern; no exceptions for control flow |
Readability | Verbose pipeline—clear success/exception hooks | Concise route mapping; less boilerplate |
Consistency | Fine-grained control over specific exception mappings | Uniform handling of all errors through Result extensions |
Performance | Minimal overhead, but exception creation is costlier | Slightly faster in happy path (no exception allocation) |
Learning Curve | Familiar for those used to try/catch | Requires understanding of Result and its extensions |
Both approaches produce nearly identical OpenAPI definitions, providing the same endpoints, request/response models, and tags—making client generation (e.g., via NSwag or AutoRest) straightforward.