Skip to content
Kumar Chandrachooda
.NET

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.

By Kumar Chandrachooda 29 Oct 2025 5 min read
Eight tributaries, one river: every source feeds the same DTO, in a fixed order

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 required so 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 IPlainTextRequest and the raw body lands in Content — with a subtle disposal dance in the source (the StreamReader is 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 ValueParser delegates that turn strings into ints, Guids, enums, DateOnly, arrays, and anything with a suitable TryParse/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 UserId means the handler never touches HttpContext.User; [HasPermission("order:delete")] bool CanDelete binds 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 mirror ComplexFormBinder for 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.