HTTP Methods, Status Codes, and Semantics Done Right

HTTP Methods, Status Codes, and Semantics Done Right
📘 API Design and Integration: The Practical Handbook — Chapter 3 of 12
Part I: Foundations · ~13 min read
Goal: Use HTTP methods and status codes for what they mean, so clients, proxies, and caches behave correctly.
Table of Contents · [ Read as eBook ]
In Chapter 2 we gave ShipCart a clean set of resources and URLs. A URL only tells a client what it’s talking about, though. The method says what the client wants to do, and the status code says what actually happened. Get those two right and a lot of behaviour comes for free: HTTP client libraries know when a retry is safe, caches know what they may store, and developers can read a log line and understand it without opening your code.
Get them wrong and you get the classic symptoms: 200 OK with {"error": "not found"} in the body, a GET that quietly deletes something, or a retry that charges a customer twice. This chapter goes through methods, status codes, and conditional requests using the definitions in RFC 9110 (HTTP Semantics), and ends with full CRUD for ShipCart products, protected by ETag-based optimistic concurrency.
Methods: Safe, Idempotent, and Everything Else
RFC 9110 gives each method two properties that matter far more than their names.
- Safe means the client is not asking the server to change anything (section 9.2.1). The server may still write a log line or bump a counter, but the client hasn’t requested a change and can’t be blamed for one. Crawlers, link prefetchers, and caches rely on this.
- Idempotent means sending the same request several times has the same intended effect on the server as sending it once (section 9.2.2). That’s what makes a request safe to retry automatically after a timeout.
| Method | Safe | Idempotent | Typical use in ShipCart |
|---|---|---|---|
GET |
Yes | Yes | Read a product or list orders |
HEAD |
Yes | Yes | Like GET, headers only |
OPTIONS |
Yes | Yes | Discover allowed methods, CORS preflight |
POST |
No | No | Create an order, run an action such as /orders/{orderId}/cancel |
PUT |
No | Yes | Replace a product with a complete new representation |
PATCH |
No | Not by definition | Change some fields of a product |
DELETE |
No | Yes | Remove a product |
A few points that trip people up:
- Idempotent is about the effect, not the response. The first
DELETE /products/4returns204 No Content; a repeat returns404 Not Found. The responses differ, but the server ends in the same state, soDELETEis still idempotent. GETmust never change state. IfGET /orders/{orderId}/cancelcancels an order, a browser prefetch, a link preview bot, or a monitoring tool will eventually cancel one for you. Actions usePOST, as in Chapter 2.POSTisn’t idempotent by default. If a client times out while placing an order and simply retries, it may create two orders. The usual fix is an idempotency key, which Chapter 10 covers properly.PATCHcan be idempotent, but HTTP doesn’t promise it. “Set the price to 17.99” is idempotent; “add 1 to stock” is not. RFC 5789 definesPATCHand leaves that to the patch format and your design.
PUT vs PATCH
PUT replaces the target resource’s state with the representation in the request body (RFC 9110, section 9.3.4). If the client leaves out inStock, it hasn’t said “leave stock alone”; it has sent an incomplete product, and the API should reject it rather than guess. PUT can also create a resource when the client chooses the URL, but in ShipCart the server assigns product IDs, so we create with POST /products and use PUT only for replacement.
PATCH applies a set of changes. The request body is a patch document, and its media type tells the server how to read it. Two standard formats cover nearly every JSON API.
JSON Merge Patch (RFC 7396), media type application/merge-patch+json, looks like the resource itself. Fields you include are set, fields you leave out are untouched, and null means “remove this field”:
PATCH /products/1 HTTP/1.1
Content-Type: application/merge-patch+json
If-Match: "1"
{ "price": 17.99 }
It’s easy to read and easy to build in any language. Its limits are that you can’t set a field to null (because null means remove), and you can’t change part of an array; you send the whole array instead.
JSON Patch (RFC 6902), media type application/json-patch+json, is a list of operations (add, remove, replace, move, copy, and test) applied in order:
PATCH /products/1 HTTP/1.1
Content-Type: application/json-patch+json
[
{ "op": "test", "path": "/price", "value": 19.99 },
{ "op": "replace", "path": "/price", "value": 17.99 }
]
JSON Patch can edit single array elements, and its test operation lets a patch fail if the document isn’t what the client expected. The cost is that it’s harder to read, and it ties clients to your JSON structure through paths like /items/0/quantity.
For ShipCart’s catalogue, where clients change a handful of top-level fields, Merge Patch is the better fit. Whichever you pick, document it and check the Content-Type, so a client that sends something else gets 415 Unsupported Media Type instead of a half-understood update.
Status Codes: The Families and the Codes You Actually Need
Status codes are grouped by their first digit (RFC 9110, section 15):
- 1xx Informational: interim responses. You’ll rarely set these yourself.
- 2xx Successful: the request was received, understood, and accepted.
- 3xx Redirection: the client needs to do something else, such as follow a link or use its cached copy.
- 4xx Client error: the request was wrong in some way. Retrying it unchanged won’t help.
- 5xx Server error: the server failed on a request that may have been valid.
That split between 4xx and 5xx is the most useful thing a status code tells a client: “fix your request” versus “it’s us, maybe try later”. Clients that don’t recognise a specific code must treat it like the x00 code of its family, so a client that has never heard of 422 still knows it made a client error.
Here is the short list ShipCart uses, which covers most APIs:
| Code | Meaning | When ShipCart returns it |
|---|---|---|
200 OK |
Success, with a body | GET /products/1, or a successful PUT/PATCH that returns the updated product |
201 Created |
A new resource was created | POST /products, with a Location header pointing at it |
202 Accepted |
Accepted for processing, not finished | Starting a long-running catalogue import |
204 No Content |
Success, deliberately no body | DELETE /products/4 |
304 Not Modified |
Your cached copy is still current | Conditional GET with a matching If-None-Match |
400 Bad Request |
The request can’t be parsed or is malformed | Broken JSON |
401 Unauthorized |
Missing or invalid credentials | No API key or an expired token (Chapter 8) |
403 Forbidden |
Authenticated, but not allowed | A merchant editing another merchant’s product |
404 Not Found |
No such resource (or you may not know it exists) | GET /products/999 |
409 Conflict |
Conflicts with the resource’s current state | Cancelling an order that’s already dispatched |
412 Precondition Failed |
A condition in the request headers is false | If-Match with a stale ETag |
415 Unsupported Media Type |
Body format not supported | PATCH with text/plain |
422 Unprocessable Content |
Well-formed, but semantically invalid | A negative price |
428 Precondition Required |
The server requires a conditional request | PATCH without If-Match |
429 Too Many Requests |
Rate limit exceeded | Chapter 9 |
500 Internal Server Error |
Unexpected server failure | An unhandled exception |
503 Service Unavailable |
Temporarily unable to handle requests | Maintenance or overload, ideally with Retry-After |
The pairs that cause the most debate:
400vs422. Use400when the server can’t make sense of the request at all: invalid JSON or a missing body. Use422when it parsed fine but breaks a rule, such as a price of-1. RFC 9110 renamed422from “Unprocessable Entity” to “Unprocessable Content”; ASP.NET Core 8 still uses the older phrase in its reason text and helper names (TypedResults.UnprocessableEntity), but it’s the same code. Some teams use400for everything, which is acceptable if it’s consistent; Chapter 4 makes the body of these errors the part clients can rely on.401vs403.401means “I don’t know who you are”, and RFC 9110 requires it to come with aWWW-Authenticateheader.403means “I know who you are, and the answer is no.”403vs404. If revealing that a resource exists is itself a leak (another merchant’s order, say), return404instead of403. RFC 9110 explicitly allows this.409vs412vs422.409is a clash with the resource’s state (“this order has shipped”).412is specifically about a precondition header the client sent.422is about the content of the request itself.- Never
200for errors. Monitoring, retries, caches, and client libraries all look at the status code first. An error hidden in a200body is invisible to every one of them.
Location, 201 Created, and 202 Accepted
When POST /products creates a product, return 201 Created, a Location header with the new product’s URL, and the product itself:
HTTP/1.1 201 Created
Location: /products/4
ETag: "1"
Content-Type: application/json; charset=utf-8
{"id":4,"sku":"SKU-1004","name":"Desk Lamp","price":34.00,"currency":"USD","inStock":true}
The Location header is the small, practical hypermedia mentioned in Chapter 1: the client doesn’t have to build the URL itself. Returning the body saves a follow-up GET and shows the client the server-assigned values, such as the ID and currency.
Some work can’t finish inside one request. Imagine a merchant uploading a catalogue file with thousands of products. Holding the connection open until every row is processed invites timeouts. Instead, accept the job and give the client somewhere to check on it:
POST /catalogue-imports HTTP/1.1
Content-Type: text/csv
HTTP/1.1 202 Accepted
Location: /catalogue-imports/5c1d9e2a-7b3f-4c8e-9d0a-1e2f3a4b5c6d
GET on that URL returns the import’s status (queued, running, completed, failed) and, once it’s done, a summary or a link to the results. Notice the import is itself a resource, the third option from Chapter 2. 202 means only “accepted”; it promises nothing about the outcome, so the status resource is what tells the client how it went. Chapter 11 shows how to notify clients with webhooks instead of making them poll.
Conditional Requests, Caching, and Optimistic Concurrency
An ETag (entity tag) is an opaque label for one version of a resource’s representation (RFC 9110, section 8.8.3). The server sends it in a response header, ETag: "2", and the client can send it back in a conditional request. It can be a version number, a hash of the content, or a database row version; clients must treat it as opaque. A tag that starts with W/ is weak, meaning “semantically equivalent” rather than byte-for-byte identical.
ETags solve two different problems.
Saving Bandwidth: If-None-Match and 304
The storefront fetches GET /products/1 and keeps the response and its ETag. Next time, it asks “only send it if it changed”:
GET /products/1 HTTP/1.1
If-None-Match: "1"
If the product’s current ETag is still "1", the server answers 304 Not Modified with no body, and the client uses its cached copy. Comparison for If-None-Match is weak, so W/"1" would match too.
Controlling Caches: Cache-Control
Cache-Control tells browsers, CDNs, and proxies what they may do with a response. Caching is defined in RFC 9111. The directives you’ll use most:
max-age=60: the response is fresh for 60 seconds, so it can be reused without asking the server.no-cache: it can be stored, but must be revalidated (withIf-None-Match) before each reuse. Despite the name, it does not mean “don’t cache”.no-store: don’t store it anywhere. Use it for carts, payment details, and anything personal.private: only the end user’s own cache may store it, not a shared CDN.publicallows shared caches.
How long a response may be cached is a product decision, not a technical one. ShipCart lets the public product list be reused for 60 seconds, and marks single products no-cache so clients revalidate cheaply with their ETag. A merchant who changes a price accepts that a listing may show the old price for up to a minute.
Preventing Lost Updates: If-Match and 412
Here’s the problem that matters most for writes. Two merchant admins open product 1 at the same time, both seeing a price of 19.99 and ETag "1". Alice sets it to 17.99 and saves. A moment later Bob, still looking at the old page, sets it to 18.49 and saves. Without any checks, Bob’s update silently overwrites Alice’s, and nobody knows. That’s a lost update.
Optimistic concurrency fixes it without locks. Each write says which version it was based on:
PATCH /products/1 HTTP/1.1
Content-Type: application/merge-patch+json
If-Match: "1"
{ "price": 18.49 }
The server applies the change only if the current ETag still matches. Alice’s request matches, succeeds, and the ETag becomes "2". Bob’s still says "1", so the server returns 412 Precondition Failed. Bob’s client can fetch the latest version, show him the new price, and let him decide. If-Match uses strong comparison (RFC 9110, section 13.1.1), so weak ETags never satisfy it.
What if a client doesn’t send If-Match at all? Then the check can’t run. RFC 6585 defines 428 Precondition Required for servers that insist on conditional writes. ShipCart requires If-Match on PUT, PATCH, and DELETE for products, so a forgetful client gets a clear 428 instead of an unprotected write.
One subtlety: checking the ETag and then saving must happen as one atomic step. If two requests both check "1" before either saves, both pass. In a database you get this by making the update conditional (UPDATE ... WHERE id = @id AND version = @version) or by using a row-version column; EF Core supports this through concurrency tokens. In the exercise below, an in-memory compare-and-swap plays that role.
Typed Results in ASP.NET Core
Minimal API handlers can return a union of typed results, such as Results<Ok<Product>, NotFound>. Chapter 2 used them already; here they earn their keep. The signature lists every status a handler can produce, the compiler stops you returning anything else, and the OpenAPI document (Chapter 6) can describe each response without extra attributes.
group.MapGet("/{productId:int}", Results<Ok<Product>, NotFound> (int productId, ProductStore store) =>
store.TryGet(productId, out var stored)
? TypedResults.Ok(stored.Product)
: TypedResults.NotFound());
Use TypedResults rather than Results inside these handlers. TypedResults.Ok(product) returns the concrete Ok<Product> type that the union needs, while Results.Ok(product) returns the general IResult interface. For codes without a dedicated helper, such as 304, 412, and 428, TypedResults.StatusCode(...) returns StatusCodeHttpResult, which you add to the union.
Hands-On Exercise: Product CRUD With ETag Concurrency
The goal: full create, read, update, and delete for products, where two simultaneous price updates based on the same version produce one 200 and one 412 Precondition Failed.
This replaces the product endpoints from Chapter 2. To keep the listing focused, it contains only the products group; you can keep your orders and shipments groups alongside it. The sample was built and tested on .NET 8.
Step 1: The Endpoints and Their Status Codes
Write the contract down before the code:
| Request | Success | Other outcomes |
|---|---|---|
GET /products |
200 |
|
GET /products/{productId} |
200 with ETag |
304 (If-None-Match matches), 404 |
POST /products |
201 with Location and ETag |
400, 422 |
PUT /products/{productId} |
200 with new ETag |
400, 404, 412, 422, 428 |
PATCH /products/{productId} (Merge Patch) |
200 with new ETag |
400, 404, 412, 415, 422, 428 |
DELETE /products/{productId} |
204 |
404, 412, 428 |
Step 2: Implement It
Replace Program.cs with:
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductStore>();
builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true);
var app = builder.Build();
app.MapGroup("/products").WithTags("Products").MapProducts();
app.Run();
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProducts(this RouteGroupBuilder group)
{
// GET /products: safe, cacheable for a short time.
group.MapGet("/", (ProductStore store, HttpResponse response) =>
{
response.Headers.CacheControl = "public, max-age=60";
return TypedResults.Ok(store.All());
});
// GET /products/{id}: returns an ETag and honours If-None-Match.
group.MapGet("/{productId:int}", Results<Ok<Product>, NotFound, StatusCodeHttpResult> (
int productId, ProductStore store, HttpContext http) =>
{
if (!store.TryGet(productId, out var stored))
return TypedResults.NotFound();
var etag = stored.ETag;
http.Response.Headers.ETag = etag.ToString();
http.Response.Headers.CacheControl = "no-cache"; // store it, but revalidate before reuse
var ifNoneMatch = http.Request.GetTypedHeaders().IfNoneMatch;
if (ifNoneMatch.Any(t => t.Equals(EntityTagHeaderValue.Any) || t.Compare(etag, useStrongComparison: false)))
return TypedResults.StatusCode(StatusCodes.Status304NotModified);
return TypedResults.Ok(stored.Product);
});
// POST /products: the server assigns the ID, so POST (not PUT) creates.
group.MapPost("/", Results<Created<Product>, UnprocessableEntity<string>> (
NewProduct input, ProductStore store, HttpResponse response) =>
{
if (Validate(input.Name, input.Price) is { } error)
return TypedResults.UnprocessableEntity(error);
var stored = store.Add(input);
response.Headers.ETag = stored.ETag.ToString();
return TypedResults.Created($"/products/{stored.Product.Id}", stored.Product);
});
// PUT /products/{id}: full replacement, guarded by If-Match.
group.MapPut("/{productId:int}", Results<Ok<Product>, NotFound, UnprocessableEntity<string>, StatusCodeHttpResult> (
int productId, ReplaceProduct input, ProductStore store, HttpContext http) =>
{
if (Validate(input.Name, input.Price) is { } error)
return TypedResults.UnprocessableEntity(error);
return Update(productId, store, http, current =>
current with { Sku = input.Sku, Name = input.Name, Price = input.Price, InStock = input.InStock });
});
// PATCH /products/{id}: JSON Merge Patch (RFC 7396), guarded by If-Match.
group.MapPatch("/{productId:int}", Results<Ok<Product>, NotFound, UnprocessableEntity<string>, StatusCodeHttpResult> (
int productId, JsonElement patch, ProductStore store, HttpContext http) =>
{
if (patch.ValueKind != JsonValueKind.Object)
return TypedResults.UnprocessableEntity("A merge patch must be a JSON object.");
string? problem = null;
var result = Update(productId, store, http, current =>
{
var next = current;
foreach (var field in patch.EnumerateObject())
{
switch (field.Name, field.Value.ValueKind)
{
case ("name", JsonValueKind.String):
next = next with { Name = field.Value.GetString()! }; break;
case ("price", JsonValueKind.Number):
next = next with { Price = field.Value.GetDecimal() }; break;
case ("inStock", JsonValueKind.True or JsonValueKind.False):
next = next with { InStock = field.Value.GetBoolean() }; break;
case (_, JsonValueKind.Null):
problem = $"'{field.Name}' is required and can't be removed."; return null;
default:
problem = $"'{field.Name}' can't be changed with this value."; return null;
}
}
problem = Validate(next.Name, next.Price);
return problem is null ? next : null;
});
return problem is not null ? TypedResults.UnprocessableEntity(problem) : result;
}).Accepts<JsonElement>("application/merge-patch+json");
// DELETE /products/{id}: 204 on success, guarded by If-Match.
group.MapDelete("/{productId:int}", Results<NoContent, NotFound, StatusCodeHttpResult> (
int productId, ProductStore store, HttpContext http) =>
{
if (!store.TryGet(productId, out var stored))
return TypedResults.NotFound();
if (CheckIfMatch(http, stored.ETag) is { } failed)
return failed;
return store.TryRemove(stored) ? TypedResults.NoContent()
: TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed);
});
return group;
}
// Shared optimistic-concurrency flow for PUT and PATCH.
// The change function returns null to signal a validation problem.
private static Results<Ok<Product>, NotFound, UnprocessableEntity<string>, StatusCodeHttpResult> Update(
int productId, ProductStore store, HttpContext http, Func<Product, Product?> change)
{
if (!store.TryGet(productId, out var stored))
return TypedResults.NotFound();
if (CheckIfMatch(http, stored.ETag) is { } failed)
return failed;
var updated = change(stored.Product);
if (updated is null)
return TypedResults.UnprocessableEntity("Invalid change.");
// Compare-and-swap: only succeeds if nobody changed the product since we read it.
if (!store.TryReplace(stored, updated, out var saved))
return TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed);
http.Response.Headers.ETag = saved.ETag.ToString();
return TypedResults.Ok(saved.Product);
}
private static StatusCodeHttpResult? CheckIfMatch(HttpContext http, EntityTagHeaderValue current)
{
var ifMatch = http.Request.GetTypedHeaders().IfMatch;
if (ifMatch.Count == 0)
return TypedResults.StatusCode(StatusCodes.Status428PreconditionRequired);
// If-Match uses strong comparison (RFC 9110, section 13.1.1).
return ifMatch.Any(t => t.Equals(EntityTagHeaderValue.Any) || t.Compare(current, useStrongComparison: true))
? null
: TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed);
}
private static string? Validate(string name, decimal price) =>
string.IsNullOrWhiteSpace(name) ? "Name is required."
: price <= 0 ? "Price must be greater than zero."
: null;
}
public record Product(int Id, string Sku, string Name, decimal Price, string Currency, bool InStock);
public record NewProduct(string Sku, string Name, decimal Price, bool InStock);
public record ReplaceProduct(string Sku, string Name, decimal Price, bool InStock);
// A product plus the version that its ETag is built from.
public record StoredProduct(Product Product, int Version)
{
public EntityTagHeaderValue ETag => new($"\"{Version}\"");
}
public class ProductStore
{
private readonly ConcurrentDictionary<int, StoredProduct> _products = new()
{
[1] = new(new(1, "SKU-1001", "Canvas Tote Bag", 19.99m, "USD", true), 1),
[2] = new(new(2, "SKU-1002", "Stainless Water Bottle", 24.50m, "USD", true), 1),
[3] = new(new(3, "SKU-1003", "Wireless Mouse", 29.00m, "USD", false), 1)
};
private int _nextId = 3;
public IEnumerable<Product> All() => _products.Values.Select(s => s.Product).OrderBy(p => p.Id);
public bool TryGet(int id, out StoredProduct stored) => _products.TryGetValue(id, out stored!);
public StoredProduct Add(NewProduct input)
{
var id = Interlocked.Increment(ref _nextId);
var stored = new StoredProduct(new(id, input.Sku, input.Name, input.Price, "USD", input.InStock), 1);
_products[id] = stored;
return stored;
}
public bool TryReplace(StoredProduct expected, Product updated, out StoredProduct saved)
{
saved = new StoredProduct(updated, expected.Version + 1);
return _products.TryUpdate(expected.Product.Id, saved, expected);
}
public bool TryRemove(StoredProduct expected) =>
_products.TryRemove(new KeyValuePair<int, StoredProduct>(expected.Product.Id, expected));
}
A few things to notice:
- The ETag is built from a version number. Each successful write stores a new
StoredProductwithVersion + 1, so the ETag changes whenever the product does. Clients never see the version as a field; they only see the opaque ETag. TryReplaceis the atomic check.ConcurrentDictionary.TryUpdateswaps in the new value only if the stored value still equals the one we read. If another request got there first, it fails and we return412, even when both requests passed theIf-Matchcheck a moment earlier.- Merge Patch is read as a
JsonElement. That lets the handler tell “field missing” (leave it alone) from “field isnull” (remove it). Since every product field is required,nullis rejected with422. Accepts<JsonElement>("application/merge-patch+json")documents the expected media type for OpenAPI. Minimal APIs only read JSON bodies when theContent-Typeis JSON, including+jsontypes likeapplication/merge-patch+json, and answer other types with415.- Malformed JSON gets
400from the framework before your handler runs. In the Development environment the body is an exception message; Chapter 4 replaces that with a consistent Problem Details response. Cache-Controlis set per endpoint. The list is reusable for 60 seconds; a single product isno-cache, so clients revalidate it withIf-None-Matchand usually get a cheap304.
Step 3: Try It
Run the app:
dotnet run --project src/ShipCart.Api --urls http://localhost:5000
Then walk through the lifecycle in a second terminal:
# Read: note the ETag, then revalidate with it
curl -i http://localhost:5000/products/1
curl -i http://localhost:5000/products/1 -H 'If-None-Match: "1"'
# Update without If-Match: 428
curl -i -X PATCH http://localhost:5000/products/1 \
-H 'Content-Type: application/merge-patch+json' -d '{"price":17.99}'
# Create, replace, delete
curl -i -X POST http://localhost:5000/products -H 'Content-Type: application/json' \
-d '{"sku":"SKU-1004","name":"Desk Lamp","price":34.00,"inStock":true}'
curl -i -X PUT http://localhost:5000/products/4 -H 'Content-Type: application/json' -H 'If-Match: "1"' \
-d '{"sku":"SKU-1004","name":"LED Desk Lamp","price":36.00,"inStock":true}'
curl -i -X DELETE http://localhost:5000/products/4 -H 'If-Match: "2"'
curl -i -X DELETE http://localhost:5000/products/4 -H 'If-Match: "2"'
You should see 200 with ETag: "1", then 304, 428, 201 with Location: /products/4, 200 with ETag: "2", 204, and finally 404, because the product is gone.
Now the race. Alice and Bob both read product 2 at version "1" and send their changes at the same moment:
for price in 21.00 22.00; do
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:5000/products/2 \
-H 'Content-Type: application/merge-patch+json' -H 'If-Match: "1"' \
-d "{\"price\":$price}" &
done
wait
One request prints 200 and the other 412. Which one wins depends on timing, but there’s never more than one winner, and the loser knows it lost. Finally, check the error cases: send {"price":-1} or {"name":null} with the current ETag for 422, change the Content-Type to text/plain for 415, and send broken JSON for 400.
Stretch goals:
- Add a
GET /catalogue-imports/{importId:guid}status resource and aPOST /catalogue-importsthat returns202 Acceptedwith aLocationheader. Fake the processing with a background task that flips the status tocompletedafter a few seconds. - Use a weak ETag based on a hash of the JSON for
GET /products, and supportIf-None-Matchon the list. Why is a weak ETag fine here but not forIf-Match? - Return the updated
ETagin a412response so the client knows which version it should fetch. Is it better for the412to include the current product, or to make the client re-GET? Write down the trade-off.
Key Takeaways
- A method’s safety and idempotency matter more than its name.
GETmust never change state;PUTandDELETEare idempotent;POSTisn’t. PUTreplaces the whole resource;PATCHapplies a patch document. Prefer JSON Merge Patch for simple field updates and JSON Patch when you need array edits ortest.- The 4xx versus 5xx split tells clients whether to fix the request or try later. Never return
200for an error. - Return
201withLocationfor creation,204for success with no body, and202plus a status resource for long-running work. - ETags give you cheap revalidation (
If-None-Matchand304) and safe concurrent writes (If-Matchand412). RequireIf-Matchwith428when lost updates would hurt. - The version check and the save must be one atomic step, whether that’s a conditional SQL update, an EF Core concurrency token, or a compare-and-swap.
- Typed results make every possible status code part of the handler’s signature.
Further Reading (Primary Sources)
- RFC 9110: HTTP Semantics (methods, status codes, conditional requests)
- RFC 9111: HTTP Caching
- RFC 5789: PATCH Method for HTTP
- RFC 7396: JSON Merge Patch
- RFC 6902: JavaScript Object Notation (JSON) Patch
- RFC 6585: Additional HTTP Status Codes (
428,429) - Microsoft Learn: Create responses in Minimal API applications
- Microsoft Learn: Handling concurrency conflicts in EF Core
Previous: Chapter 2 — Resource Modelling and URL Design | Table of Contents | Next: Chapter 4 — Request/Response Design and Error Handling with RFC 9457 Problem Details (coming soon)
Get the next chapter
One email when a new chapter or deep-dive is published. Practical backend and API design notes. No spam, unsubscribe any time.

