What is model binding in ASP.NET Core ([FromBody], [FromQuery], [FromRoute]), and how do you validate the input (data annotations, FluentValidation)?
9 reports in the question index: Infosys (3), Hexaware, Saviant Consulting, Kale Logistics, Chetu, Wipro and Biyani Technologies, in first technical rounds.
Say this first · about 30 seconds
Model binding is how ASP.NET Core turns the raw request, meaning the route, the query string, headers and the JSON body, into the typed parameters of my action, so the action gets an int and a request object instead of strings.
With the ApiController attribute it works out the source for me: a complex type comes from the body, a simple type from the route when its name matches a route value, otherwise from the query string. I still write FromBody, FromRoute and FromQuery when it isn't obvious.
Validation runs right after binding. Data annotations like Required and Range on the request class fill the model state, and ApiController returns a 400 with a validation problem details body before my action runs, so every client gets errors in one shape. For rules across fields I call FluentValidation myself, since its old auto-validation package is no longer recommended. One production trap: Required on a plain decimal does nothing, because a missing field binds to zero, so such fields must be nullable.
Make it yours · UDC Finance customer portal API (invented example)
Situation: UDC customers can move their monthly payment day in the portal. The FIS core only accepts days 1 to 28, so the day exists in every month. Our API passed 29, 30 and 31 straight through; the core rejected them in the overnight batch, and the service team phoned each customer the next day.
What I did: a FluentValidation validator (a small library: one class holds the rules for one request) for the change-payment-day request: day 1 to 28, and a reason that isn't empty. The controller calls it and puts its errors into , the object that holds the binding and validation errors, so the 400 has exactly the same ProblemDetails shape as every other error from our API.
Result: the customer sees "Day must be 1 to 28" next to the field at once. No more overnight rejections or phone calls.
What I'd say: the rule lives in one class with its own unit test, and I call it explicitly instead of using the old auto-validation package, which FluentValidation no longer recommends.
The code, simplified
Invented to show the shape, in C#. Tap Next: a customer asks for day 31 with an empty reason. The last file holds the rules the controller just ran.
UDC.sln · where these 3 files sit
Contracts/
Controllers/
Validators/
Diagram · step 1The customer asks to move the payment day to the 31st, with no reason. Binding fills req.
PortalUDC
ControllerChange
Validatorrules
PUT day 31
ChangePaymentDay.cs · line 1 · 1 of 1The request class: the portal's JSON fills these two properties by name.
Fix later in your repoExample written for this card: invented, not from a real codebase.
Say it out loud, then check
Show the 30-second answer
Model binding is how ASP.NET Core turns the raw request, meaning the route, the query string, headers and the JSON body, into the typed parameters of my action, so the action gets an int and a request object instead of strings.
With the ApiController attribute it works out the source for me: a complex type comes from the body, a simple type from the route when its name matches a route value, otherwise from the query string. I still write FromBody, FromRoute and FromQuery when it isn't obvious.
Validation runs right after binding. Data annotations like Required and Range on the request class fill the model state, and ApiController returns a 400 with a validation problem details body before my action runs, so every client gets errors in one shape. For rules across fields I call FluentValidation myself, since its old auto-validation package is no longer recommended. One production trap: Required on a plain decimal does nothing, because a missing field binds to zero, so such fields must be nullable.
Check your answer: tick what you said
You said 0 of 8
Then add your example: UDC Finance customer portal API (invented example)
Situation: UDC customers can move their monthly payment day in the portal. The FIS core only accepts days 1 to 28, so the day exists in every month. Our API passed 29, 30 and 31 straight through; the core rejected them in the overnight batch, and the service team phoned each customer the next day.
What I did: a FluentValidation validator (a small library: one class holds the rules for one request) for the change-payment-day request: day 1 to 28, and a reason that isn't empty. The controller calls it and puts its errors into , the object that holds the binding and validation errors, so the 400 has exactly the same ProblemDetails shape as every other error from our API.
Result: the customer sees "Day must be 1 to 28" next to the field at once. No more overnight rejections or phone calls.
What I'd say: the rule lives in one class with its own unit test, and I call it explicitly instead of using the old auto-validation package, which FluentValidation no longer recommends.
See it
Route/dealers/42
Query?dryRun=true
Body (JSON)amount, termMonths…
1/9A BMW FS dealer app posts a . The data sits in three places in one request.[FromRoute]: the text "42" in the path becomes int dealerId = 42.[FromQuery]: "true" after the ? becomes bool dryRun = true.[FromBody]: the JSON becomes a object, field by field.Then validation: each attribute on the request class checks its own property.All valid: the action runs with ready, checked values. No parsing code in it.Another request: amount 50, a 6-month term, other fields fine. Binding works, but two rules fail.[ApiController] answers 400 with both errors. The action never runs.Text that can't become the type, like "abc" for the amount, ends the same way: a 400, not a crash.
In plain words
Model binding: before your action runs, ASP.NET Core reads the request and fills in the action's parameters for you. Text like "42" becomes the number 42, and a JSON body becomes a C# object.
Where a value can come from: the route (the path, like /dealers/42), the query string (after ?, like ?dryRun=true), the body (the JSON sent with a POST or PUT), a header, or a form. You say which with an attribute on the parameter: [FromRoute],[FromQuery],[FromBody],[FromHeader],[FromForm].
[ApiController]: an attribute on the controller class that switches on API behaviour. It guesses the source when you don't write one: a class comes from the body, a parameter named like a route value from the route, anything else from the query string.
Only one body: the body is one stream of bytes, read once, so only one parameter can come from it. Its format must match the Content-Type header; JSON needs application/json, or the API answers 415 Unsupported Media Type.
Validation: checking the values are acceptable. Data annotations are attributes on the properties of the request class: [Required] (must be there), [Range(1000, 500000)] (between two numbers), [StringLength],[EmailAddress] and more.
ModelState: the object where binding and validation write every problem they find, per field.
The automatic 400: with [ApiController], if has any error, the action doesn't run. The client gets 400 Bad Request with a body that lists every field and its message. Without [ApiController] the action runs anyway, and you must check yourself.
Cross-field rules: a rule that compares two fields (down payment at least 10% of the amount) goes in , a built-in interface with one method, or in a FluentValidation validator: a class from a popular library that holds all the rules for one request.
The number trap: a C# decimal or int can't be empty; if the JSON leaves it out, it becomes 0. [Required] sees 0, not "missing", and passes. Write decimal? (the ? allows null) so a missing value is null and [Required] catches it.
DTO: a class shaped for one request or response (here ), separate from the database classes.
The senior answer · about 2 minutes
What binding does
Model binding is the step between routing and my action that turns text into typed parameters. The route gives me dealerId, the query string gives dryRun, and the JSON body becomes a through System.Text.Json, .NET's built-in JSON reader. If a value can't be converted, say "abc" for an amount, that's recorded as an error rather than an exception, so it becomes a 400 and not a 500.
Where each value comes from
With [ApiController], complex types come from the body, parameters named like a route value come from the route, and the rest from the query. I write the attributes when a reader would have to guess, and I remember the body is one stream: only one parameter can come from it, and the client must send Content-Type: application/json or it gets a 415.
Validation that clients can rely on
Simple rules sit on the request class as data annotations. [ApiController] then answers every invalid request with a 400 before my code runs, so the portal handles one error shape everywhere. Cross-field rules, like a minimum down payment, go in or a FluentValidation validator that I call explicitly and copy into , so the shape stays the same.
Business checks that need data, like "this dealer is still active", belong in the service, not in an attribute: they need the database and they return their own errors.
What I watch for
Non-nullable numbers. A missing downPayment binds to 0 and [Required] is satisfied, which at Navistar sent applications to credit with no down payment: the incident in Deep. Our rule now: required numbers are nullable, and the API rejects unknown JSON fields, so a renamed field on the client fails loudly in testing instead of silently in production.
One request, every source and check
The real order on .NET 10 for one POST, from the raw request to the action or the 400.
Dealerapp
Binderfills
Checksrules
ActionCreate
POST + JSON
1/6Content-Type first. JSON with application/json is read. A form post can't be, so: 415.Route and query. "42" becomes dealerId, "true" dryRun. Bad text like maybe is an error.Body. JSON fills the request object. Text that won't convert is an error; a missing number is 0.Attributes.[Required],[Range] and the rest check each field and write failures to .Whole object. Only if every attribute passed, runs the cross-field rules.Decision. Any error: [ApiController] sends 400 with all of them. None: the action runs.
The story
Before
BMW FS dealers send from their dealer app to our API. In the first version, each action read the request by hand. (A controller is a class that handles requests; an action is one of its methods, answering one kind of request.) It cut the dealer id out of the address, read dryRun from the query string (the part of the address after ?), turned the JSON body into an object with , and then checked each value with its own if.
Create()parse + 6 ifs + work
Update()parse + 5 ifs + work
Quote()parse + 3 ifs + work
1/4Before: every action parses the request and checks each value itself, copied by hand.One copy forgot the term check. A 0-month application got in and broke the payment calculation.Fix: ASP.NET Core fills the parameters and runs the checks before any action starts.Bad input now stops with a 400 in one place, for every action, old or new.
Why it had to change
Parsing and checking input is the same job in every action. Copied by hand, it gets forgotten or done slightly differently, and one missed check lets bad data reach the FIS core (the lender's main loan system, which our API feeds). Every action also answered errors in its own shape, so the dealer app needed special code for each.
How it works now
ASP.NET Core does both jobs before your action runs. Model binding reads the route, the query string, headers and the JSON body, and fills in the action's parameters, converting text to numbers and objects. Validation then checks the values against rules you put on the request class as attributes (labels in square brackets that add a rule or a behaviour), like [Range(12, 84)]. If anything fails, a controller marked [ApiController] answers 400 with a list of every error, and the action never runs. The main diagram follows one request through both steps.
What we gained
The actions only do their own work, every input is checked the same way, and every client gets errors in one shape. The rest of the card shows where each value comes from, how to write rules that go beyond one field, and the trap that let bad data through at Navistar.
In the lending API
The action's parameters say where each value comes from; the diagram above shows them being filled.
Only the key lines are shown; Deep walks the whole file.
Lending.sln · where this file sits
src/BmwFs.Applications.Api/Controllers/
⋯ 6 lines in Deep
ApplicationsController.cs · line 9 · 1 of 4 is the return type: any kind of answer, such as 200 or 400.
The code, file by file
The BMW FS applications API: start-up, the controller and the request class. Tap Next to walk one request.
Lending.sln · where these 3 files sit
src/BmwFs.Applications.Api/
Controllers/
Contracts/
Diagram · step 1A BMW FS dealer app posts a . The data sits in three places in one request.
Route/dealers/42
Query?dryRun=true
Body (JSON)amount, termMonths…
Program.cs · line 3 · 1 of 2Turns on controllers, and with them model binding and validation.
What they ask next
The follow-ups interviews really ask. Answer out loud, then tap to check.
ReportedWhat is the difference between [FromBody], [FromQuery] and [FromRoute]?
They name where a parameter's value comes from. [FromRoute] reads a value from the path, like 42 in /dealers/42.[FromQuery] reads from the query string after ?, like dryRun=true.[FromBody] reads the request body, usually JSON, into one object, and only one parameter can use it. With [ApiController] you can often leave them out: a class is taken from the body, a name that matches a route value from the route, the rest from the query.
ReportedHow do you pass an object, say an Employee, to a Web API?
Send it as JSON in the body of a POST or PUT, with Content-Type: application/json, and give the action a parameter of that class, marked [FromBody] or inferred by [ApiController]. System.Text.Json fills the properties by name. For small search values, use the query string instead; for a file, a form with [FromForm].
ReportedHow do you implement validation in a Web API?
On the server, always, even if the client validates too, because anyone can call the API directly. Simple rules as data annotations on the request class; [ApiController] turns failures into a 400 ProblemDetails automatically. Rules across fields in or FluentValidation. Rules that need data, like "the dealer is active", in the service, which returns its own error.
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedWhat is the difference between [FromBody], [FromQuery] and [FromRoute]?
They name where a parameter's value comes from. [FromRoute] reads a value from the path, like 42 in /dealers/42.[FromQuery] reads from the query string after ?, like dryRun=true.[FromBody] reads the request body, usually JSON, into one object, and only one parameter can use it. With [ApiController] you can often leave them out: a class is taken from the body, a name that matches a route value from the route, the rest from the query.
ReportedHow do you pass an object, say an Employee, to a Web API?
Send it as JSON in the body of a POST or PUT, with Content-Type: application/json, and give the action a parameter of that class, marked [FromBody] or inferred by [ApiController]. System.Text.Json fills the properties by name. For small search values, use the query string instead; for a file, a form with [FromForm].
ReportedHow do you implement validation in a Web API?
On the server, always, even if the client validates too, because anyone can call the API directly. Simple rules as data annotations on the request class; [ApiController] turns failures into a 400 ProblemDetails automatically. Rules across fields in or FluentValidation. Rules that need data, like "the dealer is active", in the service, which returns its own error.
Annotations for simple per-field rules: they're built in, short, and the automatic 400 uses them. FluentValidation when rules get bigger: several fields, conditions, async lookups, rules you want to unit-test on their own, or a request class you can't add attributes to. Call its validator explicitly or from a filter, and put its errors in so the 400 keeps the same shape. Its automatic MVC integration package is no longer supported, so I don't use it on new projects.
Can you change or switch off the automatic 400?
Yes. in lets you set , to shape the response differently (an error code your clients expect, say), or SuppressModelStateInvalidFilter = true, to switch it off and check yourself. I keep the default unless an existing client needs another shape.
Why does the app refuse to start when an action has two class parameters?
With [ApiController] both are inferred as [FromBody], and the body is one stream that can be read once. ASP.NET Core throws an InvalidOperationException when the endpoints are built at start-up, before any request (checked on .NET 10): only one parameter per action may be bound from the body. Put both in one request class, or mark the second [FromQuery].
What is over-posting, and how do you stop it?
If you bind the request straight to an EF entity (a class mapped to a database table), the client can send fields you never meant them to set, like "status": "Approved", and binding fills them. Bind to a request DTO that has only the fields the client may send, then copy them to the entity in the service.
A client renamed a JSON field and nobody noticed. How do you catch that?
By default System.Text.Json ignores fields it doesn't know, so the old property just stays at its default. Set UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow in , and an unknown field becomes a 400 that names it. Make required numbers nullable as well. Both are in the incident's fix.
Do minimal APIs validate data annotations automatically?
Not by default: a minimal API handler runs even when the body breaks the attributes. Since .NET 10, one line in Program.cs, builder.Services.AddValidation(), makes minimal APIs check data annotations and and answer 400 ProblemDetails, like [ApiController]. On older versions, or for FluentValidation, use an endpoint filter that returns Results.ValidationProblem:.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
A new controller saved applications that broke every rule
POST /api/v2/dealers/42/applications
{ "customerName": "Ann Lee", "amount": 50,
"termMonths": 6, "downPayment": 5000 }
→ 200 OK, saved as application 9002
Cause
A developer created from an older MVC template. It had the same request class and attributes but no [ApiController] on the class. Binding and validation still ran and wrote both errors into , but without [ApiController] nothing stops the action, and the action never looked at . Applications with an amount of 50 reached the database.
Fix
Add [ApiController] to the class (we now put it on a shared base controller, so no new controller can miss it), and a test that posts an invalid body to every POST and PUT endpoint and expects 400.
Before · ApplicationsV2Controller.cs · line 1 · 1 of 4The address for every action in this class; {dealerId:int} is a whole number from the path.
Say it
Validation always runs, but only ApiController turns a failed validation into an automatic 400. Without it the action runs and must check ModelState itself. We moved the attribute to a base controller and test every write endpoint with a bad body.
Every call from the new quote page failed with 415
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json
{ "title": "Unsupported Media Type", "status": 415 }
Cause
A new page posted its data as an HTML form (Content-Type: application/x-www-form-urlencoded) to an action whose parameter is [FromBody]. With [FromBody] the API only reads formats it has an input formatter for, which by default is JSON, so it found no way to read the body and refused the request with 415 before the action ran.
Fix
The page now sends JSON with Content-Type: application/json, like the rest of the app. If an endpoint really must accept a form (a file upload, say), its parameter uses [FromForm] instead.
Lending.sln · where these 2 files sit
Controllers/
Before · QuotesController.cs · line 3 · 1 of 1Reads JSON only. A form post never gets here: 415.
Say it
FromBody only reads the body in a format the API has a formatter for, JSON by default, chosen by the Content-Type header. A form post gets 415. Forms need FromForm.
Production incident: Truck loan applications went to credit with no down payment
P2 · serious, has a workaroundNavistar Financialcame with a deployCredit applications API (ASP.NET Core on App Service)
Composed, not a real event at Navistar Financial. The mechanism is real ASP.NET Core, checked by a run on .NET 10: System.Text.Json ignores a JSON field it doesn't know, a missing field leaves a decimal property at 0, and [Required] on a non-nullable decimal never fails. decimal? makes [Required] fail, and UnmappedMemberHandling.Disallow turns an unknown field into a 400. Times, counts, names, ticket and PR numbers are composed.
The story in 5 lines
On Thu 8 Oct Navistar's daily credit exceptions report showed 21 approved truck loan applications with a down payment of $0, which credit policy doesn't allow for these buyers.
The applications table had 63 applications with a $0 down payment, all submitted since dealer portal release 7.2 on Tue 6 Oct. Before that: none.
Release 7.2 renamed the portal's field downPayment to downPaymentAmount. The API ignored the unknown field, the decimal stayed at 0, and [Required] on a non-nullable decimal can't fail, so every application was accepted.
The same day: the 21 approvals were put on hold, the portal sent the right name again, and an made the field decimal?, so a missing value is a 400. The fix also rejects unknown JSON fields, and a contract test runs on both sides.
The rule since: required numbers in request classes are nullable, unknown fields are refused, and the portal and API test the same request samples.
Who's who (6 people)
You
.NET developer on the vendor team (L3). Owns the API fix.
Tech lead
Vendor senior. Runs the bridge call, reviews every PR.
Portal developer
Vendor Angular developer on the Navistar dealer portal.
L2 pod
Vendor support. Runs queries, deploys with approval.
Navistar credit operations analyst
Owns the daily exceptions report on credit decisions.
Navistar IT change manager
Approves the emergency change.
Tap a step. The map lights the place where it happens.
Dealer portalrelease 7.2
Applications APICreate application
Credit operationsexceptions report
FIS corecredit decision
You + tech leadL3: our code
Azure SQLApplications table
Azure DevOpsPR, tests, release
Staging slotthe next build waits
1/8 Detect
The exceptions report: 21 approved applications with a $0 down payment.
Thu 8 Oct, 09:00 CT, US Central (19:30 IST)Navistar credit operations analyst, then the L2 pod
What: the daily exceptions report, which lists credit decisions that break a policy, shows 21 truck loan applications approved with a down payment (the part of the price the buyer pays up front) of $0. Policy needs at least 10% for these buyers.
How we saw it: the analyst checks three of them with the dealers. All three buyers did agree a down payment; the dealers had typed it into the portal. opens a ticket with the vendor team.
Why it hid: the API answered 200 OK to every one. A $0 down payment is a valid number, so nothing in the logs looked wrong.
In theory
A request that leaves out a required value is refused.
In practice
The API accepted it and stored 0. Only a business report, two days later, noticed.
Try it yourself · 10 minutes, on your Mac
dotnet new webapi -n BindLab --use-controllers. Add a request class with [Required] public decimal DownPayment { get; set; } and an [ApiController] action that returns it.
POST {} with Content-Type: application/json: you get 200 and downPayment: 0. Then POST {"downPaymentAmount": 5}: the same, the unknown field is ignored.
Set UnmappedMemberHandling = Disallow in and repeat the second POST: now a 400 that names $.downPaymentAmount (the JSON path of the unknown field).
Change the property to decimal? and POST {} again: 400, "The DownPayment field is required."
You should see
A value type always has a value, so [Required] can't tell "missing" from 0. Nullable types and refusing unknown fields make a missing or misnamed value fail loudly.
The senior move
"Required numbers are nullable, unknown fields are refused, and the client's real requests run in the API's tests."
SourcesSourced: [Required] on non-nullable value types and the advice to use nullable types (ASP.NET Core docs, "Model validation": [Required] validation on the server); (.NET 8 and later, System.Text.Json docs, "Handle unmapped members"). Checked with a real run on .NET 10: a missing or misnamed field bound a decimal to 0 and passed [Required];decimal? returned 400; returned 400 naming the field. Composed: Navistar's systems, dates, counts, names, ticket and numbers and the SQL.
Traps
Don't say[Required] on an int or decimal makes it mandatory.SayA missing number binds to 0, and 0 satisfies [Required]. Make it nullable, decimal?, so a missing value fails.
Don't sayThe portal validates the form, so the API doesn't need to.SayAnyone can call the API without the portal. The server validates every request; client checks are only for a faster message.
Don't sayBind the request straight to the EF entity to save a class.SayBind to a request DTO, or the client can set fields like Status (over-posting).
Don't sayValidation failures throw exceptions that the error handler turns into 400s.SayThey're recorded in .[ApiController] returns the 400 before the action runs; no exception is thrown.
Traps
What should you say instead? Tap to check.
Don't say[Required] on an int or decimal makes it mandatory.
Say instead
A missing number binds to 0, and 0 satisfies [Required]. Make it nullable, decimal?, so a missing value fails.
Don't sayThe portal validates the form, so the API doesn't need to.
Say instead
Anyone can call the API without the portal. The server validates every request; client checks are only for a faster message.
Don't sayBind the request straight to the EF entity to save a class.
Say instead
Bind to a request DTO, or the client can set fields like Status (over-posting).
Don't sayValidation failures throw exceptions that the error handler turns into 400s.
Say instead
They're recorded in .[ApiController] returns the 400 before the action runs; no exception is thrown.
Live coding
Easy first, then harder. Set a timer and type it before you look.
Task 1
Question index: Musteasy · 10 minutes
Add validation to the create-application endpoint: customer name required, amount 1,000 to 500,000, term 12 to 84 months, down payment required. Invalid input must return 400 ProblemDetails listing every invalid field.
Which attributes check "present" and "between two numbers"?
What makes the controller answer 400 by itself?
What happens to a missing decimal field?
Show a solution
Lending.sln · where these 2 files sit
Controllers/
Contracts/
Diagram · step 8[ApiController] answers 400 with both errors. The action never runs.
Route/dealers/42
Query?dryRun=true
Body (JSON)amount, termMonths…
ApplicationsController.cs · line 1 · 1 of 2Automatic 400 when validation fails.
What the interviewer checks
A request DTO with [Required] and [Range] on the right properties.
[ApiController] on the controller, so invalid input gets a 400 before the action runs.
decimal? for the down payment, so a missing value is caught.
registered; the body lists every invalid field with its message.
Can say what happens without [ApiController]: the action runs and must check .
Task 2
Question index: Mustmedium · 15 minutes
Add a rule across two fields without a library: the down payment must be at least 10% of the amount. It must come back in the same 400 shape as the other errors.
Which built-in interface lets a class validate itself?
How do you tie the error to the field?
When does this method run if is already out of range?
Show a solution
Lending.sln · where this file sits
Contracts/
Diagram · step 2The attributes run first. Every field passes on its own.
Dealerapp
Attributesper field
Validate()whole object
60,000 / 5,000
CreateApplicationRequest.cs · line 2 · 1 of 5A built-in interface with one method, , that MVC calls after the attributes pass.
What the interviewer checks
on the request class, with returning a for .
The error appears under in the same 400 .
Knows runs only after every attribute passes, so it can trust is in range.
Can say when FluentValidation would be the better choice.
With AI tools
Interviewers now ask how AI tools change this. Assistants write request classes in seconds, and two mistakes keep showing up in what they generate: [Required] on non-nullable numbers, which never fails, and controllers built straight on the EF entity, which allows over-posting. I review every generated DTO for nullable required numbers and fields the client shouldn't set, and I ask the assistant for the invalid-input tests as well as the class.