# ADR 0008 — FastEndpoints instead of minimal APIs
- Status: accepted
- Date: 2026-07-30
- Supersedes, in part: [ADR 0002](0002-minimal-apis.md)
## Context
[ADR 0002](0002-minimal-apis.md) 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//` 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`**, 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, 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` 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`, 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()` 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`. 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](0005-no-application-layer.md) 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` 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` / 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` 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.