12 KiB
Endpoint Mapping
How HTTP endpoints are automatically generated from commands and queries.
Overview
The MapSvrntyCommands() and MapSvrntyQueries() extension methods use metadata discovery to automatically create Minimal API endpoints for all registered commands and queries.
No manual controller code required!
Discovery Process
┌──────────────────────────────┐
│ Application Startup │
└─────────────┬────────────────┘
│
▼
┌──────────────────────────────┐
│ MapSvrntyCommands() │
│ 1. Get ICommandDiscovery │
│ 2. Get all registered │
│ command metadata │
└─────────────┬────────────────┘
│
▼
┌──────────────────────────────┐
│ For Each Command: │
│ 1. Check [IgnoreCommand] │
│ 2. Get command name │
│ 3. Get handler type │
│ 4. Get result type │
│ 5. Create endpoint │
└─────────────┬────────────────┘
│
▼
┌──────────────────────────────┐
│ Generated Endpoint: │
│ POST /api/command/{name} │
│ - Model binding │
│ - Validation │
│ - Authorization │
│ - Handler invocation │
│ - Response formatting │
└──────────────────────────────┘
Command Mapping
MapSvrntyCommands
app.MapSvrntyCommands();
This generates:
// For each registered command
app.MapPost("/api/command/{commandName}", async (
[FromBody] TCommand command,
[FromServices] ICommandHandler<TCommand, TResult> handler,
[FromServices] IValidator<TCommand>? validator,
[FromServices] ICommandAuthorizationService<TCommand>? authService,
HttpContext httpContext,
CancellationToken cancellationToken) =>
{
// Validate
if (validator != null)
{
var validationResult = await validator.ValidateAsync(command, cancellationToken);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
}
// Authorize
if (authService != null)
{
var canExecute = await authService.CanExecuteAsync(
command,
httpContext.User,
cancellationToken);
if (!canExecute)
{
return Results.Forbid();
}
}
// Execute
var result = await handler.HandleAsync(command, cancellationToken);
// Return result
return result != null ? Results.Ok(result) : Results.NoContent();
})
.WithTags("Commands")
.WithOpenApi();
Custom Route Prefix
app.MapSvrntyCommands("my-commands");
// POST /my-commands/{commandName}
Ignore Specific Commands
[IgnoreCommand]
public record InternalCommand { }
// No endpoint generated
Query Mapping
MapSvrntyQueries
app.MapSvrntyQueries();
This generates TWO endpoints per query:
GET Endpoint
app.MapGet("/api/query/{queryName}", async (
[AsParameters] TQuery query,
[FromServices] IQueryHandler<TQuery, TResult> handler,
[FromServices] IQueryAuthorizationService<TQuery>? authService,
HttpContext httpContext,
CancellationToken cancellationToken) =>
{
// Authorize
if (authService != null)
{
var canExecute = await authService.CanExecuteAsync(
query,
httpContext.User,
cancellationToken);
if (!canExecute)
{
return Results.Forbid();
}
}
// Execute
var result = await handler.HandleAsync(query, cancellationToken);
return Results.Ok(result);
})
.WithTags("Queries")
.WithOpenApi();
POST Endpoint
app.MapPost("/api/query/{queryName}", async (
[FromBody] TQuery query,
[FromServices] IQueryHandler<TQuery, TResult> handler,
[FromServices] IQueryAuthorizationService<TQuery>? authService,
HttpContext httpContext,
CancellationToken cancellationToken) =>
{
// Same as GET but query comes from body
})
.WithTags("Queries")
.WithOpenApi();
Naming Resolution
Default Naming
Command/Query class names are converted to endpoints:
| Class Name | Endpoint |
|---|---|
CreateUserCommand |
/api/command/createUser |
GetUserQuery |
/api/query/getUser |
SearchProductsQuery |
/api/query/searchProducts |
UpdateOrderStatusCommand |
/api/command/updateOrderStatus |
Rules:
- Remove "Command" or "Query" suffix
- Convert to lowerCamelCase
- Preserve numbers and underscores
Custom Names
Use [CommandName] or [QueryName] attributes:
[CommandName("users/create")]
public record CreateUserCommand { }
// Endpoint: POST /api/command/users/create
[QueryName("products/search")]
public record SearchProductsQuery { }
// Endpoints:
// GET /api/query/products/search
// POST /api/query/products/search
Model Binding
Commands (POST only)
POST /api/command/createUser
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com"
}
Model binding deserializes JSON to command object.
Queries (GET)
GET /api/query/searchProducts?category=Electronics&minPrice=100&maxPrice=500
Model binding maps query string parameters to query properties.
Queries (POST)
POST /api/query/searchProducts
Content-Type: application/json
{
"category": "Electronics",
"minPrice": 100,
"maxPrice": 500
}
Model binding deserializes JSON to query object.
Validation Integration
Automatic Validation
If IValidator<TCommand> is registered:
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
The endpoint automatically validates before calling the handler.
Validation failure:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Name is required"]
}
}
Authorization Integration
Automatic Authorization
If authorization service is registered:
builder.Services.AddScoped<ICommandAuthorizationService<DeleteUserCommand>, DeleteUserCommandAuthorization>();
The endpoint checks authorization before execution.
Authorization failure:
HTTP/1.1 403 Forbidden
Response Types
Commands Without Result
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
public async Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken)
{
// Delete user
}
}
Response:
HTTP/1.1 204 No Content
Commands With Result
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
return newUserId;
}
}
Response:
HTTP/1.1 200 OK
Content-Type: application/json
42
Queries
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
return userDto;
}
}
Response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}
OpenAPI Integration
Automatic Tags
All command endpoints are tagged with "Commands":
{
"paths": {
"/api/command/createUser": {
"post": {
"tags": ["Commands"],
...
}
}
}
}
All query endpoints are tagged with "Queries":
{
"paths": {
"/api/query/getUser": {
"get": {
"tags": ["Queries"],
...
},
"post": {
"tags": ["Queries"],
...
}
}
}
}
Request/Response Schemas
Swagger automatically documents request and response types:
{
"components": {
"schemas": {
"CreateUserCommand": {
"type": "object",
"properties": {
"name": { "type": "string" },
"email": { "type": "string" }
}
},
"UserDto": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"email": { "type": "string" }
}
}
}
}
}
Content Negotiation
Default: JSON
Accept: application/json
Content-Type: application/json
XML Support (Optional)
builder.Services.AddControllers()
.AddXmlSerializerFormatters();
Accept: application/xml
Content-Type: application/xml
Error Handling
Validation Errors (400)
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { ... }
}
Authorization Failures (403)
HTTP/1.1 403 Forbidden
Not Found (404)
throw new KeyNotFoundException("User not found");
HTTP/1.1 404 Not Found
Unhandled Exceptions (500)
HTTP/1.1 500 Internal Server Error
Customization
Custom Endpoint Configuration
app.MapSvrntyCommands(options =>
{
options.RoutePrefix = "my-commands";
options.RequireAuthorization = true;
options.AllowAnonymous = false;
});
Per-Endpoint Customization
After mapping, you can customize individual endpoints:
var commandEndpoints = app.MapSvrntyCommands();
// Customize specific endpoint
commandEndpoints
.Where(e => e.DisplayName == "CreateUser")
.RequireAuthorization("AdminOnly");
Testing Endpoints
Integration Tests
public class CreateUserCommandTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public CreateUserCommandTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateUser_WithValidData_Returns200()
{
var command = new { name = "John Doe", email = "john@example.com" };
var response = await _client.PostAsJsonAsync("/api/command/createUser", command);
response.EnsureSuccessStatusCode();
var userId = await response.Content.ReadFromJsonAsync<int>();
Assert.True(userId > 0);
}
[Fact]
public async Task CreateUser_WithInvalidData_Returns400()
{
var command = new { name = "", email = "invalid" };
var response = await _client.PostAsJsonAsync("/api/command/createUser", command);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
Best Practices
✅ DO
- Use MapSvrntyCommands() and MapSvrntyQueries()
- Let the framework handle endpoint generation
- Use [IgnoreCommand]/[IgnoreQuery] for internal operations
- Rely on automatic validation and authorization
- Use OpenAPI tags for organization
- Test endpoints with integration tests
❌ DON'T
- Don't create manual controllers for CQRS operations
- Don't bypass validation or authorization
- Don't expose internal commands via HTTP
- Don't skip error handling
- Don't ignore HTTP status codes