| name | aspnet-minimal-api |
| description | ASP.NET Core Minimal API best practices for .NET 8. Use when building or reviewing Minimal API endpoints, route groups, IResult returns, Problem Details error handling, OpenAPI/Swagger setup, CancellationToken wiring, and endpoint extension method patterns. Covers SpaceOS.Kernel API layer conventions (kebab-case routes, plural nouns, Result<T> mapping, 201 Created with Location header). |
This skill provides authoritative patterns for ASP.NET Core Minimal API in .NET 8 LTS. Apply these rules when writing or reviewing any file in the SpaceOS.Kernel.Api project.
Core Principles
- No controllers. Minimal API only.
ControllerBase, [ApiController], [Route] attributes are forbidden.
IResult everywhere. Every endpoint returns IResult — never a raw object or void.
- Delegate to MediatR. Endpoint lambdas contain zero business logic. One line:
mediator.Send(...).
CancellationToken end-to-end. Every endpoint accepts CancellationToken ct and passes it to mediator.Send().
Route Conventions
/api/{resource} GET list, POST create
/api/{resource}/{id:guid} GET by id, PUT update
/api/{parent}/{parentId:guid}/{child} GET child list, POST create child
Rules:
- kebab-case —
flow-epics, work-stations, space-layers
- plural nouns —
/api/tenants not /api/tenant
{id:guid} constraint — always typed, never raw {id}
- No verbs in URLs —
/api/flow-epics/{id}/delegate is acceptable for FSM transitions
Endpoint Extension Method Pattern
Every aggregate gets its own static class. Program.cs only calls Map*Endpoints().
public static class TenantEndpoints
{
public static IEndpointRouteBuilder MapTenantEndpoints(
this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/tenants")
.WithTags("Tenants")
.WithOpenApi();
group.MapGet("/", GetAllAsync);
group.MapGet("/{id:guid}", GetByIdAsync);
group.MapPost("/", CreateAsync);
group.MapPut("/{id:guid}", UpdateAsync);
return app;
}
private static async Task<IResult> GetAllAsync(
IMediator mediator, CancellationToken ct)
{
var result = await mediator
.Send(new GetAllTenantsQuery(), ct)
.ConfigureAwait(false);
return result.ToApiResult();
}
private static async Task<IResult> GetByIdAsync(
Guid id, IMediator mediator, CancellationToken ct)
{
result = mediator
.Send( GetTenantByIdQuery(id), ct)
.ConfigureAwait();
result.ToApiResult();
}
{
result = mediator
.Send( CreateTenantCommand(request.Name), ct)
.ConfigureAwait();
result.ToCreatedResult(, r => { id = r.Id });
}
{
result = mediator
.Send( UpdateTenantNameCommand(id, request.Name), ct)
.ConfigureAwait();
result.ToApiResult();
}
}
Program.cs stays clean:
app.MapTenantEndpoints();
app.MapFacilityEndpoints();
app.MapWorkStationEndpoints();
app.MapSpaceLayerEndpoints();
app.MapFlowEpicEndpoints();
Result → IResult Mapping
Single shared extension — create once in Api/Extensions/ResultExtensions.cs:
public static class ResultExtensions
{
public static IResult ToApiResult<T>(this Result<T> result) =>
result.Status switch
{
ResultStatus.Ok => Results.Ok(result.Value),
ResultStatus.NotFound => Results.Problem(
title: "Resource Not Found",
detail: result.Errors.FirstOrDefault(),
statusCode: 404,
type: "https://httpstatuses.io/404"),
ResultStatus.Invalid => Results.ValidationProblem(
result.ValidationErrors.ToDictionary(
e => e.Identifier,
e => new[] { e.ErrorMessage })),
ResultStatus.Error => Results.Problem(
title: "An error occurred",
detail: result.Errors.FirstOrDefault(),
statusCode: 500,
type: "https://httpstatuses.io/500"),
_ => Results.Problem(statusCode: 500)
};
public static IResult ToCreatedResult<T>(
this Result<T> result,
routeName,
Func<T, > routeValues) =>
result.Status
{
ResultStatus.Ok => Results.CreatedAtRoute(
routeName,
routeValues(result.Value),
result.Value),
ResultStatus.Invalid => Results.ValidationProblem(
result.ValidationErrors.ToDictionary(
e => e.Identifier,
e => [] { e.ErrorMessage })),
_ => Results.Problem(statusCode: )
};
=>
result.Status
{
ResultStatus.Ok => Results.NoContent(),
ResultStatus.NotFound => Results.Problem(statusCode: ,
type: ),
ResultStatus.Invalid => Results.ValidationProblem(
result.ValidationErrors.ToDictionary(
e => e.Identifier,
e => [] { e.ErrorMessage })),
_ => Results.Problem(statusCode: )
};
}
HTTP Status Code Semantics
| Operation | Success | Validation fail | Not found | Server error |
|---|
| GET | 200 OK | — | 404 Problem Details | 500 Problem Details |
| POST (create) | 201 Created + Location | 422 ValidationProblem | — | 500 Problem Details |
| PUT (update) | 200 OK | 422 ValidationProblem | 404 Problem Details | 500 Problem Details |
| FSM transition (PUT) | 200 OK | 422 ValidationProblem | 404 Problem Details | 500 Problem Details |
Never use 400 Bad Request for validation. FluentValidation failures → 422 Unprocessable Entity.
400 Bad Request is reserved for DomainException (business rule violations).
Problem Details (RFC 7807)
Every non-2xx response must include:
{
"type": "https://httpstatuses.io/404",
"title": "Resource Not Found",
"status": 404,
"detail": "Tenant with id '...' was not found.",
"instance": "/api/tenants/00000000-0000-0000-0000-000000000000"
}
Global exception middleware catches unhandled exceptions:
internal sealed class ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context).ConfigureAwait(false);
}
catch (DomainException ex)
{
logger.LogWarning(ex, "Domain rule violation on {Path}", context.Request.Path);
context.Response.StatusCode = 400;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Type = "https://httpstatuses.io/400",
Title = "Domain Rule Violation",
Status = 400,
Detail = ex.Message,
Instance = context.Request.Path
}).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception on {Path}", context.Request.Path);
context.Response.StatusCode = 500;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Type = "https://httpstatuses.io/500",
Title = "Internal Server Error",
Status = ,
Detail = ,
Instance = context.Request.Path
}).ConfigureAwait();
}
}
}
Register before routing in Program.cs:
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseRouting();
OpenAPI / Swagger (.NET 8 — Swashbuckle v6)
.NET 8 uses Swashbuckle, not the built-in AddOpenApi() (that is .NET 9+).
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.*" />
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "SpaceOS Kernel API",
Version = "v1",
Description = "ConTech & PropTech Operating System"
});
});
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c =>
c.SwaggerEndpoint("/swagger/v1/swagger.json", "SpaceOS v1"));
}
Endpoint metadata for Swagger:
group.MapGet("/{id:guid}", GetByIdAsync)
.WithName("GetTenantById")
.WithSummary("Get tenant by ID")
.Produces<TenantDto>(200)
.ProducesProblem(404)
.WithOpenApi();
Request Records
Thin, no validation — FluentValidation in Application layer handles it:
public sealed record CreateTenantRequest(string Name);
public sealed record UpdateTenantNameRequest(string Name);
One file per aggregate: TenantRequests.cs, FacilityRequests.cs, etc.
Program.cs Full Template (.NET 8)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "SpaceOS Kernel API", Version = "v1"
}));
var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c =>
c.SwaggerEndpoint("/swagger/v1/swagger.json", "SpaceOS v1"));
}
app.MapGet("/healthz", () => Results.Ok(new { status = "healthy" }))
.WithTags("Health")
.ExcludeFromDescription();
app.MapTenantEndpoints();
app.MapFacilityEndpoints();
app.MapWorkStationEndpoints();
app.MapSpaceLayerEndpoints();
app.MapFlowEpicEndpoints();
app.Run();
public partial class Program { }
Integration Test Setup
public sealed class SpaceOsApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor is not null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(o =>
o.UseSqlite("DataSource=:memory:"));
});
}
public async Task InitializeAsync()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync().ConfigureAwait(false);
}
public new Task DisposeAsync() => Task.CompletedTask;
}
public partial class Program { } in Program.cs is required — without it WebApplicationFactory<Program> cannot reference the entry point.
Anti-Patterns — Never Generate These
[ApiController]
public class TenantsController : ControllerBase { }
app.MapGet("/api/tenants/{id}", async (Guid id) => await repo.GetByIdAsync(id));
app.MapPost("/api/tenants", async (CreateTenantRequest req) => {
if (req.Name.Length > 100) return Results.BadRequest("Too long");
});
app.MapGet("/api/tenants", async (IMediator mediator) =>
await mediator.Send(new GetAllTenantsQuery()));
Results.BadRequest("Name is required");
builder.Services.AddOpenApi();