Validators Are Singletons: FluentValidation, the FastEndpoints Way
FastEndpoints wires FluentValidation into the pipeline by request-DTO type, runs validators as singletons, and shares one failure list between binder, validator and handler. The lifetime rule is the part that bites. Part 5 of FastEndpoints in Depth.
Minimal APIs shipped for years with no validation story at all — every team grew its own endpoint filter, its own error shape, its own way of resolving validators. FastEndpoints made a different call: FluentValidation is in the box, wired into a fixed pipeline position, with one error envelope for everything. Having read the validation code, I want to walk through what that integration actually does — and the singleton lifetime decision that is both its best performance trick and its sharpest edge.
Declaring a validator
The usage side is standard FluentValidation with a different base class:
public class CreateOrderValidator : Validator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Lines).NotEmpty().WithMessage("An order needs at least one line.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.Quantity).GreaterThan(0);
});
}
}
You never register it. As Part 2 showed, the startup scanner finds every Validator<T> and matches it to endpoints by request DTO type. Two endpoints sharing CreateOrderRequest share this validator; two validators for the same DTO is a detected ambiguity that disables auto-wiring until you pick one explicitly. Validator<T> is just FluentValidation's AbstractValidator<T> plus a marker interface (IEndpointValidator) so the scanner can tell “endpoint validators” apart from other FluentValidation classes in your solution — plain AbstractValidator<T> types are ignored unless you opt in.
The singleton rule
The base class's own doc comment states the contract: validators are registered as singletons — the same instance validates every request, for performance. That has three practical consequences, in ascending order of pain:
- No instance state. Obvious, rarely a problem — validator rules are declared in the constructor anyway.
- The constructor runs once, at first use. Anything you resolve there is resolved once and cached forever. Resolving a scoped
DbContextin a validator constructor is a bug that works in development and corrupts under load. - Service resolution inside rules must be deliberate.
Validator<T>implements the library's resolver interface, soResolve<T>()is available — but you own the scoping. The safe pattern for per-request dependencies is resolving inside the rule lambda, or better, creating a scope explicitly:
RuleFor(x => x.Sku).MustAsync(async (sku, ct) =>
{
using var scope = CreateScope();
var catalog = scope.ServiceProvider.GetRequiredService<ICatalogService>();
return await catalog.ExistsAsync(sku, ct);
}).WithMessage("Unknown SKU.");
My honest take: database-hitting rules belong in the handler (or a pre-processor), not the validator. Validators shine for shape — presence, ranges, formats, cross-field consistency. The moment a rule needs I/O, its failure modes (timeouts, retries, transactions) outgrow the validation abstraction. The library lets you do it; the lifetime rules are telling you to think twice.
One failure list to rule them all
The pipeline detail that makes FastEndpoints validation feel coherent: the binder, the validator, and your handler all write to the same List<ValidationFailure> on the endpoint instance. Part 4 showed the binder appending unparseable values and missing required fields to it. Validation (running immediately after binding, wrapped by the OnBeforeValidate/OnAfterValidate hooks from Part 3) appends rule failures. And inside the handler you can keep going:
public override async Task HandleAsync(CreateOrderRequest req, CancellationToken ct)
{
if (await inventory.IsDiscontinuedAsync(req.Lines, ct))
AddError(r => r.Lines, "Order contains discontinued items.");
ThrowIfAnyErrors(); // 400 with all accumulated errors, if any
var credit = await credit.CheckAsync(req.CustomerId, ct);
if (credit.Blocked)
ThrowError(r => r.CustomerId, "Customer is on credit hold."); // immediate 400
...
}
AddError accumulates; ThrowIfAnyErrors flushes accumulated errors as one response; ThrowError is the immediate single-error exit. All three funnel into the same ValidationFailureException that binding failures use, which is why a client sees an identical error shape whether the problem was “quantity isn't a number” (binder), “quantity must be positive” (validator), or “customer on credit hold” (handler). Uniformity here is worth more than it looks: client teams write one error parser, once.
The default envelope is the library's ErrorResponse — status, message, and a dictionary of property name to error messages. Not RFC 7807 by default, which surprises people; the library ships a ProblemDetails DTO and a one-liner to switch (Config.Errors.UseProblemDetails()), and beyond that Config.Errors.ResponseBuilder hands you full control of the shape, per status code, with the failure list in hand. Property names in the envelope respect your JSON naming policy — there is a deliberate hook at startup that plugs the serializer's naming policy into FluentValidation's property-name resolver, so customerId in the request is customerId in the error, not CustomerId. It is a small thing that reads like someone actually operating an API wrote it.
Opting out, and what “out” means
Configure() offers DontThrowIfValidationFails(). With it set, validation still runs and still populates ValidationFailures — but the pipeline proceeds to your handler, where ValidationFailed (a bool) and the list itself let you decide. I have used this exactly twice, both times for endpoints that needed to return a partial success (a batch import reporting per-row problems alongside accepted rows). It is the right escape hatch: the default remains fail-fast, and opting out is per-endpoint and visible in Configure() where reviewers look.
There is no magic skip-validation-for-this-request mechanism, and pre-processors (Part 6) run after validation, so they can't suppress it either — they can only observe its failures. If an endpoint genuinely shouldn't validate, don't give its DTO a validator.
Testing validators
Because validators are plain FluentValidation classes, unit testing needs nothing from the library:
[Fact]
public void Empty_customer_fails()
{
var result = new CreateOrderValidator().TestValidate(
new CreateOrderRequest { CustomerId = "" });
result.ShouldHaveValidationErrorFor(x => x.CustomerId);
}
The one caveat comes back to lifetimes: if your validator resolves services, the static ServiceResolver the library routes through won't exist in a bare unit test. The testing package's Factory.Create<TEndpoint>() path (Part 14) wires enough of that up for endpoint-level tests; for validator-level tests, keep the rules pure and this problem never exists — another gentle push toward shape-only validators.
The design in one sentence
FastEndpoints validation is FluentValidation with three opinions bolted on: discovery by DTO type (no registration), singleton lifetime (no per-request allocation, no casual scoped dependencies), and a single shared failure list from binder to handler (one error envelope for every failure source). Two of those opinions are pure win; the singleton one is a trade you should make with open eyes.
One pipeline station remains between validation and your handler — the processors. They are also where FastEndpoints hides its most interesting exception-handling mechanism, an ExceptionDispatchInfo that waits politely in a finally block: Part 6, Pre-Processors, Post-Processors, and the Exception That Waits.