Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20269 min read

API conventions

Every endpoint is an IEndpoint class mapped under /api/v1. Domain exceptions become ProblemDetails responses, the OpenAPI document is built at compile time, and cookie calls need an XSRF token.

Base path
/api/v1
Endpoints
IEndpoint classes, found by Scrutor
Errors
ProblemDetails, one status per exception type
Contract
obj/openapi.json, written on every build

Middleware runs in a fixed order set in Program.cs. Then the endpoint turns the request into a message and hands it to Wolverine.

Middlewareheaders, tenancy, auth, antiforgery
IEndpointbinds the request, calls bus.InvokeAsync
Handlerreturns a result or throws
Successa typed result, serialized with JsonOptions
Exceptionan IExceptionHandler writes ProblemDetails
Figure 1. One pipeline for every endpoint in every module.
Order Middleware Does
1 UseForwardedHeaders Reads the client IP, scheme and host from the proxy (X-Forwarded-For, X-Forwarded-Proto, X-Original-Host)
2 UseAppointMeSecurityHeaders Adds the security headers to every response
3 UseHsts (outside Development), UseHttpsRedirection HTTPS only
4 UseExceptionHandler Runs the exception handlers listed below
5 UseStaticFiles Serves the built SPA from wwwroot
6 UseAppointMeMultiTenancy Reads X-Company-Id. See Multi-tenancy.
7 UseAuthentication, UseAuthorization Signs the caller in, then applies the endpoint’s policy
8 UseAppointMeAntiforgery Checks the XSRF token on unsafe /api calls

Authentication uses a policy scheme named Hybrid. A request with an Authorization: Bearer … header goes to JWT bearer validation, and anything else to the appointme.auth cookie, which the OIDC code flow sets. The provider is Keycloak or Entra External ID, picked by Authentication:Provider.

A fallback authorization policy applies to every endpoint: it needs a signed-in, registered user unless the endpoint calls .AllowAnonymous(), as login, signup, the demo login and GET /api/v1/me do. The details are on Identity & principal.

An endpoint is a class that implements IEndpoint. Each module’s assembly is scanned at startup, and every endpoint is mapped into one api/v1 route group.

src/AppointMe.Shared/Endpoints/IEndpoint.csView source ↗
public interface IEndpoint
{
void MapEndpoint(IEndpointRouteBuilder builder);
}

Each module’s registration calls AddEndpoints with its assembly. The false passed to AddClasses turns off Scrutor’s public-only filter, so endpoint classes can be internal sealed:

src/AppointMe.Shared/Endpoints/EndpointsServiceCollectionExtensions.csView source ↗
public IServiceCollection AddEndpoints(Assembly assembly)
{
return services.Scan(scan => scan
.FromAssemblies(assembly)
.AddClasses(classes => classes.AssignableTo<IEndpoint>(), false)
.AsImplementedInterfaces()
.WithTransientLifetime());
}

app.MapEndpoints() in Program.cs builds the group and hands it to every endpoint:

src/AppointMe.Shared/Endpoints/EndpointsApplicationBuilderExtensions.csView source ↗
public WebApplication MapEndpoints()
{
var apiVersionSet = app
.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1))
.ReportApiVersions()
.Build();
var versionedGroup = app
.MapGroup("api/v1")
.WithApiVersionSet(apiVersionSet);
var endpoints = app.Services
.GetRequiredService<IEnumerable<IEndpoint>>();
foreach (var endpoint in endpoints)
{
endpoint.MapEndpoint(versionedGroup);
}
return app;
}

So builder.MapPost("/customers", …) in RegisterCustomerEndpoint serves POST /api/v1/customers. The endpoints follow these conventions:

Convention Example
internal sealed class <Slice>Endpoint : IEndpoint, in the slice folder RegisterCustomerEndpoint
Routes without the prefix, plural nouns, typed route values /customers/{id:guid}
.WithName(nameof(Handler)) on every route, which becomes the OpenAPI operationId RegisterCustomer, generated as useRegisterCustomer
[FromBody] for a JSON body, [AsParameters] for a query-string class RegisterCustomerRequest, GetCustomersRequest
The handler maps to a message and calls bus.InvokeAsync, nothing more Every endpoint except login, logout and the demo login, which work with the auth schemes directly
Typed results TypedResults.Created(...), Results.NoContent(), or the response DTO

The full recipe for a new endpoint is Add an endpoint.

There’s one API version. The registration below sets version 1 as the default, and MapEndpoints attaches a version set with HasApiVersion(new ApiVersion(1)) and ReportApiVersions() to the group:

src/AppointMe.Api/ApiVersioning/ApiVersioningExtensions.csView source ↗
services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1);
options.ReportApiVersions = true;
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
});

The group path is the literal text api/v1, not a {version:apiVersion} route template, and AssumeDefaultVersionWhenUnspecified makes every request version 1. Nothing maps a v2 today. Adding one means a second group in MapEndpoints.

Handlers throw the exceptions in AppointMe.Shared/Domain/Errors. A chain of IExceptionHandlers turns each one into a ProblemDetails response with a fixed status code.

Exception Status title The message goes to
BadHttpRequestException The exception’s own: 400, or 413 for an oversized body Bad Request detail
ValidationException 400 Validation Error error. detail is always “One or more validation errors occurred”.
AccessDeniedException 403 Access denied detail
NotFoundException 404 Resource Not Found detail
ConflictException 409 Conflict error, plus code when the exception has one
DbUpdateConcurrencyException, thrown or as the inner exception 409 Conflict A fixed detail, plus code: "concurrency_conflict"
Anything else 500 Internal Server Error A fixed detail: “Unexpected error occurred.”

ASP.NET Core asks the handlers in registration order and stops at the first one that handles the exception. GlobalExceptionHandler handles everything, so it’s last:

src/AppointMe.Api/ErrorHandling/ErrorHandlingServiceCollectionExtensions.csView source ↗
return services
.AddProblemDetails()
.AddExceptionHandler<BadHttpRequestExceptionHandler>()
.AddExceptionHandler<ValidationExceptionHandler>()
.AddExceptionHandler<NotFoundExceptionHandler>()
.AddExceptionHandler<ConflictExceptionHandler>()
.AddExceptionHandler<AccessDeniedExceptionHandler>()
.AddExceptionHandler<ConcurrencyExceptionHandler>()
.AddExceptionHandler<GlobalExceptionHandler>();

The SPA reads error, then detail, then title (src/lib/query-client.ts). It shows a global toast only for 5xx and network errors, and leaves 4xx handling to the component that made the call.

In Development, ASP.NET Core’s ThrowOnBadRequest is on by default. A missing required value, an unparsable route or query value, or malformed JSON then throws BadHttpRequestException, and BadHttpRequestExceptionHandler returns it as a 400 with the binder’s message in detail, such as Failed to bind parameter "int Limit" from "abc". Outside Development the framework writes the 400 itself, without throwing, so the handler isn’t involved. Kestrel limits, such as an oversized body, throw in every environment and keep their own status code.

The OpenAPI document is built at compile time

Section titled “The OpenAPI document is built at compile time”

Every dotnet build of the API writes src/AppointMe.Api/obj/openapi.json, and the frontend’s typed client is generated from that file.

src/AppointMe.Api/AppointMe.Api.csprojView source ↗
<!-- Build-time OpenAPI document (Microsoft.Extensions.ApiDescription.Server): every build writes
obj/openapi.json (a build artifact, never committed), and the frontend's orval generates the API
client from it. -->
<OpenApiDocumentsDirectory>obj</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>
Piece What it does
The Codegen environment The build starts the app with appsettings.Codegen.json placeholders, no database and Wolverine as an in-memory mediator. CodeGenerationDetection.IsRunningGeneration() tells startup code it’s in this run.
InvalidateOpenApiDocumentsOnReferenceChange A target in AppointMe.Api.csproj that regenerates the document when a module assembly changes, since the endpoints live in the modules
PermissionSchemaTransformer, RoleSchemaTransformer Describe Permission as a string enum of every registered permission code, and Role as one of the built-in role names
.ExcludeFromDescription() Keeps an endpoint out of the document, as the demo login does
npm run generate:api Builds the API, then runs orval on obj/openapi.json to write src/AppointMe.Frontend/src/api/client.ts

In Development the running API also serves the document at https://localhost:7233/openapi/v1.json. How the generated client is used is on Typed API client.

Cookie-authenticated calls that change data must echo an XSRF token. Every response gets security headers, and JSON uses strict numbers and string enums.

Piece Value
Request-token cookie XSRF-TOKEN, readable by JavaScript, Secure, SameSite=Lax, issued on every GET
Header that echoes it X-XSRF-TOKEN
Cookie token, HttpOnly appointme.csrf
Checked on Any method except GET, HEAD, OPTIONS and TRACE, under /api, without a bearer header
On failure 400, title Antiforgery Validation Error
src/AppointMe.Api/Antiforgery/AntiforgeryMiddleware.csView source ↗
// Bearer requests carry no ambient credential, so they cannot be forged cross-site;
// only cookie-backed requests need the token pair. Non-/api surfaces protect themselves
// (Hangfire ships its own antiforgery, the OIDC callback is covered by state/correlation).
private static bool RequiresValidation(HttpRequest request) =>
request.Path.StartsWithSegments(ApiPrefix) && !request.HasBearerTokenHeader();

The token is reissued on every GET because it’s bound to the signed-in identity, so the first GET after login or logout refreshes it. The SPA’s axios instance echoes it with no extra code:

src/AppointMe.Frontend/src/lib/axios.tsView source ↗
export const axiosInstance = axios.create({
baseURL: '/',
withCredentials: true,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
withXSRFToken: true,
});
Header Value
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy-Report-Only default-src 'self' and related directives. It’s report-only while the policy is tuned against the SPA.
Strict-Transport-Security From UseHsts, outside Development: 365 days, with includeSubDomains

SecurityHeadersMiddleware sets its headers in Response.OnStarting, so they survive the exception handler clearing the response. A test in AppointMe.Api.Tests covers that case.

src/AppointMe.Api/Json/JsonOptionsExtensions.csView source ↗
return services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.SerializerOptions.Converters.Add(new RoleJsonConverter());
});

Strict number handling rejects a number sent as a string, such as "10". Enums travel as names ("Female"), and roles travel as their name.

Step Where What
1. Add the exception AppointMe.Shared/Domain/Errors/ Derive from DomainException, which carries an optional Code
2. Write the handler AppointMe.Api/ErrorHandling/ An internal sealed IExceptionHandler that sets the status and writes through IProblemDetailsService
3. Register it ErrorHandlingServiceCollectionExtensions .AddExceptionHandler<…>() anywhere before GlobalExceptionHandler
4. Test it AppointMe.Api.Tests/ErrorHandling/ Follow BadHttpRequestExceptionHandlerTests: status, body and “doesn’t handle other exceptions”
Symptom Cause Fix
400 Antiforgery Validation Error from curl or Postman The call used the auth cookie but no X-XSRF-TOKEN header Copy the XSRF-TOKEN cookie from a GET into the header, or call with a bearer token
The UI says “One or more validation errors occurred” The client read detail Read error first, as query-client.ts does
A ValidationException code never reaches the client ValidationExceptionHandler doesn’t write Code. Only ConflictExceptionHandler passes an exception’s Code through as code. Branch on the status and message, or add code to ValidationExceptionHandler
DELETE /api/v1/customers/{id} returns 200, but PUT returns 204 The delete endpoint’s handler returns a plain Task, and the update returns Results.NoContent() Return Results.NoContent() if you want 204 everywhere
dotnet build fails in the OpenAPI step after you add startup code The build-time run has no database and placeholder config Skip that code when CodeGenerationDetection.IsRunningGeneration() is true, as Program.cs does for Hangfire
Why exceptions instead of a Result type?2 min · optional

A handler returns its success value and throws for the failure cases, and one folder, AppointMe.Api/ErrorHandling, decides what each failure looks like over HTTP. Handlers stay short, the value objects can validate from anywhere (Email.Create throws, wherever it’s called), and an exception thrown in a handler comes back out of InvokeAsync, where the exception handlers pick it up.

The cost: the failure cases aren’t visible in a handler’s signature, and exceptions cost more than return values. With a handful of exception types and failures that end a request anyway, that trade is cheap here.

Why generate the OpenAPI document at build time?1 min · optional

Generating from a running API needs the backend up and a workaround for its local TLS certificate. A build-time document needs only dotnet build, and a contract change shows up as a diff in src/api/ when you regenerate. The price is the Codegen environment: startup code that needs real infrastructure has to check for it.