DEV Community

Cover image for Getting Request Handling Right in .NET APIs
Kazem
Kazem

Posted on

Getting Request Handling Right in .NET APIs

Every request handler answers three questions: where does this data come from, is it well-formed, and is it legal? Most bugs I see in API code come from answering these in the wrong place: validating something in the controller that belongs in the domain, or trying to bind data that should've been rejected by a route constraint before your code even ran. .NET already gives you the tools to keep these separate. The trick is using them for what they're actually for.

Where does the data come from

Take a single action signature:

[HttpPost("{id:guid}/comments")]
public async Task<IActionResult> Add(
    [FromRoute] Guid id,
    [FromQuery] bool notify,
    [FromBody]  CommentRequest body,
    [FromHeader(Name = "X-Idempotency-Key")] string? idempotencyKey,
    [FromServices] ICommentService service,
    CancellationToken ct)
Enter fullscreen mode Exit fullscreen mode

Five different binding sources, five different jobs. id comes from the route because it identifies the resource. notify sits in the query string since it's an optional modifier, and body, the actual payload, belongs in the request body. The idempotency key is transport-level metadata rather than part of the resource, so it comes from a header. The service isn't part of the request at all; it comes from DI.

None of this is exotic. It's just naming things by where they belong instead of dumping everything into one bag and sorting it out in code. When you see a handler that manually reads Request.Query["notify"] inside the method body, that's usually a sign someone skipped past the binding attributes rather than a sign they weren't available.

Route constraints do part of this job before your action even runs. {id:guid} rejects anything that isn't a valid GUID at the routing layer. The request never reaches your handler, so you don't write an if (!Guid.TryParse(...)) check that's really just working around a mistake. {page:int:min(1)} does the same thing for pagination: no page zero, no negative pages, no need for a manual bounds check in every paginated endpoint. Use them. They're free correctness you don't have to write yourself.

Is the input well-formed

Once the request is bound, the next question is whether the values make sense on their own: not whether the operation is allowed, just whether the shape is right.

public sealed record CreatePostRequest
{
    [Required, StringLength(200, MinimumLength = 3)]
    public required string Title { get; init; }

    [Required]
    public required string Body { get; init; }

    public DateTimeOffset? PublishedAt { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

With [ApiController] on the controller, a failing model state returns 400 plus a ValidationProblemDetails body automatically. You don't write that branch yourself. And as of .NET 10, minimal APIs get the same thing without a hand-rolled filter: AddValidation() wires up DataAnnotations validation directly, so the gap between controllers and minimal APIs on this point mostly closes.

For anything past "is this field required and how long," DataAnnotations starts to strain. Conditional rules, cross-field checks, async checks like uniqueness: that's FluentValidation territory. I keep it in the Application layer, not on the entity. The reason is the question it's answering: request validation asks "is this input well-formed?" The domain asks "is this operation legal?" Those are different questions with different answers depending on context, and collapsing them into one validation step is where things get messy. A title being too short is always wrong. A post being published while the author is suspended is only wrong because of state the entity itself owns. Mixing those means either the entity ends up depending on request shapes it shouldn't know about, or the request validator starts reaching into business rules it has no business enforcing.

Shaping the response

On the way out, prefer TypedResults over Results:

return TypedResults.Ok(dto);                                  // 200
return TypedResults.Created($"/api/posts/{dto.Id}", dto);     // 201 + Location
return TypedResults.NoContent();                              // 204
return TypedResults.NotFound();                               // 404
return TypedResults.ValidationProblem(errors);                // 400
return TypedResults.Problem(statusCode: 409, title: "Conflict");
return TypedResults.Stream(stream, "text/csv", "export.csv");
Enter fullscreen mode Exit fullscreen mode

The difference isn't cosmetic. The concrete type flows into OpenAPI generation and is assertable in tests: you can check that an endpoint returns Created<PostDto> instead of asserting on a generic IActionResult and hoping the status code lines up. Results still works, but you lose that.

For large collections, don't buffer the whole thing into a list before returning it:

app.MapGet("/export", (AppDbContext db, CancellationToken ct) =>
    db.Posts.AsNoTracking().AsAsyncEnumerable());   // IAsyncEnumerable → streamed JSON
Enter fullscreen mode Exit fullscreen mode

IAsyncEnumerable streams the JSON as rows come off the database instead of materializing everything in memory first. For an export endpoint especially, that's the difference between a response that starts immediately and one that hangs while the server loads ten thousand rows before writing a single byte.

Contract hygiene

A few rules I hold to regardless of the endpoint:

DTOs both ways, always. Never accept or return entities directly. Over-posting (a client sending fields it shouldn't be able to set, which then get bound straight onto your entity) is a real vulnerability, not a theoretical one. And returning entities means every change to your domain model is also a silent breaking change to your API clients, whether you meant it to be or not.

Version from day one, either /api/v1/... or a header. Retro-fitting versioning onto an API that's already live and already has consumers is painful in a way that's hard to appreciate until you're the one doing it.

For docs, AddOpenApi() / MapOpenApi() generates the OpenAPI document now. Swashbuckle's templates left as of .NET 9. Pair it with Scalar or Swagger UI for the browsable page.

ProblemDetails everywhere, including on 500s. One error shape across the whole API means clients write one error-handling path instead of special-casing whatever shape each endpoint happened to return.

And status codes should mean what they say: 401 is "who are you," 403 is "I know who you are and no," 409 is a concurrency conflict, 422 is semantically invalid input: the request was well-formed but the operation itself can't happen. Getting these right isn't pedantry. It's the difference between a client that can programmatically decide whether to retry, prompt for re-authentication, or just fail, versus a client that has to parse your error message to figure out what happened.

What trips people up isn't any single piece of this. It's blending the layers: validating business rules in a request DTO, or handling routing concerns inside a service method. Keep each question where it belongs, and the framework does most of the work for you.

Top comments (0)