Skip to content
Kumar Chandrachooda
.NET

All Configuration Is Static

The Config object you lambda into UseFastEndpoints is a thin view over nine process-wide static option singletons - and its properties hide the most useful hooks in the library: endpoint filters, a global configurator, warm-up, serializer swaps and custom value parsers. Part 4 of FastEndpoints — The Missing Chapters.

By Kumar Chandrachooda 07 Jun 2026 5 min read
One Config facade, nine static option blocks behind it

Every FastEndpoints app has this line, and the parent series wrote it dozens of times without ever looking inside:

app.UseFastEndpoints(c =>
{
    c.Endpoints.RoutePrefix = "api";
    c.Errors.UseProblemDetails();
});

Part 17 listed “statics/Config globals” among the sharp edges and moved on. This part opens the drawer. There are two stories in Src/Library/Config/: what the Config object actually is (a mirage, pleasantly honest about it), and what it contains - because several of the most useful hooks in the whole library live here and nowhere in the parent series.

The mirage

Config.cs is short enough to characterize completely. Every options group is an internal static readonly field on the class - BndOpts, EpOpts, ErrOpts, SecOpts, SerOpts, ThrOpts, VerOpts, ValOpts, X402Opts - and the file declares a global using Cfg = FastEndpoints.Config; so the rest of the library reads them as Cfg.SerOpts.Options from anywhere, no injection required. The instance properties you touch in the lambda (c.Endpoints, c.Errors, ...) simply return those statics.

So the Config instance is a view. There is no per-app options object; there is one set of option singletons per process. That is why the binder, the senders, the swagger processors can all read configuration from static context with zero plumbing - and it is why two FastEndpoints apps cannot coexist in one process with different settings. The practical place that bites is integration testing: spin up several WebApplicationFactory hosts in one test run and they share every one of these objects. Last writer wins, globally. The library defends its own internals against a related hazard (the resolver's disposed-root asymmetry), but your configuration deltas between test hosts are on you. Keep them identical, or isolate suites per process.

With the architecture named, the useful part: a tour of the hooks the parent series never covered.

Endpoints: the levers over startup itself

EndpointOptions holds the hooks that run while endpoints are being registered - which makes them the closest thing FastEndpoints has to a plugin system.

Configurator is a global Configure() that runs for every endpoint at startup, before its own configuration is locked:

app.UseFastEndpoints(c => c.Endpoints.Configurator = ep =>
{
    ep.Description(d => d.Produces<ErrorResponse>(500));
    if (ep.Routes[0].StartsWith("/internal"))
        ep.Roles("ops");
});

Every convention you would have written as a base endpoint class - default error metadata, blanket auth for a route subtree, tagging rules - can live here instead, applied to endpoints that never opted in.

Filter is the bouncer: a Func<EndpointDefinition, bool> consulted per discovered endpoint; return false and the endpoint is simply not registered. Combined with your own marker attributes it becomes deployment-time feature selection - register the admin surface only in the internal build, say.

NameGenerator controls the name every endpoint gets registered under - the same name that CreatedAtAsync<TEndpoint> looks up (Part 2) and Swagger uses as operationId. The default composes type name, verb, and route number, with ShortNames and PrefixNameWithFirstTag toggles; replacing it with your own Func<EndpointNameGenerationContext, string> renames the whole API surface in one line.

Warmup() fixes a cold-start tail: binders, compiled setters, validators and mappers are all built lazily on first request per endpoint. Opting into warm-up pre-compiles them at startup (optionally filtered), trading boot time for p99 on first hits.

There is also GlobalResponseModifier - a hook invoked by every sender before the body is written, the natural home for a deprecation header or tenant stamp - and AllowEmptyRequestDtos for the edge case of deliberately property-less request types.

Errors and serialization: the envelope factories

ErrorOptions was half-covered by the validation part: ResponseBuilder turns a List<ValidationFailure> into whatever your clients expect, with UseProblemDetails() as the RFC 7807 preset. The uncovered corners: StatusCode and ContentType set the default failure shape (the content type defaults to application/problem+json even for the non-problem-details envelope), GeneralErrorsField names the bucket for failures that belong to no property (the antiforgery middleware uses it), and ProducesMetadataType controls what a 400 advertises in OpenAPI - set it to null and endpoints stop declaring the automatic 400 entirely.

SerializerOptions goes further than the usual JsonSerializerOptions tweak. RequestDeserializer and ResponseSerializer are full delegate replacements for how request bodies are read and responses written - the documented example swaps in Newtonsoft wholesale, but the more interesting production use is per-content-type dispatch (MessagePack for one route group, JSON elsewhere). CharacterEncoding is the string appended to every response content type; null turns it off.

BindingOptions hides the deepest cut: custom value parsers. Everything the binder pulls from route, query, header or form arrives as a string and goes through a parser cache (parent Part 4); ValueParserFor<T> inserts your own:

app.UseFastEndpoints(c => c.Binding.ValueParserFor<GeoPoint>(
    input => GeoPoint.TryParse(input?.ToString(), out var p)
                 ? new(true, p)
                 : new(false, null)));

From then on any DTO property of type GeoPoint, from any binding source, parses through that delegate - one registration instead of a type converter per DTO. Alongside it: JsonExceptionTransformer (how malformed JSON becomes a ValidationFailure instead of a 500, swappable, nullable to opt out), FormExceptionTransformer (its multipart sibling, useful for turning Kestrel's 413s into your envelope), Modifier (a post-bind hook over every request), and AllowUndefinedEnumValues.

The claim types you must not touch (says the source)

SecurityOptions is five strings and a parser, and the parent security part only met one of them. PermissionsClaimType defaults to "permissions", and its doc comment says, in effect, change this and Auth0 integration breaks - the default is chosen to match what real identity providers emit. RoleClaimType, NameClaimType, ScopeClaimType carry the same do-not-touch-casually warnings, and ScopeParser (default: split on spaces) is the hook for providers that send scopes as arrays instead. The lesson generalizes: these defaults are compatibility decisions, not arbitrary strings, and the source tells you so where the docs don't.

ThrottleOptions (header name, error message) and ValidationOptions (EnableDataAnnotationsSupport for teams migrating off [Required], and UsePropertyNamingPolicy controlling whether error keys follow your JSON casing) round out the set, plus X402Opts for the micro-payments middleware.

Reading the design

Why statics? The honest answer from reading call sites: because the alternative is threading an options object through every static-constructor-built cache in the library - the binder's compiled setters, the sender extensions, the singleton validators - all of which run in contexts where DI is awkward or deliberately avoided. The statics are load-bearing for the performance architecture. The cost is the one this part opened with: configuration is process-truth, not app-truth, and the Config lambda's cozy instance syntax slightly disguises that.

My working rules: treat UseFastEndpoints(c => ...) as write-once boot code, never conditional per environment beyond what a process restart covers; keep test hosts configuration-identical; and before writing a base endpoint class, check whether Configurator and Filter already do the job. They usually do.

Next week: the other half of the statics story - the resolver and the two little packages that ride along with every install.