Skip to main content
Article complete

Get one like this every Tuesday at 7 PM IST.

codewithmukesh
Back to blog
dotnet webapi-course 30 min read Lesson 1/146 Updated

RESTful API Best Practices for .NET Developers (.NET 10) – The Complete 2026 Guide to Production-Ready APIs

Build production-grade REST APIs in .NET 10. Resource naming, HTTP methods, status codes, versioning, idempotency, RFC 9457 Problem Details, caching, and concurrency - done right.

Build production-grade REST APIs in .NET 10. Resource naming, HTTP methods, status codes, versioning, idempotency, RFC 9457 Problem Details, caching, and concurrency - done right.

dotnet webapi-course

rest-api webapi api-design http-methods http-query-method rfc-10008 status-codes api-versioning problem-details rfc-9457 idempotency optimistic-concurrency etag pagination rate-limiting hateoas openapi scalar minimal-apis dotnet-10 dotnet-webapi-zero-to-hero-course

Mukesh Murugan
Mukesh Murugan
Software Engineer
Chapter 01 of 146
View course

.NET Web API Zero to Hero Course

From dotnet new to docker push - REST, EF Core 10, auth, caching, Clean Architecture, observability. 146 hands-on lessons, source on GitHub.

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, returns 201 + Location), PUT (full replace, idempotent), PATCH (partial), DELETE (idempotent). Return accurate status codes (never 200 OK for errors), and standardize errors on RFC 9457 Problem Details via AddProblemDetails(). 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-Match412). 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:

AdvantageWhy It Matters
SimpleUses HTTP verbs you already know (GET, POST, PUT, DELETE)
ScalableStateless design enables horizontal scaling
FlexibleSupports multiple formats (JSON, XML, etc.)
CacheableBuilt-in HTTP caching reduces server load
DecoupledClient 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 session
app.MapPost("/api/orders", (HttpContext context) =>
{
var userId = context.Session.GetInt32("UserId"); // Session state!
// Create order for this user
});
// GOOD: All context in the request
app.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 minutes

The response includes headers like:

Cache-Control: public, max-age=600
ETag: "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 42

4.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.1
Host: api.example.com
Content-Type: application/json ← How to parse the body
Accept: application/json ← What format I want back
Authorization: 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 API

6. 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/getUsersGET /api/users
POST /api/createUserPOST /api/users
PUT /api/updateUser/42PUT /api/users/42
DELETE /api/deleteUser/42DELETE /api/users/42
GET /api/getUserOrders/42GET /api/users/42/orders

The HTTP method already tells us the action:

// The method IS the verb
app.MapGet("/api/users", GetUsers); // GET = retrieve
app.MapPost("/api/users", CreateUser); // POST = create
app.MapPut("/api/users/{id}", UpdateUser); // PUT = replace
app.MapDelete("/api/users/{id}", DeleteUser);// DELETE = remove

Use 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 users
GET /api/users/42 → Returns single user
POST /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 users
GET /api/users/42/orders → All orders for user 42
GET /api/users/42/orders/7 → Order 7 for user 42
POST /api/users/42/orders → Create order for user 42
# Items belong to orders
GET /api/orders/7/items → All items in order 7
POST /api/orders/7/items → Add item to order 7

But don’t go too deep. Three levels is usually the maximum before URLs become unwieldy:

# Too deep - hard to read and use
GET /api/users/42/orders/7/items/3/options/1
# Better - flatten when entities can stand alone
GET /api/order-items/3/options/1

Rule 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/orders

Path Parameters vs. Query Parameters

Path parameters identify a specific resource:

GET /api/users/42 → User with ID 42
GET /api/products/abc-123 → Product with slug abc-123

Query parameters filter, sort, paginate, or modify the response:

GET /api/users?role=admin&sort=name → Filter and sort
GET /api/products?category=electronics → Filter by category
GET /api/orders?page=2&pageSize=20 → Pagination
Use Path Parameters WhenUse Query Parameters When
Identifying a resourceFiltering a collection
Required for the requestOptional refinements
Part of the resource hierarchySorting, pagination, search

Never do this:

# BAD: Path for filtering
GET /api/users/role/admin/sort/name/page/2
# GOOD: Query for filtering
GET /api/users?role=admin&sort=name&page=2

HTTP 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, not 200 OK
  • Include Location header 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 Content on success (or 200 OK with 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

AspectPUTPATCH
PayloadComplete resourceOnly changed fields
Missing fieldsReset to null/defaultLeft unchanged
IdempotentYesDepends on implementation
Use whenReplacing the whole resourceUpdating a few fields

Example:

// Current user state
{
"id": 42,
"name": "John Doe",
"email": "john@example.com",
"phone": "555-1234"
}
// PUT request - replaces everything
PUT /api/users/42
{
"name": "John Smith",
"email": "john.smith@example.com"
}
// Result: phone is now NULL (not provided)
// PATCH request - updates only provided fields
PATCH /api/users/42
{
"name": "John Smith"
}
// Result: phone and email unchanged

Less 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

PropertyGETPOSTQUERY
Safe (no state change)YesNoYes
IdempotentYesNoYes
CacheableYesNot in practiceYes
Carries a request bodyNoYesYes

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.1
Host: api.example.com
Content-Type: application/json
Accept: 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 OK
Content-Type: application/json
Content-Location: /api/v1/products?q=a1b2c3
{ "data": [ ... ], "pagination": { ... } }

Two response headers from the spec are worth knowing:

  • Content-Location points to a URL where the same results can be fetched with a plain GET - handy for sharing or bookmarking a query result.
  • Accept-Query is a response header a server can send (for example, on OPTIONS) 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/42 and 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/payments
Idempotency-Key: 3f1d8c9a-2b6e-4f0a-9c1d-7a5b2e8f1c44
Content-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.

Read next

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)

CodeNameWhen to Use
200 OKSuccessGET that returns data, PUT/PATCH that returns updated resource
201 CreatedResource createdPOST that creates a new resource
204 No ContentSuccess, no bodyDELETE, PUT/PATCH when not returning the resource
// 200 OK - returning data
return Results.Ok(users);
// 201 Created - new resource with location
return Results.Created($"/api/users/{user.Id}", user);
// 204 No Content - success but nothing to return
return Results.NoContent();

Client Error Codes (4xx)

CodeNameWhen to Use
400 Bad RequestInvalid inputMalformed JSON, validation errors
401 UnauthorizedNot authenticatedMissing or invalid credentials
403 ForbiddenNot authorizedValid credentials but no permission
404 Not FoundResource doesn’t existID not found, bad route
409 ConflictState conflictDuplicate email, version mismatch
422 Unprocessable EntitySemantic errorBusiness rule violation
429 Too Many RequestsRate limitedClient exceeded rate limit
// 400 Bad Request - validation failed
return Results.BadRequest(new { errors = validationErrors });
// 401 Unauthorized - no/invalid credentials
return Results.Unauthorized();
// 403 Forbidden - authenticated but not allowed
return Results.Forbid();
// 404 Not Found - resource doesn't exist
return Results.NotFound();
// 409 Conflict - can't complete due to state
return Results.Conflict(new { message = "Email already registered" });

Server Error Codes (5xx)

CodeNameWhen to Use
500 Internal Server ErrorServer bugUnhandled exception
502 Bad GatewayUpstream errorProxy/gateway received bad response
503 Service UnavailableTemporarily downMaintenance, overload
504 Gateway TimeoutUpstream timeoutProxy/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 body
return Results.Ok(new { success = false, error = "User not found" });
// GOOD: Appropriate error status
return Results.NotFound(new { error = "User not found" });

Using 500 for client errors:

// BAD: Server error for bad input
throw new Exception("Invalid email format"); // Results in 500
// GOOD: Client error for bad input
return 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 issue

Error 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 Details
app.UseStatusCodePages(); // Converts bare 4xx/5xx to Problem Details

For 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.cs
builder.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.

Read next

Problem Details in ASP.NET Core (RFC 9457)

The complete guide to standardized API errors - IProblemDetailsService, custom extensions, and validation mapping.

Read next

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.

Version in the URL path. Most explicit and cache-friendly.

GET /api/v1/users
GET /api/v2/users
var 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.0
GET /api/users?api-version=2.0

Header Versioning

Version in a custom header.

GET /api/users
X-API-Version: 2.0

Which to Choose?

StrategyProsCons
URI PathExplicit, cacheable, easy to testDuplicates routes, “ugly” URLs
Query StringSimple, optionalLess discoverable, harder to cache
HeaderClean URLsHidden, 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:

  1. Add Sunset header with deprecation date
  2. Communicate to clients via email/docs
  3. Keep deprecated version running for a transition period
  4. Monitor usage and remove when safe
app.MapGet("/api/v1/users", GetUsersV1)
.WithMetadata(new ObsoleteAttribute("Use /api/v2/users instead. Sunset: 2026-06-01"));

Free resource Companion download

.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=20

Response 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=20
public 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=true
public 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=asc
GET /api/users?sort=-createdAt # Prefix with - for descending
app.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=20

This 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 hour
Cache-Control: private, max-age=300 # User-specific, cache 5 min
Cache-Control: no-cache # Validate before using cache
Cache-Control: no-store # Never cache (sensitive data)
ETag: "abc123" # Version identifier
Last-Modified: Wed, 01 Jan 2026 10:00:00 GMT

ETag 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);
});
Read next

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:

  1. Client GETs the resource and receives an ETag (a version stamp).
  2. Client sends that value back on PUT/PATCH in an If-Match header.
  3. If the server’s current version still matches, the write proceeds. If it changed in the meantime, the server returns 412 Precondition Failed and 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.

Read next

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:

Read next

JWT Authentication in ASP.NET Core

Issue and validate JWTs end to end on .NET 10, with security best practices baked in.

Read next

Refresh Tokens in ASP.NET Core

Add refresh token rotation and reuse detection so short-lived access tokens stay practical.

Read next

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");
Read next

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.Extensions

Prevent 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.json

Scalar for Interactive Documentation

using Scalar.AspNetCore;
app.MapScalarApiReference(); // Available at /scalar/v1

Enhancing 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");
Read next

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:

StyleBest forStrengthsTrade-offs
RESTPublic APIs, CRUD-heavy services, anything consumed by browsers/mobileUniversal tooling, cacheable, simple, self-documenting with OpenAPIOver/under-fetching, multiple round-trips for related data
gRPCInternal service-to-service, low-latency microservices, streamingBinary Protobuf (fast, compact), strong contracts, bidirectional streaming, HTTP/2Not browser-native (needs gRPC-Web), harder to debug, not cacheable
GraphQLAggregating many sources, clients with wildly different data needsClient picks exact fields, one round-trip, strong typingCaching 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

RestfulApiBestPractices.Api/ 4 dirs · 8 files
RestfulApiBestPractices.Api/
Program.cs
Models/
Product.cs
DTOs/
CreateProductRequest.cs
UpdateProductRequest.cs
PatchProductRequest.cs
ProductResponse.cs
Services/
IProductService.cs
ProductService.cs

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);
// Services
builder.Services.AddSingleton<IProductService, ProductService>();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Middleware
app.UseExceptionHandler();
app.MapOpenApi();
app.MapScalarApiReference();
// API Routes
var 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:

Terminal window
# Get all products (paginated)
curl http://localhost:5000/api/v1/products?page=1&pageSize=10
# Get a specific product
curl http://localhost:5000/api/v1/products/1
# Create a new product
curl -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 update
curl -X PATCH http://localhost:5000/api/v1/products/1 \
-H "Content-Type: application/json" \
-d '{"price":1299.99}'
# Delete a product
curl -X DELETE http://localhost:5000/api/v1/products/1

Quick Reference Cheat Sheet

PrincipleRule
URIsUse nouns, plural form, no verbs
GETRetrieve resources (safe, idempotent)
POSTCreate resources (returns 201 + Location)
PUTReplace resources (idempotent)
PATCHPartial update (not necessarily idempotent)
DELETERemove resources (idempotent)
QUERYSafe, idempotent read with a request body (RFC 10008)
404Resource not found
400Bad request / validation error
401Authentication required
403Permission denied
409Conflict (duplicate, version mismatch)
412Precondition failed (stale If-Match on write)
PaginationAlways paginate collections
VersioningUse URI path (/api/v1/)
CachingUse ETags and Cache-Control
IdempotencyUse an Idempotency-Key header for unsafe retries (POST)
ConcurrencyETag + If-Match → 412 to prevent lost updates
ErrorsRFC 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:


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:

  1. Consistent resource naming – Clients can guess your endpoints
  2. Proper HTTP methods – GET for reads, POST for creates, PUT/PATCH for updates, DELETE for removals
  3. Meaningful status codes – Don’t return 200 for everything
  4. Structured error responses – Problem Details format (RFC 9457)
  5. Versioning strategy – Plan for evolution from day one
  6. Pagination by default – Never return unbounded lists
  7. Documentation – OpenAPI + Scalar for interactive docs
Read next

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.

Read next

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.

Source code Open on GitHub

Grab the source code.

Get the full implementation. Drop your email for instant access, or skip straight to GitHub.

Skip - go straight to GitHub
View all articles

What's your take?

Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.

View on GitHub

Weekly .NET tips · free

Newsletter

stay ahead in .NET

One email every Tuesday at 7 PM IST. One topic, deep. The week's articles. No filler.

Tutorials Architecture DevOps AI
Join 9,735 developers · Delivered every Tuesday
Privacy notice 30s read

Cookies, but only the useful ones.

I use cookies to understand which articles get read and which CTAs actually work. No third-party advertising trackers, ever. Read the privacy policy →