The Resolver Behind Everything
Mappers, validators, processors, even the Resolve<T>() you call in a handler - almost none of it goes through your DI container the way you assume. A static ServiceResolver with two caches sits behind everything, and reading it explains both the speed and the sharp edges. Part 5 of FastEndpoints — The Missing Chapters.
The FastEndpoints NuGet package quietly brings two more along for the ride: FastEndpoints.Core and FastEndpoints.Attributes. In eighteen months of using the library I had never opened either. The parent series covered the assembly scanner's policy — which interfaces it hunts for, what it rejects (Part 2) — and it established that validators live as singletons (Part 5). But it never answered the mechanical question underneath both: who actually constructs these objects, and where do they live?
The answer is one internal class in FastEndpoints.Core: ServiceResolver. Once you have read it, half of the library's lifetime behaviour stops being folklore and becomes engineering. This part reads it, the scanner it ships beside, and the little Attributes package whose csproj file is more interesting than its code.
A static resolver, on purpose
ServiceResolver (in Src/Core/ServiceResolver/ServiceResolver.cs) implements the library's IServiceResolver abstraction and is exposed to the entire codebase through a static Instance property. Everything funnels through it. When you call Resolve<T>() inside an endpoint, a mapper, or even the HttpContext.Resolve<T>() extension method — which pointedly does not use httpContext.RequestServices directly — you end up here.
The class is constructed with three things: the root IServiceProvider, an optional IHttpContextAccessor, and an isUnitTestMode flag (the testing package builds one by hand; if you skip that setup, Instance greets you with "Have you done the unit test setup correctly?"). Resolution logic is simple and deliberate:
Resolve<T>()prefers the current request'sRequestServices(via the accessor) and falls back to the root provider — so scoped services work inside a handler, and background work still resolves from the root.- Two
ConcurrentDictionarycaches do the heavy lifting: a_factoryCacheof compiledObjectFactorydelegates (built withActivatorUtilities.CreateFactory) for per-call construction, and a_singletonCacheof already-built instances.
That second cache is the one that explains the folklore.
CreateSingleton: the container that isn't
When FastEndpoints needs a validator, a mapper (Part 1 of this series), or a pre/post-processor instance, it does not resolve it from your IServiceCollection. It calls:
// ServiceResolver.CreateSingleton (paraphrased from Src/Core/ServiceResolver)
_singletonCache.GetOrAdd(
type,
t => ActivatorUtilities.GetServiceOrCreateInstance(RequestServices ?? provider, t));
GetServiceOrCreateInstance means: if you happened to register the type in DI, that registration wins; otherwise the resolver constructs it itself, satisfying constructor parameters from the provider. Either way, the result is cached forever in a process-wide dictionary. Your container's lifetime configuration is irrelevant after the first resolution — register a validator as scoped and it will still behave as a singleton, because the library keeps the first instance it ever got.
This is the mechanical truth behind the “validators are singletons” rule, and it is why the mapper base classes shout “do not maintain state in the mappers” in every XML doc. It is also why these objects are fast: no per-request container work at all, just a dictionary hit.
Once you know this, the design of those base classes makes sense too. A mapper can still call Resolve<T>() for scoped services because resolution routes through the static resolver, which prefers the ambient request scope. The object is a process singleton; its service lookups are request-aware. That's a subtle contract, and it's nowhere in the docs — it's in this file.
The TryResolve asymmetry
Here is the detail that earns ServiceResolver its place in this series. Resolve<T>() falls back from the request scope to the root provider. TryResolve<T>() does not — when a request scope exists, it resolves solely from that scope. The source explains why, in one of the best comments in the repository: the static Instance captures a root provider at startup, and in a test run that spins up several WebApplicationFactory hosts, the static can outlive the host that set it. Querying that disposed root would throw ObjectDisposedException. So the “safe” method is genuinely safe: inside a request it refuses to touch the possibly-stale root, and only uses it when there is no request at all (background command execution, unit-test setup).
If you have ever seen a FastEndpoints test suite work perfectly file-by-file and explode when run together, this comment is your diagnosis: process-wide statics plus multiple hosts. The library defends its own calls; your code should prefer TryResolve in anything that might run under multiple test hosts.
Keyed services are supported throughout — Resolve<T>("keyName") maps to GetRequiredKeyedService, and the non-generic overload throws a plain-spoken "Keyed services not supported!" if the provider isn't an IKeyedServiceProvider. The endpoint factory honours it for property injection too:
public class ExportEndpoint : EndpointWithoutRequest
{
[KeyedService("cold")]
public IBlobStore Archive { get; set; } = null!; // GetRequiredKeyedService at creation
public override void Configure() => Get("/exports/archive");
}
The scanner's blocklist
FastEndpoints.Core's other resident is AssemblyScanner, whose policy Part 2 of the parent series covered. What that part didn't show is the blocklist: a hardcoded set of assembly-name prefixes the scanner skips — Microsoft, System, FluentValidation, NSwag, Newtonsoft, Grpc, JetBrains, StackExchange, testhost, and a dozen more, including FastEndpoints itself. The scan unions your explicitly-provided assemblies with everything loaded in the AppDomain, filters by those prefixes, then keeps concrete, non-generic types that implement one of the wanted interfaces.
Two practical consequences hide in that list. First: name an assembly SystemIntegration.Api and your endpoints silently vanish — the prefix match doesn't care that it isn't Microsoft's. Second: the exclusion is skipped for assemblies you pass explicitly, so the fix is one line — AddFastEndpoints(o => o.Assemblies = [typeof(MyEndpoint).Assembly]). The scanner also honours a TypeFilter and an exclusion attribute, and throws at startup if you disable auto-discovery without providing any assemblies. Loud failure over silent emptiness, as usual for this library.
The attributes package is a compiler artifact
FastEndpoints.Attributes holds exactly what it says — [BindFrom], [FromClaim], [DontInject], [ToHeader], [KeyedService], [RegisterService<T>], the Http* route attributes, plus two small contracts (ParseResult, ReflectionCache). The code is trivial. The target frameworks are the story:
<TargetFrameworks>netstandard2.0;net8.0;net9.0;net10.0</TargetFrameworks>
netstandard2.0 exists for one consumer: the Roslyn source generator, which — like all analyzers — must itself target netstandard2.0 and therefore can only reference netstandard2.0 libraries. Splitting the attributes out lets the generator see them at compile time, and gives a second gift for free: your DTO/contract assemblies can carry binding attributes without referencing the full ASP.NET-dependent library.
My favourite line in the package is in RegisterServiceAttribute<TService>. The constructor takes a LifeTime and then does this:
_ = serviceLifetime; // discarded
The value is never used at runtime. It exists purely to be read by the source generator, which emits a RegisterServicesFrom{AssemblyName}() extension registering the type with that lifetime (Part 15 of the parent series covers the generator family). An attribute as a compile-time message, with a runtime body that deliberately throws the message away — once you see it, you understand exactly where the boundary between the two worlds sits.
Rounding out the tooling family: FastEndpoints.Generator.Cli repackages the same generator for build systems that aren't MSBuild — if your pipeline can't host analyzers, you run generation as a CLI step and compile the emitted sources like any other file. Same output, different delivery.
What I take from this corner
The theme of Src/Core is consistent with everything else the source has taught me: FastEndpoints treats your DI container as a component supplier, not as its object graph. Hot-path objects are built once, cached in plain dictionaries, and reached through a static — because a dictionary hit beats container resolution, and the library is honest about the cost: process-wide state, lifetime rules the container doesn't control, and test-parallelism edges it defends with careful asymmetries like TryResolve.
Next week: the package that inverts this entire series — a FastEndpoints extension that never references FastEndpoints.