Skip to content

Expert · Web APIs with ASP.NET Core and EF Core · topic 32 of 43 · 35 min

Routing, model binding, validation and status codes

Explanation

Routing matches the request URL and method to an endpoint. Model binding fills your handler's parameters from the route, the query string, headers or the JSON body. Validation rejects bad input with 400 Bad Request before it reaches your logic. Returning the right status code (200, 201, 204, 400, 401, 403, 404, 409, 500) tells clients exactly what happened.

Routing

A route is a URL pattern. ASP.NET Core compares each request's path and method to the routes you mapped and picks the best match. Curly braces mark route parameters, and constraints after a colon restrict them.

csharp
app.MapGet("/trains/{number}", (string number) => $"Train {number}");
app.MapGet("/orders/{id:int}", (int id) => $"Order {id}");
app.MapGet("/bookings/{date:datetime}", (DateTime date) => date.ToShortDateString());
app.MapGet("/files/{*path}", (string path) => path);   // catch-all: matches a/b/c.txt

var api = app.MapGroup("/api/v1/products");            // shared prefix
api.MapGet("/", () => "all products");
api.MapGet("/{id:int}", (int id) => $"product {id}");
MapGroup keeps related endpoints together under one prefix.

Model binding: where parameters come from

SourceMinimal APIController attributeExample
RouteName matches a {route} value[FromRoute]/orders/{id}
Query stringSimple type not in the route[FromQuery]/search?q=kurta&page=2
Body (JSON)Complex type on POST/PUT[FromBody]{"name":"Pen","price":10}
Header[FromHeader][FromHeader]X-Request-Id: abc
ServicesRegistered service type[FromServices]IOrderService
csharp
app.MapGet("/search", (string q, int page = 1, int size = 20) =>
    $"Searching '{q}', page {page}, size {size}");
// GET /search?q=kurta&page=2  ->  Searching 'kurta', page 2, size 20

app.MapPost("/orders", (CreateOrder order) => Results.Ok(order));
// POST /orders with JSON {"productId":7,"quantity":2}

public record CreateOrder(int ProductId, int Quantity);
Optional parameters get their defaults when missing. A required query parameter that is missing gives 400.

Validation: never trust input

Anything can arrive in a request: empty names, negative quantities, a 5 MB "address". Validate at the edge, before the data touches your database. The common tool is data annotations: attributes on properties that state the rules.

csharp
using System.ComponentModel.DataAnnotations;

public class CreateProduct
{
    [Required, StringLength(100, MinimumLength = 2)]
    public string Name { get; set; } = "";

    [Range(1, 1_000_000)]
    public decimal Price { get; set; }

    [EmailAddress]
    public string? SupplierEmail { get; set; }
}
Required, StringLength, Range and EmailAddress are built-in validation attributes.

In controllers marked [ApiController], these rules run automatically, and a bad request gets a 400 response with a ProblemDetails body listing every error, before your action runs. From .NET 10, minimal APIs can do the same after you call builder.Services.AddValidation(); on older versions you check by hand (or use a library such as FluentValidation) and return Results.ValidationProblem.

csharp
app.MapPost("/products", (CreateProduct p) =>
{
    var errors = new Dictionary<string, string[]>();
    if (string.IsNullOrWhiteSpace(p.Name)) errors["name"] = new[] { "Name is required" };
    if (p.Price <= 0) errors["price"] = new[] { "Price must be positive" };
    if (errors.Count > 0) return Results.ValidationProblem(errors);

    // ... save ...
    return Results.Created($"/products/1", p);
});
Manual validation works in every version. Results.ValidationProblem sends 400 with a standard error format.

Status codes: say what happened

CodeNameUse when
200OKSuccess, with data in the body
201CreatedA POST created something; include its location
204No ContentSuccess with nothing to return (e.g. DELETE)
400Bad RequestThe input is invalid
401UnauthorizedNot logged in / no valid token
403ForbiddenLogged in but not allowed
404Not FoundThe resource does not exist
409ConflictClashes with current state (e.g. seat already booked)
500Internal Server ErrorA bug or crash on the server; never send on purpose for bad input
Careful: Returning 200 with {"success": false} for every error forces every client to read the body to know what happened, and breaks caching and monitoring. Use proper status codes. And never send exception details or stack traces to clients in production.

Common mistakes and fixes

  • Using a database entity class directly as the input type: clients can set fields they should not (like IsAdmin or Id). Use small input records (DTOs, Data Transfer Objects) for requests.
  • Two routes that match the same URL: an "ambiguous match" error at run time. Make routes distinct or add constraints.
  • Expecting a GET to read a JSON body: GET requests should use route and query parameters.
  • Validating only in the mobile app: anyone can call your API directly with tools like curl. The server must validate.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Routing, model binding, validation and status codes · 1/7

Routing · binding · validation

In this lesson you will control which code handles each request, where the inputs come from, and what status the client gets.

Prefer a video by a teacher? Find free videos on “ASP.NET Core routing model binding validation status codes web api” on YouTube ↗

In real life: A seat booking endpoint for a bus company

A bus booking API must reject bad input (400), unknown buses (404) and seats already taken (409), and return 201 when a booking succeeds. Clear status codes let the app show the right message: "invalid seat", "bus not found" or "someone just booked this seat".

csharp
var booked = new HashSet<(int Bus, int Seat)> { (501, 12) };
var buses = new HashSet<int> { 501, 502 };

app.MapPost("/buses/{bus:int}/bookings", (int bus, BookSeat req) =>
{
    if (req.Seat is < 1 or > 40)
        return Results.ValidationProblem(new Dictionary<string, string[]> { ["seat"] = new[] { "Seat must be 1 to 40" } });
    if (!buses.Contains(bus))
        return Results.NotFound();
    if (!booked.Add((bus, req.Seat)))
        return Results.Conflict(new { message = $"Seat {req.Seat} is already booked" });
    return Results.Created($"/buses/{bus}/bookings/{req.Seat}", new { bus, req.Seat, req.Passenger });
});

public record BookSeat(int Seat, string Passenger);
Seat 12 on bus 501 returns 409, seat 50 returns 400, bus 999 returns 404 and seat 13 returns 201.

Practice (0/4 done)

Task 1 · Question

GET /search?q=rice&page=3 with handler (string q, int page = 1). What is page?

Task 2 · Question

A user tries to book a seat that was just booked by someone else. Best status code?

Task 3 · Question

Where does a POST handler parameter of type CreateOrder (a record) usually come from in a minimal API?

Task 4 · Practice

Add a POST /students endpoint that accepts { "name": "...", "rollNo": 12, "email": "..." }. Reject: empty name, rollNo outside 1 to 500, and an email without "@". Return 400 with all errors together, 409 if the roll number already exists, and 201 on success.

Quick quiz

Question 1

1.What is the difference between 401 and 403?

Question 2

2.What does [ApiController] do for invalid model state?

Question 3

3.Why not accept your database entity class directly as API input?

Question 4

4.Which status is right for a successful DELETE with nothing to return?

Question 5

5.What does MapGroup("/api/v1/products") help with?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

What is the difference between 401 and 403?

Answer: 401: not authenticated; 403: authenticated but not allowed

Unauthorized means "who are you?"; Forbidden means "you may not do this".

What does [ApiController] do for invalid model state?

Answer: Automatically returns 400 with the validation errors

Automatic 400 responses are part of [ApiController].

Why not accept your database entity class directly as API input?

Answer: Clients could set fields they should not, such as Id or IsAdmin

Use dedicated input DTOs to avoid over-posting.

Which status is right for a successful DELETE with nothing to return?

Answer: 204

204 No Content.

What does MapGroup("/api/v1/products") help with?

Answer: Sharing a route prefix (and settings) across related endpoints

Route groups reduce repetition.

Which route handles GET /products?sort=price?Related

Answer: GET /products

The query string is not part of routing. The handler of GET /products reads it.

From Routing: sending each request to the right code

Why make handlers depend on a Store interface?Related

Answer: The storage can change and tests can use a fake without touching handlers

This is the "accept interfaces" idea applied to APIs.

From REST API design and JSON handlers

The JSON is fine, but age is -4. Which status fits best?Related

Answer: 422 Unprocessable Content

The request is well-formed, but a value breaks a rule. Many APIs also use 400 for this.

From Validating input

In Express 5, what happens when an async route handler throws an error?Related

Answer: Express passes the error to the error-handling middleware automatically

Express 5 catches rejected promises from handlers and calls next(err) for you. Express 4 needed try/catch or a wrapper.

From Route handlers that follow the contract

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 3 more topics in “Web APIs with ASP.NET Core and EF Core” before its exam: Dependency injection and service lifetimes, Configuration, secrets and the middleware pipeline, Entity Framework Core: databases with C#.
  2. Module 8 exam: Web APIs with ASP.NET Core and EF Core · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Testing, tooling and Unity. 43 topics left in the course before the final exam.

← Previous: ASP.NET Core: your first Web API