Welcome to the first article in the .NET Web API Zero to Hero FREE course! If you’re building APIs in .NET - whether you’re just starting out or have been doing it for years - this guide will give you a rock-solid foundation in REST principles and API design best practices, fully updated for .NET 10.
I’ve reviewed hundreds of .NET codebases over the years, and the number one issue I see isn’t performance or architecture - it’s inconsistent, poorly designed APIs. Endpoints named /api/getUserById, query parameters used for resource identification, 200 OK returned for errors, and zero versioning strategy. These APIs work, but they’re a nightmare to maintain and integrate with.
By the end of this article, you’ll understand why REST principles matter and how to apply them in your .NET projects. You won’t just know the rules - you’ll understand when to break them and why.
The complete sample code lives at github.com/codewithmukesh/dotnet-webapi-zero-to-hero-course - clone it and follow along.
TL;DR - REST API best practices in .NET 10: Model resources as plural nouns (
/api/users, not/api/getUsers) and let HTTP methods carry the verb - GET (safe, idempotent), POST (create, returns201+Location), PUT (full replace, idempotent), PATCH (partial), DELETE (idempotent). Return accurate status codes (never200 OKfor errors), and standardize errors on RFC 9457 Problem Details viaAddProblemDetails(). Version from day one (URI path versioning -/api/v1/), paginate every collection, cache reads with ETags and output caching, make unsafe retries safe with idempotency keys, and guard writes with optimistic concurrency (ETag+If-Match→412). Build it on .NET 10 Minimal APIs, document it with the built-in OpenAPI generator + Scalar, and secure it with HTTPS, JWT, and rate limiting. New in 2026: the QUERY method (RFC 10008) for safe, cacheable reads that need a request body.
What is REST (And Why Should You Care)?
REST (Representational State Transfer) is an architectural style for building networked applications. It was defined by Roy Fielding in his 2000 doctoral dissertation and has become the de facto standard for web APIs.
But here’s what most tutorials get wrong: REST is not a protocol or specification - it’s a set of architectural constraints. An API that uses HTTP and JSON isn’t automatically RESTful. You can build a perfectly valid HTTP API that violates every REST principle.
REST vs. HTTP: Not the Same Thing
HTTP is a protocol. REST is an architectural style that typically uses HTTP. You can build non-RESTful APIs over HTTP (like SOAP or RPC-style APIs), and theoretically, you could build RESTful systems over other protocols.
When we talk about building “REST APIs” in .NET, we really mean HTTP APIs that follow REST constraints - using resources, standard methods, proper status codes, and stateless communication.
Why REST Won
Before REST dominated, we had:
- SOAP: XML-based, envelope-wrapped, WSDL contracts. Heavy, complex, and painful to debug.
- XML-RPC: Procedure calls over HTTP. Simple but not resource-oriented.
- Custom protocols: Every team invented their own conventions.
REST won because it’s:
| Advantage | Why It Matters |
|---|---|
| Simple | Uses HTTP verbs you already know (GET, POST, PUT, DELETE) |
| Scalable | Stateless design enables horizontal scaling |
| Flexible | Supports multiple formats (JSON, XML, etc.) |
| Cacheable | Built-in HTTP caching reduces server load |
| Decoupled | Client and server evolve independently |
For .NET developers, REST means you can use ASP.NET Core’s minimal APIs or controllers to build APIs that any client - mobile apps, SPAs, microservices - can consume without specialized tooling.
The Six REST Constraints (Most Developers Only Know Three)
Roy Fielding defined six constraints that make an architecture “RESTful.” Most developers know about statelessness and uniform interface, but the others matter too.
1. Client-Server Separation
The client (consumer) and server (API) must be independent. The client doesn’t know how the server stores data. The server doesn’t care what UI framework the client uses.
Why it matters: You can rewrite your entire .NET backend without changing mobile apps, or build a new React frontend without touching the API. True separation of concerns.
2. Statelessness
Each request from client to server must contain all information needed to understand and process the request. The server doesn’t store client session state between requests.
What this means practically:
// BAD: Relying on server-side sessionapp.MapPost("/api/orders", (HttpContext context) =>{ var userId = context.Session.GetInt32("UserId"); // Session state! // Create order for this user});
// GOOD: All context in the requestapp.MapPost("/api/orders", (CreateOrderRequest request, ClaimsPrincipal user) =>{ var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; // From JWT // Create order for this user});Benefits:
- Scalability: Any server instance can handle any request - no sticky sessions needed.
- Reliability: Server crashes don’t lose client state.
- Simplicity: No session synchronization across server instances.
3. Cacheability
Responses must define themselves as cacheable or non-cacheable. If cacheable, clients (and intermediaries like CDNs) can reuse responses for equivalent requests.
app.MapGet("/api/products/{id}", async (int id, ProductService service) =>{ var product = await service.GetByIdAsync(id); return product is null ? Results.NotFound() : Results.Ok(product);}).CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(10))); // Cacheable for 10 minutesThe response includes headers like:
Cache-Control: public, max-age=600ETag: "abc123"4. Uniform Interface
This is the most important REST constraint. It defines four sub-constraints:
4.1 Resource Identification
Resources are identified by URIs. Everything is a resource - users, orders, products, even abstract concepts like search results.
/api/users → Collection of users/api/users/42 → Specific user/api/users/42/orders → Orders belonging to user 424.2 Resource Manipulation Through Representations
Clients interact with resources through representations (usually JSON). The representation isn’t the resource itself - it’s a snapshot of its state.
{ "id": 42, "name": "John Doe", "email": "john@example.com", "createdAt": "2026-01-02T10:30:00Z"}The actual User object in your database has more fields, relationships, and internal state. The JSON is a representation suitable for the client.
4.3 Self-Descriptive Messages
Each message includes enough information to describe how to process it. HTTP headers play a key role:
POST /api/users HTTP/1.1Host: api.example.comContent-Type: application/json ← How to parse the bodyAccept: application/json ← What format I want backAuthorization: Bearer eyJhb... ← How to authenticate me
{"name": "John Doe", "email": "john@example.com"}4.4 HATEOAS (Hypermedia as the Engine of Application State)
Responses should include links to related actions and resources. This is the most controversial and least-implemented constraint.
{ "id": 42, "name": "John Doe", "_links": { "self": { "href": "/api/users/42" }, "orders": { "href": "/api/users/42/orders" }, "update": { "href": "/api/users/42", "method": "PUT" }, "delete": { "href": "/api/users/42", "method": "DELETE" } }}Practical reality: Full HATEOAS is rarely implemented in practice. Most “REST APIs” skip this constraint. That’s okay - pragmatism beats purity. But understanding it helps you design more discoverable APIs.
5. Layered System
The client doesn’t know (or care) whether it’s communicating directly with the end server or through intermediaries like load balancers, API gateways, or CDNs.
┌────────┐ ┌───────────┐ ┌──────────────┐ ┌────────────┐│ Client │ ─► │ Cloudflare│ ─► │ API Gateway │ ─► │ .NET API ││ │ │ CDN │ │ (Auth, Rate │ │ (Business ││ │ │ │ │ Limiting) │ │ Logic) │└────────┘ └───────────┘ └──────────────┘ └────────────┘ ▲ │ Client thinks it's talking directly to API6. Code on Demand (Optional)
Servers can extend client functionality by transferring executable code (like JavaScript). This is optional and rarely used for APIs - it’s more relevant for web browsers.
RESTful API Design Best Practices
Now let’s get into the practical patterns that separate well-designed APIs from the chaos I see in production codebases.
Use Nouns, Not Verbs
URIs identify resources. Use nouns. The HTTP method provides the verb.
| ❌ Bad (Verbs in URI) | ✅ Good (Resource Nouns) |
|---|---|
GET /api/getUsers | GET /api/users |
POST /api/createUser | POST /api/users |
PUT /api/updateUser/42 | PUT /api/users/42 |
DELETE /api/deleteUser/42 | DELETE /api/users/42 |
GET /api/getUserOrders/42 | GET /api/users/42/orders |
The HTTP method already tells us the action:
// The method IS the verbapp.MapGet("/api/users", GetUsers); // GET = retrieveapp.MapPost("/api/users", CreateUser); // POST = createapp.MapPut("/api/users/{id}", UpdateUser); // PUT = replaceapp.MapDelete("/api/users/{id}", DeleteUser);// DELETE = removeUse Plural Nouns for Collections
Always use plural nouns for resource collections. It’s consistent and reads naturally.
| ❌ Inconsistent | ✅ Consistent (Plural) |
|---|---|
/api/user | /api/users |
/api/user/42 | /api/users/42 |
/api/product | /api/products |
/api/order | /api/orders |
This stays consistent whether you’re accessing the collection or a single item:
GET /api/users → Returns list of usersGET /api/users/42 → Returns single userPOST /api/users → Creates a user (in the users collection)Use Nesting to Show Relationships
When resources have parent-child relationships, use nested routes to express them.
# Orders belong to usersGET /api/users/42/orders → All orders for user 42GET /api/users/42/orders/7 → Order 7 for user 42POST /api/users/42/orders → Create order for user 42
# Items belong to ordersGET /api/orders/7/items → All items in order 7POST /api/orders/7/items → Add item to order 7But don’t go too deep. Three levels is usually the maximum before URLs become unwieldy:
# Too deep - hard to read and useGET /api/users/42/orders/7/items/3/options/1
# Better - flatten when entities can stand aloneGET /api/order-items/3/options/1Rule of thumb: If the child resource can exist independently (like an order that could belong to a guest), consider a flat structure with query parameters instead:
GET /api/orders?userId=42 → Alternative to /users/42/ordersPath Parameters vs. Query Parameters
Path parameters identify a specific resource:
GET /api/users/42 → User with ID 42GET /api/products/abc-123 → Product with slug abc-123Query parameters filter, sort, paginate, or modify the response:
GET /api/users?role=admin&sort=name → Filter and sortGET /api/products?category=electronics → Filter by categoryGET /api/orders?page=2&pageSize=20 → Pagination| Use Path Parameters When | Use Query Parameters When |
|---|---|
| Identifying a resource | Filtering a collection |
| Required for the request | Optional refinements |
| Part of the resource hierarchy | Sorting, pagination, search |
Never do this:
# BAD: Path for filteringGET /api/users/role/admin/sort/name/page/2
# GOOD: Query for filteringGET /api/users?role=admin&sort=name&page=2HTTP Methods Deep Dive
Getting HTTP methods right is fundamental. Here’s the complete picture.
GET – Retrieve Resources
Retrieve a resource without modifying it. Must be safe and idempotent.
app.MapGet("/api/users", async (UserService service) =>{ var users = await service.GetAllAsync(); return Results.Ok(users);});
app.MapGet("/api/users/{id:int}", async (int id, UserService service) =>{ var user = await service.GetByIdAsync(id); return user is null ? Results.NotFound() : Results.Ok(user);});Response: 200 OK with resource body, or 404 Not Found if it doesn’t exist.
POST – Create Resources
Create a new resource. Not idempotent - calling it twice creates two resources.
app.MapPost("/api/users", async (CreateUserRequest request, UserService service) =>{ var user = await service.CreateAsync(request); return Results.Created($"/api/users/{user.Id}", user);});Key points:
- Return
201 Created, not200 OK - Include
Locationheader pointing to the new resource - Return the created resource in the body
PUT – Replace Resources
Replace an entire resource. Must be idempotent - calling it multiple times produces the same result.
app.MapPut("/api/users/{id:int}", async (int id, UpdateUserRequest request, UserService service) =>{ var user = await service.GetByIdAsync(id); if (user is null) return Results.NotFound();
await service.UpdateAsync(id, request); return Results.NoContent();});Key points:
- Client sends the complete resource representation
- Missing fields are set to null/default (not ignored)
- Return
204 No Contenton success (or200 OKwith updated resource)
PATCH – Partial Updates
Update specific fields of a resource. Use when you only want to modify some properties.
app.MapPatch("/api/users/{id:int}", async (int id, JsonPatchDocument<User> patchDoc, UserService service) =>{ var user = await service.GetByIdAsync(id); if (user is null) return Results.NotFound();
patchDoc.ApplyTo(user); await service.UpdateAsync(id, user); return Results.NoContent();});Or with a simpler DTO approach:
public record PatchUserRequest(string? Name, string? Email);
app.MapPatch("/api/users/{id:int}", async (int id, PatchUserRequest request, UserService service) =>{ var user = await service.GetByIdAsync(id); if (user is null) return Results.NotFound();
// Only update provided fields if (request.Name is not null) user.Name = request.Name; if (request.Email is not null) user.Email = request.Email;
await service.UpdateAsync(id, user); return Results.NoContent();});DELETE – Remove Resources
Delete a resource. Should be idempotent - deleting something twice shouldn’t error (the second request can return 404).
app.MapDelete("/api/users/{id:int}", async (int id, UserService service) =>{ var user = await service.GetByIdAsync(id); if (user is null) return Results.NotFound();
await service.DeleteAsync(id); return Results.NoContent();});Response: 204 No Content on success.
PUT vs. PATCH – When to Use Each
| Aspect | PUT | PATCH |
|---|---|---|
| Payload | Complete resource | Only changed fields |
| Missing fields | Reset to null/default | Left unchanged |
| Idempotent | Yes | Depends on implementation |
| Use when | Replacing the whole resource | Updating a few fields |
Example:
// Current user state{ "id": 42, "name": "John Doe", "email": "john@example.com", "phone": "555-1234"}
// PUT request - replaces everythingPUT /api/users/42{ "name": "John Smith", "email": "john.smith@example.com"}// Result: phone is now NULL (not provided)
// PATCH request - updates only provided fieldsPATCH /api/users/42{ "name": "John Smith"}// Result: phone and email unchangedLess Common Methods
- HEAD: Like GET but returns only headers (no body). Useful for checking if a resource exists.
- OPTIONS: Returns allowed methods for a resource. Used by browsers for CORS preflight.
- QUERY: A brand-new method (RFC 10008, June 2026) for safe, idempotent reads that need a request body. It gets its own section below.
The QUERY Method: A Safe Verb That Carries a Body (RFC 10008)
There’s a gap in HTTP that nearly every API developer has hit. You need a read operation - safe, idempotent, cacheable - but the query itself is too big or too structured to fit in a URL. Think a search with deeply nested filters, a GraphQL-style field selection, or a reporting query with dozens of parameters. Until recently you had two bad options:
- GET with a giant query string - but URLs have practical length limits (proxies and servers often cap somewhere between 2 KB and 8 KB), and nested filters URL-encode into an unreadable mess.
- POST with a JSON body - clean to send, but now you’ve lied to the entire HTTP stack. POST signals “this might change state,” so caches won’t store the response and proxies won’t safely retry it.
On June 15, 2026, the IETF closed that gap by publishing RFC 10008 - The HTTP QUERY Method. It spent years as the draft-ietf-httpbis-safe-method-w-body working group draft before reaching Proposed Standard. In one line, QUERY is a GET that’s allowed to have a request body: safe, idempotent, and cacheable like GET, but it carries content like POST.
QUERY vs. GET vs. POST
| Property | GET | POST | QUERY |
|---|---|---|---|
| Safe (no state change) | Yes | No | Yes |
| Idempotent | Yes | No | Yes |
| Cacheable | Yes | Not in practice | Yes |
| Carries a request body | No | Yes | Yes |
Because QUERY is safe and idempotent, intermediaries can do things they could never safely do with POST. A CDN or reverse proxy can cache the response - keyed on the request body, not just the URL - and a client can automatically retry a timed-out request without any risk of double-processing.
What a QUERY Request Looks Like on the Wire
The query lives in the body, in whatever media type fits - form encoding, JSON, or a query DSL:
QUERY /api/v1/products HTTP/1.1Host: api.example.comContent-Type: application/jsonAccept: application/json
{ "category": "electronics", "priceRange": { "min": 100, "max": 500 }, "tags": ["wireless", "noise-cancelling"], "sort": [{ "field": "price", "direction": "desc" }], "page": 1, "pageSize": 20}A successful response returns the results, exactly like GET would:
HTTP/1.1 200 OKContent-Type: application/jsonContent-Location: /api/v1/products?q=a1b2c3
{ "data": [ ... ], "pagination": { ... } }Two response headers from the spec are worth knowing:
Content-Locationpoints to a URL where the same results can be fetched with a plain GET - handy for sharing or bookmarking a query result.Accept-Queryis a response header a server can send (for example, onOPTIONS) to advertise that it supports QUERY and which body media types it accepts.
A QUERY that matches nothing returns 204 No Content; an unsupported body format returns 415 Unsupported Media Type.
Building a QUERY Endpoint in .NET 10
.NET 10 doesn’t ship a MapQuery helper yet - QUERY is only days old - but Minimal APIs already route arbitrary methods through MapMethods. You pass the verb as a plain string:
app.MapMethods("/api/v1/products", ["QUERY"], async ( HttpContext http, IProductService service) =>{ // The query lives in the request body, not the URL var query = await http.Request.ReadFromJsonAsync<ProductQuery>(); if (query is null) return Results.BadRequest();
var (products, totalCount) = await service.SearchAsync(query);
return Results.Ok(new { data = products, pagination = new { query.Page, query.PageSize, totalCount } });});
public record ProductQuery( string? Category, PriceRange? PriceRange, string[]? Tags, int Page = 1, int PageSize = 20);
public record PriceRange(decimal Min, decimal Max);If you want it to read as cleanly as MapGet, wrap it in an extension method once and reuse it everywhere:
public static class QueryEndpointExtensions{ public static IEndpointConventionBuilder MapQuery( this IEndpointRouteBuilder endpoints, string pattern, Delegate handler) => endpoints.MapMethods(pattern, ["QUERY"], handler);}
// Now it reads just like the other verbs:app.MapQuery("/api/v1/products", SearchProducts);Calling a QUERY Endpoint
HttpClient has no QueryAsync, but the HttpMethod constructor accepts any verb:
using var client = new HttpClient { BaseAddress = new Uri("https://api.example.com") };
var request = new HttpRequestMessage(new HttpMethod("QUERY"), "/api/v1/products"){ Content = JsonContent.Create(new ProductQuery( Category: "electronics", PriceRange: new PriceRange(100, 500), Tags: ["wireless"]))};
var response = await client.SendAsync(request);var result = await response.Content.ReadFromJsonAsync<ProductSearchResult>();Should You Use QUERY Today?
Honest answer: carefully, and not everywhere. A few things I’d weigh before shipping it:
- Tooling is still catching up. As of mid-2026, OpenAPI generators (including .NET’s built-in one and Swashbuckle), Scalar, and Postman don’t model the QUERY verb yet, so it won’t appear in your generated docs. Expect that to improve quickly now that it’s a real RFC.
- Infrastructure may not know it. Some older proxies, WAFs, and CDNs may reject or mishandle an unknown method until they’re updated. Test your edge before relying on it.
- Don’t replace your simple GETs.
GET /api/v1/products/42and small filtered lists are perfect as they are. QUERY only earns its place when the query genuinely doesn’t fit comfortably in a URL.
The sweet spot is exactly what it was designed for: complex, read-only search and reporting endpoints where you were already tempted to “just use POST.” Now you can keep the rich JSON body and get back HTTP’s caching and retry guarantees - without lying about your intent.
Idempotency: Making POST Retries Safe
GET, PUT, and DELETE are naturally idempotent - calling them twice has the same effect as calling them once. POST is the problem child. A client submits a payment, the network times out, the client retries, and now you’ve charged the customer twice. This is one of the most common production bugs in real-world APIs, and almost no tutorial covers it.
The fix is an idempotency key: the client generates a unique key (a GUID) per logical operation and sends it in a header. The server records the key with the result of the first request. If the same key arrives again, the server returns the stored result instead of repeating the work.
POST /api/v1/paymentsIdempotency-Key: 3f1d8c9a-2b6e-4f0a-9c1d-7a5b2e8f1c44Content-Type: application/json
{ "amount": 4999, "currency": "usd" }A minimal implementation backed by a distributed cache:
app.MapPost("/api/v1/payments", async ( PaymentRequest request, HttpContext http, IDistributedCache cache, IPaymentService payments) =>{ if (!http.Request.Headers.TryGetValue("Idempotency-Key", out var key) || string.IsNullOrWhiteSpace(key)) { return Results.BadRequest("Missing Idempotency-Key header."); }
var cacheKey = $"idemp:{key}"; var existing = await cache.GetStringAsync(cacheKey); if (existing is not null) return Results.Ok(JsonSerializer.Deserialize<PaymentResult>(existing)); // Replay
var result = await payments.ChargeAsync(request);
await cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) });
return Results.Ok(result);}).RequireRateLimiting("fixed");Best practices: scope the key to the authenticated user (so keys can’t collide or leak across tenants), store it for a sensible window (24 hours is common), and return 409 Conflict if the same key arrives with a different payload. Stripe, PayPal, and most serious payment APIs implement exactly this pattern - now yours can too.
Distributed Caching in ASP.NET Core with Redis
Set up the IDistributedCache backing store that powers idempotency keys and cross-instance state.
HTTP Status Codes Done Right
Status codes tell the client what happened. Using them correctly makes your API predictable and debuggable.
Success Codes (2xx)
| Code | Name | When to Use |
|---|---|---|
200 OK | Success | GET that returns data, PUT/PATCH that returns updated resource |
201 Created | Resource created | POST that creates a new resource |
204 No Content | Success, no body | DELETE, PUT/PATCH when not returning the resource |
// 200 OK - returning datareturn Results.Ok(users);
// 201 Created - new resource with locationreturn Results.Created($"/api/users/{user.Id}", user);
// 204 No Content - success but nothing to returnreturn Results.NoContent();Client Error Codes (4xx)
| Code | Name | When to Use |
|---|---|---|
400 Bad Request | Invalid input | Malformed JSON, validation errors |
401 Unauthorized | Not authenticated | Missing or invalid credentials |
403 Forbidden | Not authorized | Valid credentials but no permission |
404 Not Found | Resource doesn’t exist | ID not found, bad route |
409 Conflict | State conflict | Duplicate email, version mismatch |
422 Unprocessable Entity | Semantic error | Business rule violation |
429 Too Many Requests | Rate limited | Client exceeded rate limit |
// 400 Bad Request - validation failedreturn Results.BadRequest(new { errors = validationErrors });
// 401 Unauthorized - no/invalid credentialsreturn Results.Unauthorized();
// 403 Forbidden - authenticated but not allowedreturn Results.Forbid();
// 404 Not Found - resource doesn't existreturn Results.NotFound();
// 409 Conflict - can't complete due to statereturn Results.Conflict(new { message = "Email already registered" });Server Error Codes (5xx)
| Code | Name | When to Use |
|---|---|---|
500 Internal Server Error | Server bug | Unhandled exception |
502 Bad Gateway | Upstream error | Proxy/gateway received bad response |
503 Service Unavailable | Temporarily down | Maintenance, overload |
504 Gateway Timeout | Upstream timeout | Proxy/gateway didn’t get response in time |
Never expose stack traces in production 5xx responses. Log them server-side and return a generic error message to clients.
Common Mistakes
❌ Returning 200 for errors:
// BAD: Success status with error bodyreturn Results.Ok(new { success = false, error = "User not found" });
// GOOD: Appropriate error statusreturn Results.NotFound(new { error = "User not found" });❌ Using 500 for client errors:
// BAD: Server error for bad inputthrow new Exception("Invalid email format"); // Results in 500
// GOOD: Client error for bad inputreturn Results.BadRequest(new { error = "Invalid email format" });❌ 401 vs. 403 confusion:
// 401: "Who are you?" - Authentication issue// 403: "I know who you are, but you can't do this" - Authorization issueError Response Design
Consistent error responses make your API easier to consume. Don’t invent your own error envelope - use the industry standard. RFC 9457 (Problem Details for HTTP APIs) is the current standard for machine-readable error responses, published in July 2023. It obsoletes the older RFC 7807 - same application/problem+json media type, same core fields, with clarified guidance. Most tutorials and even ASP.NET Core’s older docs still say “RFC 7807”; the correct reference in 2026 is RFC 9457.
A Problem Details response is a JSON object with a few well-known members: type (a URI identifying the problem category), title, status, detail, and instance, plus any custom extension members you add.
Example Responses
Validation Error (400):
{ "type": "https://www.rfc-editor.org/rfc/rfc9110#section-15.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "email": ["The email field is required."], "name": ["Name must be between 2 and 100 characters."] }}Not Found (404):
{ "type": "https://www.rfc-editor.org/rfc/rfc9110#section-15.5.5", "title": "Not Found", "status": 404, "detail": "User with ID 42 was not found.", "instance": "/api/users/42"}Conflict (409):
{ "type": "https://example.com/problems/duplicate-email", "title": "Conflict", "status": 409, "detail": "A user with email john@example.com already exists.", "conflictingResource": "/api/users/17"}Implementing in .NET 10
ASP.NET Core ships first-class RFC 9457 support. AddProblemDetails() registers IProblemDetailsService, which turns unhandled exceptions and bare status codes (404, 400, and so on) into application/problem+json automatically:
builder.Services.AddProblemDetails(options =>{ options.CustomizeProblemDetails = context => { context.ProblemDetails.Instance = context.HttpContext.Request.Path; context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier; };});
var app = builder.Build();
app.UseExceptionHandler(); // Converts exceptions to Problem Detailsapp.UseStatusCodePages(); // Converts bare 4xx/5xx to Problem DetailsFor typed, per-exception responses, implement IExceptionHandler (the recommended .NET 10 pattern) instead of a raw UseExceptionHandler lambda. It composes cleanly, supports multiple ordered handlers, and keeps Program.cs thin:
public sealed class AppExceptionHandler : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync( HttpContext context, Exception exception, CancellationToken ct) { var (status, title) = exception switch { NotFoundException => (StatusCodes.Status404NotFound, "Not Found"), ValidationException => (StatusCodes.Status400BadRequest, "Validation Error"), _ => (StatusCodes.Status500InternalServerError, "Server Error") };
return await context.RequestServices .GetRequiredService<IProblemDetailsService>() .TryWriteAsync(new ProblemDetailsContext { HttpContext = context, ProblemDetails = new ProblemDetails { Status = status, Title = title, Detail = exception.Message } }); }}
// Program.csbuilder.Services.AddExceptionHandler<AppExceptionHandler>();From an endpoint, TypedResults.Problem(...) returns a strongly-typed Problem Details result that also flows into OpenAPI. I cover the full pattern (chained handlers, validation mapping, logging) in the dedicated guides below.
Problem Details in ASP.NET Core (RFC 9457)
The complete guide to standardized API errors - IProblemDetailsService, custom extensions, and validation mapping.
Global Exception Handling in ASP.NET Core
Implement IExceptionHandler the right way and stop leaking stack traces to clients.
API Versioning Strategies
Your API will evolve. Breaking changes are inevitable. Versioning prevents you from breaking existing clients.
URI Path Versioning (Recommended)
Version in the URL path. Most explicit and cache-friendly.
GET /api/v1/usersGET /api/v2/usersvar v1 = app.MapGroup("/api/v1");var v2 = app.MapGroup("/api/v2");
v1.MapGet("/users", GetUsersV1);v2.MapGet("/users", GetUsersV2);Query String Versioning
Version as a query parameter.
GET /api/users?api-version=1.0GET /api/users?api-version=2.0Header Versioning
Version in a custom header.
GET /api/usersX-API-Version: 2.0Which to Choose?
| Strategy | Pros | Cons |
|---|---|---|
| URI Path | Explicit, cacheable, easy to test | Duplicates routes, “ugly” URLs |
| Query String | Simple, optional | Less discoverable, harder to cache |
| Header | Clean URLs | Hidden, requires documentation |
My recommendation: URI Path versioning. It’s the most discoverable and debuggable. When a client reports an issue, you immediately know which version they’re using.
Implementing with Asp.Versioning
builder.Services.AddApiVersioning(options =>{ options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; options.ApiVersionReader = new UrlSegmentApiVersionReader();}).AddApiExplorer(options =>{ options.GroupNameFormat = "'v'VVV"; options.SubstituteApiVersionInUrl = true;});Deprecation Strategy
When deprecating a version:
- Add
Sunsetheader with deprecation date - Communicate to clients via email/docs
- Keep deprecated version running for a transition period
- Monitor usage and remove when safe
app.MapGet("/api/v1/users", GetUsersV1) .WithMetadata(new ObsoleteAttribute("Use /api/v2/users instead. Sunset: 2026-06-01"));.NET Interview Questions
300+ real .NET interview questions with answers, red flags, and follow-ups - C#, EF Core, ASP.NET Core, system design
Pagination, Filtering, and Sorting
Any endpoint returning a collection needs pagination. Nobody wants to fetch 10,000 records when they need 20.
Pagination
Offset-based (most common):
GET /api/users?page=2&pageSize=20Response includes metadata:
{ "data": [...], "pagination": { "page": 2, "pageSize": 20, "totalPages": 15, "totalCount": 287, "hasNext": true, "hasPrevious": true }}Cursor-based (better for large datasets):
GET /api/users?cursor=eyJpZCI6NDJ9&limit=20public record PagedResponse<T>( IEnumerable<T> Data, PaginationMeta Pagination);
public record PaginationMeta( int Page, int PageSize, int TotalPages, int TotalCount, bool HasNext, bool HasPrevious);
app.MapGet("/api/users", async ( int page = 1, int pageSize = 20, UserService service) =>{ pageSize = Math.Clamp(pageSize, 1, 100); // Enforce max page size
var (users, totalCount) = await service.GetPagedAsync(page, pageSize); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return Results.Ok(new PagedResponse<User>(users, new PaginationMeta( page, pageSize, totalPages, totalCount, HasNext: page < totalPages, HasPrevious: page > 1 )));});Filtering
Use query parameters for filtering:
GET /api/products?category=electronics&minPrice=100&maxPrice=500&inStock=truepublic record ProductFilter( string? Category, decimal? MinPrice, decimal? MaxPrice, bool? InStock, string? Search);
app.MapGet("/api/products", async ([AsParameters] ProductFilter filter, ProductService service) =>{ var products = await service.GetFilteredAsync(filter); return Results.Ok(products);});Sorting
GET /api/users?sort=name&order=ascGET /api/users?sort=-createdAt # Prefix with - for descendingapp.MapGet("/api/users", async ( string? sort = "id", string? order = "asc", UserService service) =>{ var allowedSortFields = new[] { "id", "name", "email", "createdAt" }; if (!allowedSortFields.Contains(sort, StringComparer.OrdinalIgnoreCase)) sort = "id";
var users = await service.GetSortedAsync(sort, order == "desc"); return Results.Ok(users);});Combined Example
GET /api/products?category=electronics&minPrice=100&sort=-price&page=1&pageSize=20This endpoint:
- Filters by category and minimum price
- Sorts by price descending
- Returns page 1 with 20 items per page
Caching for Performance
Proper caching can reduce your server load by 90%+ and dramatically improve response times.
Response Headers
Cache-Control: public, max-age=3600 # Cache for 1 hourCache-Control: private, max-age=300 # User-specific, cache 5 minCache-Control: no-cache # Validate before using cacheCache-Control: no-store # Never cache (sensitive data)
ETag: "abc123" # Version identifierLast-Modified: Wed, 01 Jan 2026 10:00:00 GMTETag Validation
ETags enable conditional requests - the server only sends the full response if the resource has changed.
app.MapGet("/api/products/{id}", async (int id, ProductService service, HttpContext context) =>{ var product = await service.GetByIdAsync(id); if (product is null) return Results.NotFound();
var etag = $"\"{product.Version}\"";
// Check if client has current version if (context.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(304); // Not Modified
context.Response.Headers.ETag = etag; context.Response.Headers.CacheControl = "private, max-age=60";
return Results.Ok(product);});Output Caching in .NET
builder.Services.AddOutputCache(options =>{ options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(10)));
options.AddPolicy("Products", builder => builder.Expire(TimeSpan.FromMinutes(30)) .Tag("products"));
options.AddPolicy("UserSpecific", builder => builder.Expire(TimeSpan.FromMinutes(5)) .SetVaryByQuery("userId"));});
app.UseOutputCache();
app.MapGet("/api/products", GetProducts) .CacheOutput("Products");
app.MapGet("/api/users/{id}/profile", GetUserProfile) .CacheOutput("UserSpecific");Cache Invalidation
app.MapPost("/api/products", async ( CreateProductRequest request, ProductService service, IOutputCacheStore cache) =>{ var product = await service.CreateAsync(request);
// Invalidate product list cache await cache.EvictByTagAsync("products", CancellationToken.None);
return Results.Created($"/api/products/{product.Id}", product);});Mastering Caching in ASP.NET Core
Deep dive into in-memory caching, distributed caching with Redis, and response caching strategies.
Optimistic Concurrency: Preventing Lost Updates
Two clients fetch the same resource, both edit it, and both call PUT. Without protection, the second write silently overwrites the first - the classic lost update problem. The REST-native solution reuses the same ETag you already emit for caching, this time on the write path.
The flow:
- Client
GETs the resource and receives anETag(a version stamp). - Client sends that value back on
PUT/PATCHin anIf-Matchheader. - If the server’s current version still matches, the write proceeds. If it changed in the meantime, the server returns
412 Precondition Failedand the client knows to refetch and retry.
app.MapPut("/api/v1/products/{id:int}", async ( int id, UpdateProductRequest request, HttpContext http, IProductService service) =>{ var product = await service.GetByIdAsync(id); if (product is null) return Results.NotFound();
var currentEtag = $"\"{product.Version}\"";
// Reject the write if the client edited a stale copy if (http.Request.Headers.IfMatch is { Count: > 0 } ifMatch && ifMatch != currentEtag) { return Results.StatusCode(StatusCodes.Status412PreconditionFailed); }
await service.UpdateAsync(id, request); return Results.NoContent();});In EF Core, back the Version with a [Timestamp]/rowversion column or a concurrency token, and a mismatch throws DbUpdateConcurrencyException - which you map to 412. This is the difference between an API that quietly corrupts data under load and one that’s safe for concurrent editing.
Optimistic Concurrency Control in EF Core
Use row versions and concurrency tokens to detect conflicting writes and avoid lost updates.
Security Best Practices
Always Use HTTPS
In production, HTTPS is non-negotiable. Configure HSTS:
if (!app.Environment.IsDevelopment()){ app.UseHsts();}app.UseHttpsRedirection();Authentication with JWT
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)) }; });
app.UseAuthentication();app.UseAuthorization();For the complete implementation - token generation, refresh token rotation, and API keys for service-to-service calls - see the dedicated guides:
JWT Authentication in ASP.NET Core
Issue and validate JWTs end to end on .NET 10, with security best practices baked in.
Refresh Tokens in ASP.NET Core
Add refresh token rotation and reuse detection so short-lived access tokens stay practical.
API Key Authentication in ASP.NET Core
Secure service-to-service and machine clients with API keys when JWTs are overkill.
Rate Limiting
Prevent abuse with rate limiting:
builder.Services.AddRateLimiter(options =>{ options.AddFixedWindowLimiter("fixed", config => { config.Window = TimeSpan.FromMinutes(1); config.PermitLimit = 100; config.QueueLimit = 10; });
options.AddSlidingWindowLimiter("sliding", config => { config.Window = TimeSpan.FromMinutes(1); config.SegmentsPerWindow = 6; config.PermitLimit = 100; });
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;});
app.UseRateLimiter();
app.MapGet("/api/users", GetUsers) .RequireRateLimiting("fixed");Rate Limiting in ASP.NET Core
A deeper look at the four built-in limiter algorithms and how to pick the right one per endpoint.
Input Validation
Never trust client input:
public record CreateUserRequest( [Required, StringLength(100, MinimumLength = 2)] string Name, [Required, EmailAddress] string Email, [Required, MinLength(8)] string Password);
app.MapPost("/api/users", async (CreateUserRequest request, UserService service) =>{ // ASP.NET Core validates automatically when using [Required], etc. var user = await service.CreateAsync(request); return Results.Created($"/api/users/{user.Id}", user);}).WithParameterValidation(); // Requires MinimalApis.ExtensionsPrevent Common Vulnerabilities
- SQL Injection: Always use parameterized queries or EF Core (never string concatenation)
- XSS: ASP.NET Core encodes output by default
- CSRF: Use anti-forgery tokens for form submissions
- Mass Assignment: Use DTOs, never bind directly to entities
API Documentation with OpenAPI
Good documentation is the difference between an API people love and one they hate.
Built-in OpenAPI in .NET
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi(); // Available at /openapi/v1.jsonScalar for Interactive Documentation
using Scalar.AspNetCore;
app.MapScalarApiReference(); // Available at /scalar/v1Enhancing Documentation
app.MapGet("/api/users/{id}", async (int id, UserService service) =>{ var user = await service.GetByIdAsync(id); return user is null ? Results.NotFound() : Results.Ok(user);}).WithName("GetUserById").WithSummary("Get a user by ID").WithDescription("Returns a single user based on their unique identifier. Returns 404 if the user doesn't exist.").Produces<User>(StatusCodes.Status200OK).Produces(StatusCodes.Status404NotFound).WithTags("Users");Swagger is Dead? Here's the Alternative!
Microsoft has introduced a new way to handle API documentation. Learn about the alternatives and how they'll affect your work.
REST vs gRPC vs GraphQL: When to Use Each
REST is the right default for the vast majority of .NET APIs - but it isn’t the only option, and the best engineers know when to reach for something else. Here’s my honest take after building all three in production:
| Style | Best for | Strengths | Trade-offs |
|---|---|---|---|
| REST | Public APIs, CRUD-heavy services, anything consumed by browsers/mobile | Universal tooling, cacheable, simple, self-documenting with OpenAPI | Over/under-fetching, multiple round-trips for related data |
| gRPC | Internal service-to-service, low-latency microservices, streaming | Binary Protobuf (fast, compact), strong contracts, bidirectional streaming, HTTP/2 | Not browser-native (needs gRPC-Web), harder to debug, not cacheable |
| GraphQL | Aggregating many sources, clients with wildly different data needs | Client picks exact fields, one round-trip, strong typing | Caching is hard, N+1 risk, server complexity, easy to DoS yourself |
My take: Default to REST for any public-facing or browser/mobile-consumed API - the ecosystem, caching, and OpenAPI tooling are unmatched, and it’s what every client already knows. Reach for gRPC for chatty internal microservice-to-microservice calls where latency and payload size matter and both ends are yours. Consider GraphQL only when you have many heterogeneous clients fetching deeply related data and the over-fetching of REST is a measured, real problem - not a hypothetical one. For most teams, “REST for the edge, gRPC between services” is the sweet spot, and you rarely need GraphQL at all.
Building a Complete RESTful API
Let’s put it all together with a real-world example - a product management API. The full, runnable source (with EF Core wiring) lives in the course repository; the snippets below show the shape.
Project Structure
Models and DTOs
The entity carries the full state; request DTOs accept input (with validation attributes) and a response DTO controls exactly what goes back to the client - never bind requests straight to your entity.
public record Product{ public int Id { get; init; } public required string Name { get; set; } public required string Description { get; set; } public decimal Price { get; set; } public int Stock { get; set; } public string? Category { get; set; } public DateTime CreatedAt { get; init; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; }}
public record CreateProductRequest( [property: Required, StringLength(200)] string Name, [property: Required] string Description, [property: Range(0.01, 1000000)] decimal Price, [property: Range(0, int.MaxValue)] int Stock, string? Category);
// UpdateProductRequest mirrors CreateProductRequest (full replacement).// PatchProductRequest makes every field nullable (partial update).
public record ProductResponse( int Id, string Name, string Description, decimal Price, int Stock, string? Category, DateTime CreatedAt, DateTime? UpdatedAt);The IProductService exposes the obvious CRUD methods (GetPagedAsync, GetByIdAsync, CreateAsync, UpdateAsync, PatchAsync, DeleteAsync); grab the full implementation from the repo. What matters for REST is how the endpoints wire it up.
Endpoint Configuration
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Servicesbuilder.Services.AddSingleton<IProductService, ProductService>();builder.Services.AddOpenApi();builder.Services.AddProblemDetails();
var app = builder.Build();
// Middlewareapp.UseExceptionHandler();app.MapOpenApi();app.MapScalarApiReference();
// API Routesvar api = app.MapGroup("/api/v1");
api.MapGet("/products", async ( int page = 1, int pageSize = 20, IProductService service) =>{ pageSize = Math.Clamp(pageSize, 1, 100); var (products, totalCount) = await service.GetPagedAsync(page, pageSize); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return Results.Ok(new { data = products.Select(p => new ProductResponse( p.Id, p.Name, p.Description, p.Price, p.Stock, p.Category, p.CreatedAt, p.UpdatedAt)), pagination = new { page, pageSize, totalPages, totalCount, hasNext = page < totalPages, hasPrevious = page > 1 } });}).WithName("GetProducts").WithSummary("Get all products with pagination").WithTags("Products");
api.MapGet("/products/{id:int}", async (int id, IProductService service) =>{ var product = await service.GetByIdAsync(id); return product is null ? Results.NotFound(new { error = $"Product with ID {id} not found" }) : Results.Ok(new ProductResponse( product.Id, product.Name, product.Description, product.Price, product.Stock, product.Category, product.CreatedAt, product.UpdatedAt));}).WithName("GetProductById").WithSummary("Get a product by ID").Produces<ProductResponse>(StatusCodes.Status200OK).Produces(StatusCodes.Status404NotFound).WithTags("Products");
api.MapPost("/products", async (CreateProductRequest request, IProductService service) =>{ var product = await service.CreateAsync(request); var response = new ProductResponse( product.Id, product.Name, product.Description, product.Price, product.Stock, product.Category, product.CreatedAt, product.UpdatedAt);
return Results.Created($"/api/v1/products/{product.Id}", response);}).WithName("CreateProduct").WithSummary("Create a new product").Produces<ProductResponse>(StatusCodes.Status201Created).Produces(StatusCodes.Status400BadRequest).WithTags("Products");
api.MapPut("/products/{id:int}", async (int id, UpdateProductRequest request, IProductService service) =>{ var product = await service.UpdateAsync(id, request); return product is null ? Results.NotFound(new { error = $"Product with ID {id} not found" }) : Results.NoContent();}).WithName("UpdateProduct").WithSummary("Update an existing product").Produces(StatusCodes.Status204NoContent).Produces(StatusCodes.Status404NotFound).WithTags("Products");
api.MapPatch("/products/{id:int}", async (int id, PatchProductRequest request, IProductService service) =>{ var product = await service.PatchAsync(id, request); return product is null ? Results.NotFound(new { error = $"Product with ID {id} not found" }) : Results.NoContent();}).WithName("PatchProduct").WithSummary("Partially update a product").Produces(StatusCodes.Status204NoContent).Produces(StatusCodes.Status404NotFound).WithTags("Products");
api.MapDelete("/products/{id:int}", async (int id, IProductService service) =>{ var deleted = await service.DeleteAsync(id); return deleted ? Results.NoContent() : Results.NotFound(new { error = $"Product with ID {id} not found" });}).WithName("DeleteProduct").WithSummary("Delete a product").Produces(StatusCodes.Status204NoContent).Produces(StatusCodes.Status404NotFound).WithTags("Products");
app.Run();Testing the API
Run the application and navigate to /scalar/v1 to see the interactive documentation. Here are some example requests:
# Get all products (paginated)curl http://localhost:5000/api/v1/products?page=1&pageSize=10
# Get a specific productcurl http://localhost:5000/api/v1/products/1
# Create a new productcurl -X POST http://localhost:5000/api/v1/products \ -H "Content-Type: application/json" \ -d '{"name":"Headphones","description":"Noise-cancelling headphones","price":199.99,"stock":75,"category":"Electronics"}'
# Update a product (full replacement)curl -X PUT http://localhost:5000/api/v1/products/1 \ -H "Content-Type: application/json" \ -d '{"name":"Gaming Laptop","description":"High-end gaming laptop","price":1499.99,"stock":30,"category":"Electronics"}'
# Partial updatecurl -X PATCH http://localhost:5000/api/v1/products/1 \ -H "Content-Type: application/json" \ -d '{"price":1299.99}'
# Delete a productcurl -X DELETE http://localhost:5000/api/v1/products/1Quick Reference Cheat Sheet
| Principle | Rule |
|---|---|
| URIs | Use nouns, plural form, no verbs |
| GET | Retrieve resources (safe, idempotent) |
| POST | Create resources (returns 201 + Location) |
| PUT | Replace resources (idempotent) |
| PATCH | Partial update (not necessarily idempotent) |
| DELETE | Remove resources (idempotent) |
| QUERY | Safe, idempotent read with a request body (RFC 10008) |
| 404 | Resource not found |
| 400 | Bad request / validation error |
| 401 | Authentication required |
| 403 | Permission denied |
| 409 | Conflict (duplicate, version mismatch) |
| 412 | Precondition failed (stale If-Match on write) |
| Pagination | Always paginate collections |
| Versioning | Use URI path (/api/v1/) |
| Caching | Use ETags and Cache-Control |
| Idempotency | Use an Idempotency-Key header for unsafe retries (POST) |
| Concurrency | ETag + If-Match → 412 to prevent lost updates |
| Errors | RFC 9457 Problem Details (application/problem+json) |
Frequently Asked Questions
What are the most important REST API best practices in .NET 10?
Model resources as plural nouns and let HTTP methods carry the verb, return accurate status codes (never 200 OK for errors), standardize errors on RFC 9457 Problem Details, version your API from day one, paginate every collection, cache reads with ETags and output caching, and secure everything with HTTPS, JWT, and rate limiting. Build it on .NET 10 Minimal APIs and document it with the built-in OpenAPI generator plus Scalar.
What is the difference between PUT and PATCH?
PUT replaces the entire resource - the client sends the complete representation, and any field left out is reset to its default. PATCH applies a partial update - only the fields supplied are changed and everything else is left untouched. PUT is idempotent; PATCH is idempotent only if your implementation makes it so. Use PUT for full replacement and PATCH when updating a few fields.
Which API versioning strategy should I use in ASP.NET Core?
URI path versioning (/api/v1/users) is the recommended default. It is the most explicit, the easiest to test and debug, and the most cache-friendly, because the version is visible in the URL. Query string and header versioning keep URLs cleaner but are less discoverable and harder to cache. Use the Asp.Versioning library to implement it cleanly.
Is HATEOAS necessary for a REST API in 2026?
No, in practice full HATEOAS is rarely implemented and is not required to ship a successful API. It is the most controversial REST constraint. Understanding it helps you design more discoverable APIs, but pragmatism beats purity - most production REST APIs skip hypermedia links and are perfectly maintainable. Add it only if clients genuinely benefit from runtime discoverability.
What is the difference between RFC 7807 and RFC 9457?
RFC 9457 (Problem Details for HTTP APIs), published in July 2023, is the current standard and obsoletes the older RFC 7807. They share the same application/problem+json media type and core fields (type, title, status, detail, instance); RFC 9457 clarifies guidance and registers the format properly. In 2026 you should reference RFC 9457. ASP.NET Core's AddProblemDetails() produces compliant responses.
How do I make a POST request idempotent in .NET?
Have the client send a unique Idempotency-Key header (a GUID) per logical operation. On the server, store the key with the result of the first request in a distributed cache. If a request arrives with a key you have already seen, return the stored result instead of repeating the work. This prevents duplicate charges or records when a client retries after a timeout.
What is the HTTP QUERY method and when should I use it?
QUERY is a new HTTP method standardized in RFC 10008 (June 2026). It is safe, idempotent, and cacheable like GET, but it carries a request body like POST. It exists for read-only operations whose query is too large or too structured to fit in a URL - complex searches, nested filters, GraphQL-style field selection, or reporting queries. Before QUERY, developers used POST for these, which broke HTTP caching and safe retries because POST implies a state change. Use QUERY when a read genuinely needs a body; keep plain GET for simple resource lookups and small filtered lists. In .NET 10, expose one with app.MapMethods(pattern, ["QUERY"], handler) since there is no MapQuery helper yet, and be aware that OpenAPI, Scalar, and Postman do not model the verb yet.
What is the difference between 401 and 403?
401 Unauthorized means the request is not authenticated - the credentials are missing or invalid, so the server does not know who you are. 403 Forbidden means the request is authenticated but not authorized - the server knows who you are, but you do not have permission to perform this action. Return 401 when authentication fails and 403 when an authenticated user lacks the required permission.
REST vs gRPC vs GraphQL - which should I choose?
Default to REST for public-facing, browser, and mobile APIs because of its universal tooling, caching, and OpenAPI support. Use gRPC for low-latency internal service-to-service communication where payload size and speed matter and both ends are yours. Consider GraphQL only when many heterogeneous clients need deeply related data and REST over-fetching is a measured, real problem. A common sweet spot is REST at the edge and gRPC between internal services.
Official References
For deeper, authoritative reading, these are the primary sources behind this guide:
- Web API design best practices - Microsoft Learn
- Generate OpenAPI documents in ASP.NET Core - Microsoft Learn
- Handle errors with Problem Details (RFC 9457) in ASP.NET Core - Microsoft Learn
- Rate limiting middleware in ASP.NET Core - Microsoft Learn
- Output caching middleware in ASP.NET Core - Microsoft Learn
- RFC 9457: Problem Details for HTTP APIs - IETF
- RFC 10008: The HTTP QUERY Method - IETF
Wrap-Up
Building truly RESTful APIs isn’t about blindly following rules - it’s about understanding why those rules exist and applying them to make your APIs predictable, maintainable, and delightful to use.
Here’s what separates good APIs from great ones:
- Consistent resource naming – Clients can guess your endpoints
- Proper HTTP methods – GET for reads, POST for creates, PUT/PATCH for updates, DELETE for removals
- Meaningful status codes – Don’t return 200 for everything
- Structured error responses – Problem Details format (RFC 9457)
- Versioning strategy – Plan for evolution from day one
- Pagination by default – Never return unbounded lists
- Documentation – OpenAPI + Scalar for interactive docs
20+ .NET 10 Best Practices & Tips from a Senior Developer
These are the API-layer best practices. For the broader .NET best practices every senior developer follows - DI lifetimes, async correctness, EF Core, caching, and security - start here.
This article is Chapter 1 of our FREE .NET Web API Zero to Hero course. In the next article, we’ll dive deep into Middlewares and the Request Pipeline in ASP.NET Core - understanding how requests flow through your application and how to intercept them.
Middlewares in ASP.NET Core
Chapter 2: how the request pipeline works and how to write custom middleware the right way.
Grab the complete source code from github.com/codewithmukesh/dotnet-webapi-zero-to-hero-course and start building better APIs today.
Have questions or want to share how you’ve applied these principles? Drop a comment below - I’d love to hear from you.
What's your take?
Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.