Eight Sources, One DTO: Model Binding Under the Hood
FastEndpoints binds a single request DTO from JSON, form, route, query, claims, headers, cookies and permissions - in that order, with compiled setters cached per DTO type. Reading the binder explains every binding surprise I have ever hit. Part 4 of FastEndpoints in Depth.
Every web framework has a binding FAQ, and it is always the same question wearing different clothes: why does my property have that value? After reading RequestBinder.cs in the FastEndpoints source, I can answer that question for this library without guessing — because the binder is one readable file with a documented, deterministic order.
One DTO, one binder, built once
FastEndpoints binds the whole request into one DTO. Not method parameters, not a mix of [FromRoute] int id, [FromBody] Payload payload — one class. The component responsible is RequestBinder<TRequest>, registered as a singleton per DTO type, and its static constructor does all the expensive thinking exactly once per process:
- It enumerates the DTO's bindable properties and compiles a setter delegate for each (expression-tree compiled, not
PropertyInfo.SetValue) into dictionaries keyed by field name. - It files attribute-decorated properties into their own lists:
[FromClaim],[FromHeader],[FromCookie],[HasPermission]. - It enforces singletons where only one makes sense — a second
[FromBody],[FromForm]or[FromQuery]property is an immediate exception naming your DTO. - It records which properties are
requiredso unbound ones can fail properly later.
The header comment sets the contract bluntly: //WARNING: request binders are singletons. do not maintain instance state! Everything mutable lives in a per-request BinderContext. If you write a custom binder (you can: implement IRequestBinder<TReq> or subclass the default and override BindAsync), that rule is yours to keep too.
The order is the API
The heart of the binder reads like a table of contents:
// Src/Library/Binder/RequestBinder.cs (FastEndpoints, MIT)
var req = ... await BindJsonBody(...) ...; // or plain-text body, or bare instance
if (_bindFormFields) BindFormValues(req, ctx);
if (_bindRouteValues) BindRouteValues(req, ctx);
if (_bindQueryParams) BindQueryParams(req, ctx);
if (_bindUserClaims) BindUserClaims(req, ctx);
if (_bindHeaders) BindHeaders(req, ctx);
if (_bindCookies) BindCookies(req, ctx);
if (_bindPermissions) BindHasPermissionProps(req, ctx);
JSON body first, then form, route, query, claims, headers, cookies, permissions. Later sources overwrite earlier ones. That single sentence resolves the classic confusion: if your JSON body says "id": 7 and the route says /orders/9, the DTO ends up with 9 — the route value wins because route binding runs after body deserialization. This is a feature (a client cannot smuggle a different resource ID in the body than the one in the URL), but it is the kind of feature you want to know about before it knows about you.
Some details per source, all visible in the file:
- JSON body is deserialized only when the request actually has a JSON content type. Normally the whole DTO is the deserialization target, but a
[FromBody]-decorated property makes that property the target instead — for the “envelope DTO” pattern where route params and body live side by side. - Plain text is a marker interface: implement
IPlainTextRequestand the raw body lands inContent— with a subtle disposal dance in the source (theStreamReaderis registered for disposal with the response rather than disposed inline, because killing the reader kills the request body that form binding may still need). - Route/query/form values flow through a parser cache: per-type
ValueParserdelegates that turn strings into ints, Guids, enums,DateOnly, arrays, and anything with a suitableTryParse/constructor. You can register custom parsers globally (Config.Binding.ValueParserFor<T>(...)) — I have used this for strongly-typed IDs. - Claims and permissions are my favourite sources, because they eliminate a whole category of handler boilerplate.
[FromClaim] string UserIdmeans the handler never touchesHttpContext.User;[HasPermission("order:delete")] bool CanDeletebinds an authorization fact as a boolean. The DTO becomes the complete, testable input of the operation — who is asking included. - Complex query/form objects:
[FromQuery]on a single complex property binds nested objects from query params (?filter.status=open&filter.page=2), with a mirrorComplexFormBinderfor forms.
Renames use [BindFrom("order_id")]; source restriction uses [QueryParam], [RouteParam], [FormField] (each of which disables the other sources for that property — they derive from a DontBind base attribute in the source, which tells you exactly how they work).
Failure is collective, not fatal
Individually unparseable values don't throw; they append to the request's ValidationFailures list. Missing required properties do the same. Only at the end does the binder decide:
// Src/Library/Binder/RequestBinder.cs (FastEndpoints, MIT)
return ctx.ValidationFailures.Count == 0
? req
: throw new ValidationFailureException(ctx.ValidationFailures, "Model binding failed!");
So a client sending three bad fields gets all three errors in one 400, in the same envelope validation errors use — binder failures and validator failures are literally the same list, which is why the error response format is uniform across both (Part 5 picks that up). A malformed JSON document, by contrast, surfaces as a JsonException, and the pipeline turns it into a 400 through a configurable JsonExceptionTransformer — you can rewrite STJ's notoriously unhelpful messages into something a client can act on.
Files without buffering
IFormFile properties bind from multipart forms, and IEnumerable<IFormFile> collects same-named uploads. But the part worth knowing about lives on the endpoint base class, not the binder: FormFileSectionsAsync() exposes the raw MultipartReader as an async stream of file sections — no buffering to memory or disk. For a gigabyte upload endpoint, you skip DTO file binding entirely and pump sections straight to blob storage. The library also lets you cap request sizes per endpoint and transform form-parsing exceptions; the source is careful to short-circuit only on a 413 (payload too large) and otherwise keep collecting errors.
The performance shape
Why is this fast enough to keep pace with Minimal APIs, which compile bespoke binding delegates per endpoint? Because FastEndpoints amortizes the same way, just along a different axis: all reflection happens in the static constructor (or, under Native AOT, at compile time via the reflection source generator — Part 15); per-request work is dictionary lookups and delegate invocations. The optional endpoint warm-up from Part 2 even forces these static constructors at startup so the first request doesn't pay for them.
The one cost you should keep in mind: every non-body source binds by property name through those dictionaries. A DTO with sixty properties pays sixty lookups against route values that will match twice. In practice this has never shown up in a profile for me — but it is the reason the binder lets you disable sources wholesale (a custom binder passing BindingSource.JsonBody | BindingSource.RouteValues skips the rest).
What I take from the binder
The design lesson is unification. Route values, claims, headers, permission checks — most frameworks make you fetch these imperatively inside the handler, which means the handler's true inputs are scattered across HttpContext. FastEndpoints pulls them all into one declared, ordered, testable object. When a unit test constructs new CreateOrderRequest { UserId = "u1", CanDelete = false }, it is expressing everything the handler will know — and that is what makes endpoint unit tests honest.
With a bound DTO in hand, the pipeline's next station is validation — where the FluentValidation integration, and the singleton lifetime it imposes, deserve a careful look: Part 5, Validators Are Singletons.