Files
DodoSSH/docs/adr/0008-fastendpoints.md
jaap-jan 9bc28f1c0f Move the API onto FastEndpoints, without moving the wire
Eight endpoints today, around sixty planned. The minimal-API shape — a static
class per area holding static local functions, route and policy and name
asserted in one fluent chain with the handler somewhere below it — has not hurt
yet, and would. A handler's dependencies are parameters rather than injected, a
group's RequireAuthorization sits far from the handler it governs, and there is
no type to hang an endpoint's own documentation on. FastEndpoints is one class
per endpoint, its route and authorization in Configure(), its handler a method
on the same type.

Nothing about the wire moves, and the evidence is that the 94 existing HTTP
tests pass with zero edits to any of them. Same routes, verbs, route
constraints, status codes, operation ids, and the same RFC 9457 bodies with the
same code values. Every place the idiomatic FastEndpoints answer would have
changed one of those, it was refused:

Endpoints are registered from an explicit List<Type>, not found by scanning.
ADR 0002 rejected reflection discovery by name, and the reason it gave is
sharper here than in general — under WebApplicationFactory the scan reaches the
test assembly, so an endpoint written in a test would be registered into the
host under test. The cost is a line per endpoint that can be forgotten, which is
what the endpoint-inventory test is for. That test is the one ADR 0002 promised
and never got.

Handlers still return Results<Ok<T>, NotFound, ProblemHttpResult> from
ExecuteAsync. The union executes as an ordinary IResult, which is what keeps
problem bodies going through the host's serialiser and IProblemDetailsService,
and what keeps the compile-time record of which statuses an endpoint can
produce. No Send.* call appears anywhere; the moment one does, a response has
left the host's serialiser.

Validation stays in the feature services. A Validator<T> short-circuits before
the handler and answers with FastEndpoints' own envelope, which carries no code
— and the code is the only part of an error the client branches on. Twenty-odd
tests assert a specific code on a 400. It is banned in BannedSymbols.txt rather
than merely avoided, because the framework's documentation leads straight to it
and it looks like an improvement.

Three defects arrived with the framework and were caught in review. All three
were green at the time, which is the part worth remembering. FastEndpoints maps
GET /_test_url_cache_ unconditionally, in every environment, with no policy and
no way to opt out; it answers with the whole endpoint-name-to-route table. It is
short-circuited to 404 — by asking routing which endpoint it selected, after the
first attempt compared the request path with Ordinal and was therefore bypassable
at /_TEST_URL_CACHE_, certified by a test that only ever tried one spelling. The
default request binder writes query-string values over the deserialised body,
which would have let ?identityProviderToken=... put an ID token in a URL and from
there into every proxy log on the path; every endpoint now binds from the body
alone. And a route value read with Route<T>() is invisible to ApiExplorer, so the
generated document named {vaultId} in a path template with nothing declaring it —
invalid OpenAPI, and unusable by the client generators the document exists for.

Two changes to the surface, both deliberate. A body that cannot be deserialised
now answers with a problem document carrying malformed-request, rather than an
empty 400: FastEndpoints' default announces application/problem+json while
sending something else, and names the failing .NET type on the wire, in a
codebase that sets IncludeErrorDetails = false to prevent exactly that. And the
route table above returns 404 where it would otherwise have answered any
authenticated caller.

Each of the three fixes has a regression test that was checked by reverting the
fix and watching it fail — four failures for the route table and the binder, four
for the document. That check is the whole reason to trust them, since all three
defects passed a full green suite on the way in.

950 tests green across 16 projects, 14 of them new and no existing test edited.
Zero warnings, format clean, locked restore clean. FluentValidation, JobQueues
and Messaging are in the graph now and none is used.

Not verified: the generated document's response schemas, which differ from
before — FastEndpoints contributes its own Produces metadata. Nothing consumes
the document yet, and MapOpenApi runs only in Development behind the fallback
policy. It needs pinning if ADR 0002's build-time artifacts/openapi/v1.json is
ever built.
2026-07-31 08:39:06 +02:00

9.2 KiB

ADR 0008 — FastEndpoints instead of minimal APIs

  • Status: accepted
  • Date: 2026-07-30
  • Supersedes, in part: ADR 0002

Context

ADR 0002 chose minimal APIs grouped into feature modules. That has worked for the eight endpoints M1 ships, but the shape it produces — a static class per area holding static local functions, with route, policy, name and summary asserted in one fluent chain and the handler somewhere below it — does not obviously scale to the ~60 endpoints the API is planned to have. The specific costs are that a handler's dependencies are parameters rather than constructor-injected, that a group's RequireAuthorization sits far from the handler it governs, and that there is no type to hang an endpoint's own documentation on.

FastEndpoints implements REPR: one class per endpoint, its route and authorization declared in Configure(), its dependencies injected, its handler a method on the same type.

Decision

FastEndpoints 8.2.0, with the endpoint classes living in the same Features/<Area>/ files the static modules occupied.

Everything about the wire is unchanged, deliberately and verifiably: the same eight routes, the same verbs and route constraints, the same status codes, the same operation ids, and the same RFC 9457 problem bodies with the same code values. The existing HTTP test suite passes without a single edit.

Five choices make that true, and each one is a place where the idiomatic FastEndpoints answer was rejected:

  • Endpoints are registered from an explicit List<Type>, not found by scanning. ADR 0002 rejected reflection discovery and the reason it gave applies with more force here, not less: under WebApplicationFactory the scan reaches the test assembly, so an endpoint written in a test would be registered into the host under test.
  • Handlers keep returning Results<Ok<T>, NotFound, ProblemHttpResult> as the endpoint's response type, from ExecuteAsync. The union executes as an ordinary IResult, which is what keeps problem bodies going through the host's serialiser and IProblemDetailsService — and what keeps the compile-time record of which statuses each endpoint can produce. None of the Send.* API is used.
  • Validation stays in the feature services. A Validator<T> short-circuits before the handler and answers with FastEndpoints' own envelope, which carries no code — and the code is the only part of an error the client branches on. Better than twenty tests assert a specific code on a 400. This is banned in BannedSymbols.txt rather than merely avoided, because it is the single most attractive wrong turn available: the framework's documentation leads straight to it and it looks like an improvement.
  • Request DTOs are bound from the JSON body only (BodyOnlyRequestBinder<T>, installed for every endpoint through Config.Endpoints.Configurator). FastEndpoints' default binder deserialises the body and then writes route values, query-string parameters, headers, claims and cookies over the top. Minimal APIs bound a body parameter from the body alone, so the default would have widened every request on this API — ?cursor=… overriding a pull cursor, and ?identityProviderToken=… letting an ID token travel in a URL, through every proxy log on the path.
  • OpenAPI stays on Microsoft.AspNetCore.OpenApi. Description(b => b.WithName(…)) writes the same operation ids, summaries and tags the minimal APIs wrote. It does not write the same parameters: a route value read with Route<T>() is invisible to ApiExplorer, so the document emitted /api/v1/vaults/{vaultId}/sync/pull with nothing declaring vaultId — a template expression with no parameter, which the specification forbids and no client generator can fill in. RouteParameterTransformer in Setup/OpenApi.cs puts them back, recovering the type from the route constraint rather than guessing it from the name. FastEndpoints.OpenApi would auto-tag every operation from the first path segment, advertise a bearer scheme that is not the OAuth2+PKCE scheme this API actually uses, and inject a module initializer through a build target — all for a Summary() mechanism we would not use.
  • Full absolute routes on every endpoint, no Group<T>. A group would save a repeated prefix and cost three startup-time failure modes, one of which — applying the group's policy to all four /me endpoints — would put Enrolled on DELETE /devices and turn today's 404 into a 403 with no failing test.

Two things about the surface did change, both on purpose:

  • A request body that cannot be deserialised now answers with a problem document (malformed-request) rather than an empty 400. FastEndpoints' default for this path announces application/problem+json while sending something else, and names the failing .NET type on the wire — in a codebase that sets IncludeErrorDetails = false on the bearer handler precisely to avoid that. Config.Errors.ResponseBuilder is replaced.
  • GET /_test_url_cache_ is answered 404 by middleware. UseFastEndpoints maps it unconditionally, in every environment, carrying neither a policy nor AllowAnonymous; it returns the server's entire endpoint-name-to-route table. There is no switch to turn it off. Deny-by- default means a caller must be authenticated to read it, which is not the same as it being nobody's business. The middleware matches on the endpoint routing selected, not on the request path — the first version compared the path with StringComparison.Ordinal and was therefore bypassable at /_TEST_URL_CACHE_, which a single-spelling test happily certified.

Consequences

  • The endpoint-inventory test ADR 0002 promised now exists (tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs) and asserts the complete route set — verb, pattern, operation id, tags, policies and anonymity. It is not optional under a hand-maintained type list: a type left off EndpointRegistration.cs is otherwise a route that quietly does not exist, with no compile error. It also brings /api/v1/meta and /.well-known/dodossh-configuration into a suite a developer actually runs before pushing — DodoSSH.SystemTests does fetch both anonymously, but only with a Docker daemon and several containers. That matters because FastEndpoints authenticates by default and both routes are unreachable-by-design if they 401.
  • FastEndpoints' serialiser is a copy of the host's JSON options, taken once while the pipeline is built, and the copy is process-wide and undocumented. AddDodoJson still configures the host, and EndpointRegistration applies the same settings to the copy explicitly rather than trusting it. A second WebApplicationFactory in the same test process would silently inherit the first host's JSON configuration; there is one host per assembly today, so this is inert — but it is the kind of thing that costs an afternoon.
  • FluentValidation, FastEndpoints.JobQueues and FastEndpoints.Messaging are now in the dependency graph and none of them is used. They are left as plain transitives.
  • ADR 0005 still stands and needed no amendment. No mediator, no behaviour pipeline, no validators and no mappers were introduced. An endpoint class replaced a static local function and still calls the same feature service inline, so "reading an endpoint means reading one file" is as true as it was — the file now holds two to four classes instead of one, and the ~80-line rule that ADR 0005 sets for extracting a service is unaffected.
  • The explicit type list is, as it happens, also the AOT-supported path. The two scanning overloads carry [RequiresUnreferencedCode]/[RequiresDynamicCode] and their message points at AddFastEndpoints(DiscoveredTypes.All); the List<Type> overload this uses carries neither. If trimming ever becomes a requirement, FastEndpoints.Generator produces that same list from source and nothing else here has to change.

Rejected

  • Assembly-scanning discovery (AddFastEndpoints()), for the reasons above.
  • Validator<T> / FluentValidation, which would replace every coded 400 with an uncoded one.
  • FastEndpoints' ErrorResponse and its ProblemDetails. Neither carries a code; the latter also points type at RFC 7231 anchors and makes errors an array of objects.
  • The default request binder. Its extra binding sources are not a feature this API wants, and the one that matters is not obvious from reading an endpoint: nothing in a Configure() method hints that the query string can reach the DTO. Rejected globally rather than per endpoint so that a future endpoint cannot opt into it by forgetting.
  • FastEndpoints.OpenApi, and Group<T> route prefixes, and the Send.* response API.
  • Staying on minimal APIs. Defensible — the objection is to a shape that has not hurt yet. The reason not to wait is that this migration is cheap at eight endpoints and gets linearly more expensive, and the contract hazards it surfaced — /_test_url_cache_, the query-string binding widening, and a strict unmapped-member rule that turned out to have no test at all — were worth finding either way.