Rulegeneral
Api Endpoints Rule
Rules for ASP.NET minimal API endpoints
API Endpoints
Guidelines for implementing minimal API endpoints in the backend, including structure, route conventions, and usage patterns.
Implementation
- Create API endpoint classes in
/application/[scs-name]/Api/Endpoints, organized by feature area - Create an endpoint class implementing
IEndpointswith proper naming ([Feature]Endpoints.cs) - Define a constant string for
RoutesPrefix:/api/[scs-name]/[Feature]:private const string RoutesPrefix = "/api/account-management/users"; - Set up the route group with a tag name,
.RequireAuthorization(), and.ProducesValidationProblem():var group = routes.MapGroup(RoutesPrefix).WithTags("Users").RequireAuthorization().ProducesValidationProblem(); - Structure each endpoint in exactly 3 lines (no logic in the body):
- Line 1: Signature with route and parameters (don't break the line even if longer than 120 characters)
- Line 2: Expression calling
=> mediator.Send() - Line 3: Optional configuration (
.Produces<T>(),.AllowAnonymous(), etc.)
- Follow these requirements:
- Use Strongly Typed IDs for route parameters
- Return
ApiResult<T>for queries andApiResultorIRequest<Result<T>>for commands - Use
[AsParameters]for query parameters - Use
with { Id = id }syntax to bind route parameters to commands and queries
- After changing the API, run
build --backendto generate the OpenAPI JSON contract, thenbuild --frontendto triggeropenapi-typescript IEndpointsare automatically registered in the SharedKernel
Examples
Example 1 - User Endpoints
// ✅ DO: Structure endpoints in exactly 3 lines with no logic in the body
public sealed class UserEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/account-management/users";
public void MapEndpoints(IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(RoutesPrefix).WithTags("Users").RequireAuthorization().ProducesValidationProblem();
// ✅ DO: Use [AsParameters] for complex queries with many querystring parameters
group.MapGet("/", async Task<ApiResult<GetUsersResponse>> ([AsParameters] GetUsersQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<GetUsersResponse>();
group.MapDelete("/{id}", async Task<ApiResult> (UserId id, IMediator mediator)
=> await mediator.Send(new DeleteUserCommand(id))
);
group.MapPost("/bulk-delete", async Task<ApiResult> (BulkDeleteUsersCommand command, IMediator mediator)
=> await mediator.Send(command)
);
// ✅ DO: Use [AsParameters] even when the query has no parameters
group.MapGet("/me", async Task<ApiResult<UserResponse>> ([AsParameters] GetUserQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<UserResponse>(); // ✅ DO: Add produces when API returns a strongly typed response
}
}
// ❌ DON'T: Add business logic inside endpoint methods or break the 3-line structure
public sealed class BadUserEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/account-management/users";
public void MapEndpoints(IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(RoutesPrefix).WithTags("Users"); // ❌ DON'T: Skip .RequireAuthorization() even if all endpoints AllowAnonymous
group.MapGet("/", async (IMediator mediator, HttpContext context) =>
{
// ❌ DON'T: Add business logic inside endpoint methods
var tenantId = context.User.GetTenantId();
var query = new GetUsersQuery { TenantId = tenantId };
var result = await mediator.Send(query);
return Results.Ok(result);
});
// ❌ DON'T: Use Put for commands that do not update an existing resource
group.MapPut("/{id}/change-user-role", async Task<ApiResult> (
UserId id,
ChangeUserRoleCommand command,
IMediator mediator
) // ❌ DON'T: Break the line even if it extends 120 characters
=> await mediator.Send(command with { Id = id })
);
// ❌ DON'T: Use MVC [FromBody] attribute
group.MapPost("/bulk-delete", async Task<ApiResult> ([FromBody] BulkDeleteUsersCommand command, IMediator mediator)
=> await mediator.Send(command)
).Produces<UserId>(StatusCodes.Status201Created) // ❌ DON'T: Add produces status code
.ProducesProblem(StatusCodes.Status403Forbidden) // ❌ DON'T: Add produces status code
.ProducesProblem(StatusCodes.Status409Conflict);
// ❌ Forgot leading slash, newing up query instead of using [AsParameters]
group.MapGet("me", async Task<ApiResult<UserResponse>> (IMediator mediator)
=> await mediator.Send(new GetUserQuery())
).Produces<UserResponse>();
}
}