One Scanner, Five Interfaces: How FastEndpoints Finds Your Code
AddFastEndpoints never asks you to register an endpoint. Reading the discovery code shows how a reflection scan over five interfaces becomes a validated endpoint catalogue - and which mistakes it turns into startup crashes on purpose. Part 2 of FastEndpoints in Depth.
The most seductive thing about FastEndpoints is what you don't write. No app.MapPost per route. No services.AddScoped<IValidator<CreateOrderRequest>, ...>. You declare classes; the library finds them. In Part 1 I called that “discovery replaces registration”. This part reads the discovery code — because auto-magic you don't understand is auto-magic you can't debug at 2 a.m. when an endpoint mysteriously isn't there.
Everything in this post comes from the open-source repository (the FastEndpoints main library, Main/EndpointData.cs and Main/MainExtensions.cs, plus the AssemblyScanner in the Core package). I'm quoting short excerpts where the mechanism itself is the lesson.
The scan: five interfaces and an exclusion attribute
AddFastEndpoints() runs once at service-registration time. It hands the assembly scanner a precise shopping list. From EndpointData's constructor:
// Src/Core + Src/Library/Main/EndpointData.cs (FastEndpoints, MIT)
var discoveredTypes = AssemblyScanner.ScanForTypes(
new()
{
...
ExcludeAttribute = Types.DontRegisterAttribute,
InterfaceTypes =
[
Types.IEndpoint,
Types.IEventHandler,
Types.ICommandHandler,
Types.ISummary,
opts.IncludeAbstractValidators ? Types.IValidator : Types.IEndpointValidator
]
});
That is the entire surface area of the magic: concrete classes implementing one of five interfaces — endpoints, event handlers, command handlers, Swagger summaries, validators — minus anything marked [DontRegister]. Entry-assembly plus referenced assemblies by default; you can widen it (o.Assemblies = ...), narrow it (o.Filter = ...), or turn auto-discovery off entirely.
Two practical consequences fall straight out of this list:
- If your endpoints live in a class library the scanner doesn't reach, they don't exist. The
EndpointDiscoveryOptions.Assembliesknob is the fix, and the failure is loud: if the scan finds nothing, startup throws"FastEndpoints was unable to find any endpoint declarations!"rather than silently serving 404s. - The fifth slot is interesting: by default only validators inheriting the library's
Validator<T>(which implements the markerIEndpointValidator) are auto-registered. Plain FluentValidationAbstractValidator<T>classes are ignored unless you opt in withIncludeAbstractValidators— a deliberate line between “validators that belong to endpoints” and “validators you use elsewhere”.
There is a second overload of AddFastEndpoints that takes source-generated type lists instead of scanning — that is the Native AOT path, and it gets its own article (Part 15).
Filing the catalogue
The scan produces a flat list of types. BuildEndpointDefinitions then files them into dictionaries: validators keyed by request DTO type, summaries keyed by endpoint type, mappers keyed by endpoint via the IHasMapper<TMapper> interface, and command/event handlers off to the messaging registry. Endpoints themselves become EndpointDefinition objects — the central data structure of the whole library. A definition carries the endpoint type, request/response DTO types, and eventually everything Configure() declares: verbs, routes, security, processors, throttle limits, all of it.
The keying choices are design decisions worth pausing on. A validator is matched to an endpoint by the request DTO type, not by naming convention or explicit registration. Elegant — and it means one DTO shared by two endpoints shares one validator. What if two validators exist for the same DTO? The code tracks that explicitly (a HasDuplicates flag per entry) and refuses to guess: the endpoint gets no auto-wired validator and you must pick one in Configure() or exclude one with [DontRegister]. Ambiguity is surfaced, not resolved silently.
Crashing on purpose
My favourite part of this file is how many mistakes it converts into startup exceptions. While building each definition it reflects over the endpoint's methods and enforces mutual exclusion:
// Src/Library/Main/EndpointData.cs (FastEndpoints, MIT)
switch (implementsHandleAsync)
{
case false when !implementsExecuteAsync:
throw new InvalidOperationException(
$"The endpoint [{x.tEndpoint.FullName}] must implement either [HandleAsync] or [ExecuteAsync] methods!");
case true when implementsExecuteAsync:
throw new InvalidOperationException(
$"The endpoint [{x.tEndpoint.FullName}] has both [HandleAsync] and [ExecuteAsync] methods implemented!");
}
The same treatment applies to configuration style: override Configure() or decorate the class with [HttpPost(...)]-style attributes — both at once is a startup crash with the offending type name in the message. Later, during route mapping, a definition with no verbs or no routes throws, and registering two endpoints on the same verb+route throws a duplicate-route error after logging every conflict.
There is a subtle trick behind that method check, and it is the kind of thing you only learn from source. HandleAsync and ExecuteAsync are both declared virtual on the base class with bodies that throw NotImplementedException — so how does reflection know whether you implemented one? The base declarations carry an internal [NotImplemented] attribute, and the check is "method exists AND is not tagged [NotImplemented]". Your override doesn't inherit the attribute; the base stub keeps it. Detection by attribute-absence. I have since borrowed this pattern for a plugin system: it is much cheaper than comparing MethodInfo.DeclaringType chains across a hierarchy.
Configure() runs once — on a throwaway instance
Here is the startup fact that most changes how you write endpoints: during UseFastEndpoints/MapFastEndpoints, the library creates one instance of every endpoint class — using a scoped service provider and a synthetic DefaultHttpContext — solely to run Configure() and capture the results into the EndpointDefinition:
// Src/Library/Main/MainExtensions.cs (FastEndpoints, MIT)
foreach (var def in endpoints.Found)
{
var ep = epFactory.Create(def, httpCtx);
def.Initialize(ep, httpCtx); // runs Configure() once
...
Cfg.EpOpts.Configurator?.Invoke(def); // apply global settings
Consequences:
Configure()is not a per-request hook. Anything you compute there is computed once, at boot, and frozen (the definition is literally flaggedIsLockedafterwards; late mutation attempts throw).- Your endpoint's constructor runs at startup at least once — with real DI. A constructor that does heavy work, or that resolves a scoped service and stores it expecting request scope, will behave surprisingly. Keep constructors trivial; FastEndpoints itself encourages property injection for services.
- The global
Configuratorruns after each endpoint's ownConfigure(), so platform-wide conventions (a shared route prefix, default processors, “everything requires auth unless stated”) layer cleanly over per-endpoint declarations. EndpointGroups slot into the same mechanism for subtree-level conventions.
After configuration, each verb+route pair is mapped into ASP.NET Core routing, endpoint metadata is attached, security policies are registered, and — if you enabled warm-up — binders and reflection caches are pre-compiled per endpoint so the first real request doesn't pay the lazy-initialisation tax. The log line at the end tells you exactly how many endpoints registered and how long discovery took; on the projects I have measured, a few hundred endpoints scan and register in tens of milliseconds.
What I take from this file
The discovery layer is small, readable, and opinionated in a specific direction: move every resolvable ambiguity to startup and crash there. Missing endpoints, duplicate validators, duplicate routes, conflicting configuration styles, missing handler methods — none of them survive to the first request. For a library whose main selling point is convention over registration, that is the only honest way to do it: magic plus silence is a debugging nightmare; magic plus loud, early, named-type failures is a convention you can trust.
What we have at the end of boot is a locked catalogue of EndpointDefinitions wired into ASP.NET Core routing. What happens when a request actually arrives — including a genuinely sneaky trick involving IResult — is Part 3.