API Design Handbook

HTTP Methods, Status Codes, and Semantics Done Right

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/4 returns 204 No Content; a repeat returns 404 Not Found. The responses differ, but the server ends in the same state, so DELETE is still idempotent.
  • GET must never change state. If GET /orders/{orderId}/cancel cancels an order, a browser prefetch, a link preview bot, or a monitoring tool will eventually cancel one for you. Actions use POST, as in Chapter 2.
  • POST isn’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.
  • PATCH can be idempotent, but HTTP doesn’t promise it. “Set the price to 17.99” is idempotent; “add 1 to stock” is not. RFC 5789 defines PATCH and 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:

  • 400 vs 422. Use 400 when the server can’t make sense of the request at all: invalid JSON or a missing body. Use 422 when it parsed fine but breaks a rule, such as a price of -1. RFC 9110 renamed 422 from “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 use 400 for everything, which is acceptable if it’s consistent; Chapter 4 makes the body of these errors the part clients can rely on.
  • 401 vs 403. 401 means “I don’t know who you are”, and RFC 9110 requires it to come with a WWW-Authenticate header. 403 means “I know who you are, and the answer is no.”
  • 403 vs 404. If revealing that a resource exists is itself a leak (another merchant’s order, say), return 404 instead of 403. RFC 9110 explicitly allows this.
  • 409 vs 412 vs 422. 409 is a clash with the resource’s state (“this order has shipped”). 412 is specifically about a precondition header the client sent. 422 is about the content of the request itself.
  • Never 200 for errors. Monitoring, retries, caches, and client libraries all look at the status code first. An error hidden in a 200 body 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 (with If-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. public allows 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 StoredProduct with Version + 1, so the ETag changes whenever the product does. Clients never see the version as a field; they only see the opaque ETag.
  • TryReplace is the atomic check. ConcurrentDictionary.TryUpdate swaps 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 return 412, even when both requests passed the If-Match check a moment earlier.
  • Merge Patch is read as a JsonElement. That lets the handler tell “field missing” (leave it alone) from “field is null” (remove it). Since every product field is required, null is rejected with 422.
  • Accepts<JsonElement>("application/merge-patch+json") documents the expected media type for OpenAPI. Minimal APIs only read JSON bodies when the Content-Type is JSON, including +json types like application/merge-patch+json, and answer other types with 415.
  • Malformed JSON gets 400 from 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-Control is set per endpoint. The list is reusable for 60 seconds; a single product is no-cache, so clients revalidate it with If-None-Match and usually get a cheap 304.

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:

  1. Add a GET /catalogue-imports/{importId:guid} status resource and a POST /catalogue-imports that returns 202 Accepted with a Location header. Fake the processing with a background task that flips the status to completed after a few seconds.
  2. Use a weak ETag based on a hash of the JSON for GET /products, and support If-None-Match on the list. Why is a weak ETag fine here but not for If-Match?
  3. Return the updated ETag in a 412 response so the client knows which version it should fetch. Is it better for the 412 to 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. GET must never change state; PUT and DELETE are idempotent; POST isn’t.
  • PUT replaces the whole resource; PATCH applies a patch document. Prefer JSON Merge Patch for simple field updates and JSON Patch when you need array edits or test.
  • The 4xx versus 5xx split tells clients whether to fix the request or try later. Never return 200 for an error.
  • Return 201 with Location for creation, 204 for success with no body, and 202 plus a status resource for long-running work.
  • ETags give you cheap revalidation (If-None-Match and 304) and safe concurrent writes (If-Match and 412). Require If-Match with 428 when 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)


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)

Newsletter

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

Keep reading

Related articles