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
IEndpointclasses, found by Scrutor- Errors
- ProblemDetails, one status per exception type
- Contract
obj/openapi.json, written on every build
How a request is handled
Section titled “How a request is handled”Middleware runs in a fixed order set in Program.cs. Then the endpoint turns the request into a message and hands it to Wolverine.
bus.InvokeAsyncJsonOptionsIExceptionHandler writes ProblemDetails| 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.
Endpoints: IEndpoint and the api/v1 group
Section titled “Endpoints: IEndpoint and the api/v1 group”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.
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:
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:
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.
Versioning
Section titled “Versioning”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:
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.
Errors become ProblemDetails
Section titled “Errors become ProblemDetails”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:
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.
Request binding errors
Section titled “Request binding errors”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.
<!-- 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.
Antiforgery, security headers and JSON
Section titled “Antiforgery, security headers and JSON”Cookie-authenticated calls that change data must echo an XSRF token. Every response gets security headers, and JSON uses strict numbers and string enums.
Antiforgery
Section titled “Antiforgery”| 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 |
// 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:
export const axiosInstance = axios.create({ baseURL: '/', withCredentials: true, xsrfCookieName: 'XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN', withXSRFToken: true,});Security headers
Section titled “Security headers”| 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.
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.
Map a new exception
Section titled “Map a new exception”| 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” |
Gotchas
Section titled “Gotchas”| 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.