Skip to content
API Design and Integration: The Practical Handbook
Exit

API Design and Integration: The Practical Handbook

API Design Handbook

by Pronab Roy

A 12-chapter, book-style handbook on designing, securing, and integrating production-grade APIs, with examples in ASP.NET Core.

Start reading

Chapter 1 Part I: Foundations

What Makes a Good API? REST and HTTP Fundamentals

Every API is a promise. When you publish an endpoint, you are telling other developers: “Send me this, and I’ll send you that, today and next month.” Good APIs keep that promise in a way that feels obvious to the people using them. Poor APIs keep it on a technicality, or break it quietly.

This first chapter lays the foundation for the rest of the handbook. We’ll look at APIs as products, unpack what REST really means (and what it doesn’t), take apart an HTTP message piece by piece, and finish by writing the first endpoint of ShipCart, the e-commerce, logistics, and SaaS system we’ll grow throughout the series.

APIs as Products: Consumers, Contracts, and Developer Experience

It’s tempting to think of an API as “the layer in front of the database.” That view leads to APIs shaped around tables and internal code instead of around the people calling them.

A more useful view is to treat an API as a product with three parts:

  • Consumers. Someone calls your API: a storefront front end, a mobile app, a carrier’s system, a merchant’s back office, or another team’s service. Each has different needs, different release cycles, and different tolerance for change.
  • The contract. This is everything a consumer can rely on: URLs, methods, request and response shapes, status codes, headers, error formats, and behaviour. If a consumer can observe it, they may come to depend on it, whether or not you wrote it down.
  • Developer experience (DX). How quickly can a new developer make a successful first call? Are names predictable? Do errors explain what went wrong and how to fix it? DX is the API equivalent of usability.

In ShipCart, the storefront team wants fast, cache-friendly product reads. A logistics partner wants a stable way to push tracking events. Merchants on the SaaS platform want clear API keys, clear limits, and docs they can follow without opening a support ticket. Designing with all three in mind is the difference between an API people put up with and one they like using.

REST Constraints, Explained

REST (Representational State Transfer) comes from Roy Fielding’s 2000 doctoral dissertation, Architectural Styles and the Design of Network-based Software Architectures (Chapter 5). It is not a protocol or a standard. It is an architectural style: a set of constraints that, together, give a system properties such as scalability, visibility, and independent evolution.

The constraints are:

  1. Client–server. The client handles the user interface and user workflow; the server handles data and business rules. Separating them lets the ShipCart storefront and the ShipCart API evolve and deploy independently.
  2. Statelessness. Each request carries everything the server needs to understand it. The server does not keep per-client session state between requests. Authentication tokens, pagination cursors, and filters travel with each request. This makes it easy to put many server instances behind a load balancer, because any instance can handle any request.
  3. Cacheability. Responses must say whether they can be cached and for how long. A product listing that changes a few times a day is a good candidate for caching; a cart total is not. We’ll cover Cache-Control and ETag in Chapter 3.
  4. Uniform interface. This is the core of REST. It has four parts: resources are identified by URIs; clients manipulate resources through representations (for example, a JSON document describing a product); messages are self-descriptive (methods, status codes, and media types carry standard meaning); and hypermedia as the engine of application state (HATEOAS), meaning responses contain links that tell the client what it can do next.
  5. Layered system. A client can’t tell, and shouldn’t need to know, whether it’s talking to the origin server or to a gateway, CDN, or reverse proxy in between. This is what lets you add a cache or a rate-limiting gateway without changing clients.
  6. Code on demand (optional). Servers may send executable code to clients. It’s rarely relevant for JSON APIs.

Why HATEOAS Is Often Skipped

Hypermedia is the constraint most “REST APIs” leave out. In practice, most API clients are written by developers who read the documentation and hard-code the URLs and flows they need, rather than discovering them from links at run time. Adding rich hypermedia then costs design effort without changing how clients behave.

That doesn’t make links worthless. A next link in a paginated response, or a Location header pointing to a newly created order, are small, practical uses of hypermedia, and we’ll use both in this series. Just be honest about what you’re building: an HTTP API that follows most REST constraints, not a fully hypermedia-driven one.

HTTP Anatomy: The Message on the Wire

REST describes a style; HTTP is the protocol almost every REST-style API uses. HTTP’s meaning, including methods, status codes, and headers, is defined in RFC 9110 (HTTP Semantics). How HTTP/1.1 messages are formatted on the wire is defined in RFC 9112.

An HTTP/1.1 request has:

  • A request line: method, request target, and protocol version, for example GET /products HTTP/1.1.
  • Header fields: name–value pairs such as Host, Accept, and Authorization.
  • An empty line.
  • An optional body (content), for example the JSON of a new order in a POST.

A response has:

  • A status line: protocol version, status code, and reason phrase, for example HTTP/1.1 200 OK.
  • Header fields such as Content-Type, Cache-Control, and Date.
  • An empty line.
  • An optional body.

HTTP/2 and HTTP/3 encode messages differently (binary frames instead of text lines), but the semantics are the same. A GET is a GET and a 404 is a 404 whichever version carries it.

Content Negotiation: Accept and Content-Type

Two headers cause a lot of confusion:

  • Content-Type describes the body of the message it appears on. A client sending JSON in a POST sets Content-Type: application/json; a server returning JSON sets the same on the response.
  • Accept is sent by the client to say which media types it is willing to receive, such as Accept: application/json.

When a server can’t produce any representation the client accepts, RFC 9110 defines 406 Not Acceptable. When a client sends a body in a format the server doesn’t support, the matching status is 415 Unsupported Media Type. As you’ll see in the exercise, frameworks don’t always enforce these for you, so it’s worth knowing what your framework actually does.

REST vs RPC-Style vs “REST-ish”

Most real-world APIs fall somewhere on a spectrum:

  • RPC-style APIs expose operations: POST /getProducts, POST /createShipment. Everything is often a POST, and errors may come back as 200 OK with an error flag in the body. It’s simple to start with, but caches and proxies can’t understand what’s happening, and every client must learn your custom conventions.
  • Resource-oriented (“REST-ish”) APIs expose resources and use HTTP semantics: GET /products, POST /shipments, 404 when a product doesn’t exist. Most public HTTP APIs you use today look like this, usually without HATEOAS.
  • Fully RESTful APIs follow every constraint, including hypermedia. They are less common in typical JSON APIs.

RPC is not wrong. Purpose-built RPC frameworks such as gRPC are excellent for internal service-to-service calls, and we’ll compare them properly in Chapter 12. The problem is accidental RPC over HTTP: using HTTP as a tunnel while ignoring the meaning it already provides. In this handbook, we build resource-oriented APIs that respect HTTP semantics, and we use pragmatic hypermedia where it helps.

Qualities of a Good API

Across all the chapters ahead, four qualities keep coming back:

  • Consistency. The same concept looks the same everywhere. If ShipCart uses createdAt on orders, it doesn’t use creation_date on shipments. Errors share one format. Collections paginate the same way.
  • Predictability. A developer who has used two endpoints can guess the third. GET /products/{id} suggests GET /orders/{id}. Status codes mean what HTTP says they mean.
  • Discoverability. It’s easy to find out what exists and how to use it: good documentation, an OpenAPI description, meaningful error messages, and helpful links such as Location and next.
  • Evolvability. The API can grow without breaking existing consumers. Adding a field is safe; renaming one is not. We’ll spend all of Chapter 7 on this.

None of these depends on a framework. They are design decisions, and they’re far cheaper to get right at the start than to retrofit later.

A First Look at ASP.NET Core: Minimal APIs vs Controllers

ASP.NET Core offers two fully supported ways to build HTTP APIs:

  • Minimal APIs map routes directly to handlers (lambdas or methods) with app.MapGet, app.MapPost, and so on. There is very little ceremony, and route groups (MapGroup) and endpoint filters help as the API grows.
  • Controllers group actions into classes that inherit from ControllerBase, using attributes such as [ApiController] and [HttpGet]. They come with conventions many teams already know, such as model binding conventions and action filters.

When to pick which: For new, focused services like ShipCart, Minimal APIs are a great fit: less code, explicit wiring, and good performance. Controllers make sense when your team already has a large controller-based codebase or relies on features and conventions built around MVC. Both use the same routing, dependency injection, middleware, and hosting, and the API design principles in this handbook apply equally to both. We’ll use Minimal APIs throughout.

Hands-On Exercise: Create ShipCart and Add GET /products

You’ll need the .NET SDK (current LTS) and curl. The code below works on .NET 8 and later.

Step 1: Create the solution

mkdir shipcart && cd shipcart
dotnet new sln -n ShipCart
dotnet new web -n ShipCart.Api -o src/ShipCart.Api
dotnet sln add src/ShipCart.Api

The web template gives you an empty ASP.NET Core app, which is a clean starting point for a Minimal API.

Step 2: Add the endpoint

Replace the contents of src/ShipCart.Api/Program.cs with:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// A tiny in-memory catalogue. A real database arrives in later chapters.
var products = new List<Product>
{
    new(1, "SKU-1001", "Canvas Tote Bag", 19.99m, "USD", InStock: true),
    new(2, "SKU-1002", "Stainless Water Bottle", 24.50m, "USD", InStock: true),
    new(3, "SKU-1003", "Wireless Mouse", 29.00m, "USD", InStock: false)
};

// GET /products returns the whole catalogue as JSON.
app.MapGet("/products", () => TypedResults.Ok(products))
   .WithName("ListProducts");

app.Run();

// The shape clients see. Keep it separate from any database entity.
public record Product(
    int Id,
    string Sku,
    string Name,
    decimal Price,
    string Currency,
    bool InStock);

A few things to notice:

  • Product is a record that defines the representation clients receive. Starting with a dedicated type, rather than exposing a database entity, is a habit we’ll build on in Chapter 4.
  • TypedResults.Ok(...) makes the 200 OK response explicit in the handler’s return type. Chapter 3 shows how this scales to several possible responses.
  • ASP.NET Core serialises the list with System.Text.Json, using camelCase property names by default.

Step 3: Run it

dotnet run --project src/ShipCart.Api --urls http://localhost:5000

The --urls option pins the port so the commands below match. Without it, the app uses the port in Properties/launchSettings.json, which the template picks at random.

Step 4: Inspect the raw HTTP exchange

In a second terminal:

curl -v http://localhost:5000/products

You’ll see output similar to this (your curl version, date, and connection details will differ):

* Host localhost:5000 was resolved.
*   Trying [::1]:5000...
* Connected to localhost (::1) port 5000
> GET /products HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/8.14.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Sat, 17 Oct 2026 19:56:33 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
<
[{"id":1,"sku":"SKU-1001","name":"Canvas Tote Bag","price":19.99,"currency":"USD","inStock":true},{"id":2,"sku":"SKU-1002","name":"Stainless Water Bottle","price":24.50,"currency":"USD","inStock":true},{"id":3,"sku":"SKU-1003","name":"Wireless Mouse","price":29.00,"currency":"USD","inStock":false}]

Here’s how to read it. curl prefixes its own messages with *, what it sends with >, and what it receives with <.

Line(s) Part What it means
* Host … / Trying … / Connected … curl info DNS lookup and TCP connection. Not part of HTTP itself.
> GET /products HTTP/1.1 Request line Method GET, target /products, protocol HTTP/1.1.
> Host: localhost:5000 Request header Required in HTTP/1.1; tells the server which host the client wants.
> User-Agent: curl/8.14.1 Request header Identifies the client software.
> Accept: */* Request header Content negotiation: curl accepts any media type.
> (empty) End of headers A GET has no body, so the request ends here.
< HTTP/1.1 200 OK Status line Version, status code 200, reason phrase OK.
< Content-Type: application/json; charset=utf-8 Response header Describes the body: JSON, encoded as UTF-8.
< Date: … Response header When the server generated the response, always in GMT.
< Server: Kestrel Response header The ASP.NET Core web server. (Many teams remove this in production.)
< Transfer-Encoding: chunked Response header HTTP/1.1 framing: the body is streamed in chunks instead of declaring a Content-Length up front.
< (empty) End of headers The body follows.
[{"id":1, …}] Body The representation of the /products collection.

Step 5: Explore and label

Try these, and for each one write down the request line, the status line, and any headers that changed:

  1. Send a different method: curl -i -X POST http://localhost:5000/products. You should get 405 Method Not Allowed with an Allow: GET header: the route exists, but not for that method.
  2. Ask for a resource that doesn’t exist: curl -i http://localhost:5000/carts. You’ll get 404 Not Found.
  3. Ask for XML: curl -i -H "Accept: application/xml" http://localhost:5000/products. Notice that you still get 200 OK with JSON. Minimal APIs return JSON and don’t perform content negotiation for you. Is that acceptable for ShipCart? (For a JSON-only API, many teams decide it is. Write down your reasoning.)
  4. Stretch goal: Add GET /products/{id:int} that returns TypedResults.Ok(product) or TypedResults.NotFound(). Hint: because the lambda can return two different types, declare its return type as Results<Ok<Product>, NotFound> (add using Microsoft.AspNetCore.Http.HttpResults;). Check both cases with curl -i.

Key Takeaways

  • An API is a product with consumers and a contract. Design for the people calling it, not for your database.
  • REST is an architectural style defined by constraints: client–server, stateless, cacheable, uniform interface, layered system, and optional code on demand.
  • Most “REST APIs” skip full HATEOAS. That’s a reasonable choice, but small hypermedia touches like Location and next links still pay off.
  • HTTP semantics are defined in RFC 9110. Learn to read a raw request and response: start line, headers, empty line, body.
  • Content-Type describes the body you’re sending; Accept says what you want back.
  • Good APIs are consistent, predictable, discoverable, and evolvable.
  • ASP.NET Core supports both Minimal APIs and controllers. The design principles are the same for both; this handbook uses Minimal APIs.

Further Reading (Primary Sources)

Chapter 2 Part I: Foundations

Resource Modelling and URL Design

📘 API Design and Integration: The Practical Handbook — Chapter 2 of 12 Part I: Foundations · ~11 min read Goal: Turn a business domain into clean, consistent resources and URLs that clients can predict. Table of Contents · [ Read as eBook ]

In Chapter 1 we wrote ShipCart’s first endpoint, GET /products. One endpoint is easy. Fifty endpoints, written by four teams over two years, are where APIs start to drift: /getOrders next to /order/{id}, /shipment_tracking next to /shipments/{id}/events, and nobody quite sure which pattern is the “real” one.

The fix isn’t a clever framework feature. It’s a small set of design decisions you make early and write down: what your resources are, how they relate, and how their URLs are spelled. This chapter walks through those decisions, models ShipCart’s catalogue, cart, order, and shipment resources, and finishes by wiring them up with route groups in ASP.NET Core.

Start With the Domain, Not the Database

A resource is anything important enough to be named and addressed: a product, an order, a shipment. In REST terms, the URL identifies the resource, and the JSON you send or receive is a representation of it (Chapter 1). The resource is the concept; the database row is just one way you store it today.

The simplest way to find resources is to listen to how people describe the business and underline the nouns. A ShipCart product manager might say:

“A merchant lists products in their catalogue. A customer adds products to a cart, checks out to create an order, and pays. The warehouse packs the order into one or more shipments, and the carrier sends tracking events until each shipment is delivered.”

That one paragraph gives us a first list of candidate resources:

Noun Resource? Notes
Merchant Yes Owns products; mostly managed through the SaaS admin side.
Product Yes The catalogue item customers browse.
Catalogue Probably not on its own It’s the collection of products, so /products already covers it.
Customer Yes Often exposed as “the current user” rather than by ID (more below).
Cart Yes Short-lived, one per customer session.
Cart item Yes, as a sub-resource Only meaningful inside a cart.
Order Yes Long-lived, the heart of the system.
Order item (line) Yes, as a sub-resource Belongs to exactly one order.
Payment Yes Has its own lifecycle (authorised, captured, refunded).
Shipment Yes One order can have several.
Tracking event Yes, as a sub-resource An append-only history of a shipment.
Carrier Maybe Reference data. Expose it only if clients need to list or choose carriers.

Two rules of thumb help with the borderline cases:

  • If clients need to read it, change it, or link to it on its own, it’s a resource. A tracking event is worth addressing because carriers push them and storefronts display them.
  • If it only ever appears as part of something else, it can stay a field. A product’s dimensions are attributes, not a resource, unless you find a real need to manage them separately.

Notice what’s not on the list: order_status_history, product_inventory_snapshot, cart_lines_v2. Those are table names, and we’ll come back to why they don’t belong in URLs.

Collections, Items, and Sub-Resources

Most resources come in two shapes:

  • A collection: /products, /orders, /shipments.
  • A single item within that collection: /products/{productId}, /orders/{orderId}.

The pairing is what makes an API predictable. A developer who has used GET /products/{productId} will guess GET /orders/{orderId} without opening the docs, and they should be right.

When to Use a Sub-Resource

A sub-resource is a collection that lives under a parent item, such as /orders/{orderId}/items. Use one when the child:

  1. Can’t exist without the parent. An order line has no meaning outside its order.
  2. Is almost always accessed through the parent. Clients ask “what’s in this order?”, not “show me every order line in the system”.
  3. Is scoped by the parent for security. If a customer can see an order, they can see its items; checking the parent once keeps authorisation simple.

Shipments are a good counter-example. A shipment belongs to an order, but warehouse staff and carriers work with shipments directly, often by shipment ID alone, and a carrier’s system has no reason to know ShipCart’s order IDs. So shipments get their own top-level collection, /shipments, and an order can still offer a filtered view at /orders/{orderId}/shipments. Both URLs return the same representations; they’re just two ways into one collection.

How Deep to Nest

Nesting reads nicely at first: /merchants/{merchantId}/products/{productId}/variants/{variantId}/images. In practice deep URLs cause problems:

  • Every client has to know every parent ID, even when the child’s own ID is already unique.
  • Moving a child to another parent (a product changing merchant, say) breaks its URL.
  • Routes become long and authorisation checks multiply.

A widely used guideline is no more than one level of nesting below a top-level resource: /collection/{id}/sub-collection and, if needed, /collection/{id}/sub-collection/{subId}. If a child has a globally unique ID and is useful on its own, promote it to the top level instead: /product-variants/{variantId} rather than four levels of nesting.

URL Conventions That Age Well

None of the following is mandated by HTTP. They’re conventions, and the main value of a convention is that it’s applied everywhere. Pick a style, write it down, and stick to it.

Plural Nouns for Collections

Use /products and /products/{productId}, not /product and /product/{productId}. With plurals the collection and the item share one prefix, so the URL reads as “the products collection” and “one item in it”. Mixing singular and plural is worse than either choice on its own.

The exception is a singleton: a resource there is exactly one of in context. The current customer’s cart is a good example. GET /cart (or GET /me/cart) is clearer than making every client look up a cart ID first. Singletons are fine as long as they’re genuinely single.

Lowercase, Hyphen-Separated Path Segments

According to RFC 3986, the path of a URI is case-sensitive, while the scheme and host are not. That means /Orders and /orders can legally be two different resources, which is exactly the kind of surprise you want to avoid. Use lowercase everywhere.

For multi-word segments, hyphens (kebab-case) are the common choice: /tracking-events, /product-variants. Underscores can disappear visually when URLs are underlined as links, and camelCase in paths clashes with the case-insensitivity people expect. Several published style guides, including Zalando’s RESTful API Guidelines, recommend kebab-case for path segments.

Keep the JSON property naming separate from the URL naming. ShipCart uses camelCase in JSON (createdAt, the System.Text.Json default from Chapter 1) and kebab-case in paths. That’s normal and consistent, because each follows one rule.

No Verbs, No File Extensions, No Trailing Slash Debates

  • No verbs in resource paths. /orders with POST creates an order; /createOrder duplicates what the HTTP method already says. (Actions that genuinely aren’t CRUD are covered below.)
  • No file extensions. Use Accept and Content-Type for formats, as we saw in Chapter 1, not /orders.json.
  • Pick one rule for trailing slashes. Treat /orders and /orders/ consistently, either as the same resource or by redirecting one to the other, and never return different data for them.

Query Strings Are for Shaping, Not Identifying

The path identifies which resource. The query string shapes how you see it: filtering, sorting, paging, and field selection.

GET /orders?status=shipped&sort=-createdAt&limit=20

Avoid using query parameters as identifiers (/orders?id=8f3c...). It works, but it breaks the item URL pattern and makes caching and logging harder to reason about. We’ll cover filtering and pagination properly in Chapter 5.

Choosing Identifiers: IDs, UUIDs, and Slugs

What goes in {orderId} matters more than it looks. The identifier becomes part of your contract: clients store it, log it, and put it in support tickets.

Option Example Strengths Weaknesses
Sequential integer /orders/1042 Short, readable Reveals volume and ordering; easy to guess neighbouring IDs; ties you to one database sequence.
UUID / GUID /orders/8f3c2a4e-... Unguessable in practice, can be generated anywhere, safe to merge across systems Long and hard to read aloud.
Prefixed opaque ID /orders/ord_7Hq2... Self-describing in logs and tickets; opaque to clients You must generate and validate it yourself.
Slug /products/canvas-tote-bag Human-friendly, good for public pages Can change when names change; must be unique.

A few principles hold whichever you choose:

  • Identifiers must be stable. Once a URL is handed out, it should keep pointing at the same resource. That’s why slugs make better aliases than primary identifiers: if a merchant renames a product, the ID stays the same and the slug can redirect.
  • Treat IDs as opaque strings in the contract. Even if ShipCart uses GUIDs internally, document them as strings. Clients then can’t rely on their structure, and you keep the freedom to change the format later.
  • Don’t rely on unguessable IDs for security. A UUID makes enumeration harder, but authorisation still has to check that the caller may see that order. We’ll return to this in Chapter 9, because broken object-level authorisation is one of the most common API vulnerabilities.

For ShipCart we’ll use GUIDs for orders, carts, payments, and shipments, and keep the integer product IDs from Chapter 1 for now. Products also get a slug for the storefront, as an alias.

Modelling Actions That Aren’t CRUD

Sooner or later a business action doesn’t map neatly to create, read, update, or delete. A customer cancels an order. A warehouse marks a shipment as dispatched. There are two main ways to model this, and both are legitimate.

Option 1: Update a State Field

PATCH /orders/{orderId}
Content-Type: application/json

{ "status": "cancelled" }

This keeps the API purely resource-oriented. It works well when the change really is just data and the rules are simple.

The trouble starts when the transition has side effects and rules: a cancellation must check the order hasn’t shipped, release reserved stock, trigger a refund, and notify the customer. Hiding all of that behind a generic PATCH makes the contract vague. Can a client set status to "delivered"? To "refunded"? The server has to reject most values, and clients have to learn which ones by trial and error.

Option 2: An Action Sub-Resource

POST /orders/{orderId}/cancel
Content-Type: application/json

{ "reason": "customer-request" }

This names the business operation explicitly. It’s easy to document, easy to authorise separately (“support staff may cancel; customers may cancel only before dispatch”), and it can accept its own input, such as a reason. POST fits because the action isn’t safe and, without extra design, isn’t idempotent; Chapter 3 covers those properties in detail, and Chapter 10 shows how to make operations like this safe to retry.

Some style guides use a slightly different spelling for the same idea. Google’s API Improvement Proposals, for example, describe custom methods with a colon, as in POST /orders/{orderId}:cancel. The principle is the same: a named action on a specific resource.

A Third Option: Model the Action as a Resource

Sometimes the action itself is worth keeping as a record. Instead of “cancel this order”, you create a cancellation:

POST /orders/{orderId}/cancellations

This gives you a history (who cancelled, when, and why) that you can GET later. It’s a good fit for refunds and returns, which have their own lifecycle, and is often overkill for a simple toggle.

ShipCart’s choice: state changes that carry business rules get an action endpoint (/cancel, /dispatch). Plain edits, such as changing a delivery note, use PATCH. Refunds and returns become resources in their own right. Whatever you choose, document the allowed transitions, and keep status read-only in the representation if clients may not set it directly.

Don’t Leak Your Internals

Your URLs are the most visible and longest-lived part of your contract. Anything you put in them becomes hard to change. Common leaks to avoid:

  • Table and column names. /tbl_order_hdr/{id} or /order-status-history exposes how you happen to store data today. If you later split or merge tables, the URL either lies or breaks.
  • Technology. /api/sqlserver/orders, /orders.aspx, or /OrderService.svc/GetOrder tie the contract to an implementation.
  • Internal service boundaries. If ShipCart splits into an order service and a fulfilment service, clients shouldn’t need to know. /order-service/orders and /fulfilment-service/shipments push your org chart into every client. A gateway or reverse proxy (the “layered system” from Chapter 1) can route /orders and /shipments to different services behind one consistent surface.
  • Sensitive data. URLs end up in browser history, proxy logs, and analytics. Never put tokens, passwords, or personal data such as email addresses in paths or query strings. Use headers or the request body.

A useful test: could you rewrite the whole back end in a different language, on a different database, and keep every URL unchanged? If not, something internal has leaked.

Writing a URL and Naming Style Guide

Decisions only help if the whole team makes them the same way. A one-page style guide is enough to start. Here is ShipCart’s:

ShipCart API URL and Naming Style Guide (v1)

  1. Resources are nouns from the business domain. No verbs in resource paths.
  2. Collections are plural: /orders, /orders/{orderId}. Singletons (such as /cart) are allowed when there is exactly one in context.
  3. Paths are lowercase with hyphens: /tracking-events. JSON properties are camelCase: createdAt.
  4. Nest at most one level: /orders/{orderId}/items. Promote children with their own identity to the top level.
  5. Identifiers are opaque strings in the contract. Use GUIDs for transactional resources. Slugs are aliases, never the only identifier.
  6. Query parameters filter, sort, page, and shape. They never identify a resource.
  7. Business actions with rules use POST /{resource}/{id}/{action} with a verb action name, for example /cancel.
  8. No file extensions, technology names, table names, or service names in URLs.
  9. No secrets or personal data in URLs.
  10. One trailing-slash rule, applied everywhere.

Keep it short enough that people actually read it, and review new endpoints against it in code review. In Chapter 6 we’ll move these rules into an OpenAPI description, where tooling can check some of them automatically.

Hands-On Exercise: Map ShipCart and Build It With MapGroup

Step 1: Draw the Resource Map

Before writing code, sketch the resources and their relationships:

products ──────────────┐
                       │ (referenced by)
cart (singleton) ── items
                       │ checkout
orders ── items        ▼
   │ └── shipments (filtered view)
   └── cancel (action)
payments
shipments ── tracking-events
   └── dispatch (action)

Step 2: Write the URL Table

Method URL Purpose
GET /products List products
GET /products/{productId} Get one product
GET /cart Get the current customer’s cart
POST /cart/items Add an item to the cart
DELETE /cart/items/{itemId} Remove an item from the cart
POST /orders Create an order from the cart (checkout)
GET /orders/{orderId} Get one order
GET /orders/{orderId}/items List an order’s lines
GET /orders/{orderId}/shipments List an order’s shipments
POST /orders/{orderId}/cancel Cancel an order
GET /shipments/{shipmentId} Get one shipment
POST /shipments/{shipmentId}/dispatch Mark a shipment as dispatched
GET /shipments/{shipmentId}/tracking-events List a shipment’s tracking events
POST /shipments/{shipmentId}/tracking-events Record a tracking event (carriers)

Step 3: Review Against the Checklist

Go through every row and check: Is it a noun? Plural (or a genuine singleton)? Lowercase and hyphenated? At most one level of nesting? No internal names? If any row fails, change the design now, while it costs nothing.

Step 4: Implement With Route Groups

Route groups, added in ASP.NET Core 7 via MapGroup, let you declare a common prefix once and attach endpoints, metadata, and filters to the whole group. They map almost one-to-one onto the resource map. Continue from Chapter 1’s Program.cs and organise it like this:

using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ShipCartStore>();

// Lowercase URLs when the app generates links, matching our style guide.
builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true);

var app = builder.Build();

app.MapGroup("/products").WithTags("Products").MapProducts();
app.MapGroup("/orders").WithTags("Orders").MapOrders();
app.MapGroup("/shipments").WithTags("Shipments").MapShipments();

app.Run();

public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProducts(this RouteGroupBuilder group)
    {
        group.MapGet("/", (ShipCartStore store) => TypedResults.Ok(store.Products));

        group.MapGet("/{productId:int}", Results<Ok<Product>, NotFound> (int productId, ShipCartStore store) =>
            store.Products.FirstOrDefault(p => p.Id == productId) is { } product
                ? TypedResults.Ok(product)
                : TypedResults.NotFound());

        return group;
    }
}

public static class OrderEndpoints
{
    public static RouteGroupBuilder MapOrders(this RouteGroupBuilder group)
    {
        group.MapGet("/{orderId:guid}", Results<Ok<Order>, NotFound> (Guid orderId, ShipCartStore store) =>
            store.Orders.TryGetValue(orderId, out var order)
                ? TypedResults.Ok(order)
                : TypedResults.NotFound());

        // Sub-resource: one level of nesting under a single order.
        group.MapGet("/{orderId:guid}/items", Results<Ok<IReadOnlyList<OrderItem>>, NotFound> (Guid orderId, ShipCartStore store) =>
            store.Orders.TryGetValue(orderId, out var order)
                ? TypedResults.Ok(order.Items)
                : TypedResults.NotFound());

        // Filtered view of the top-level shipments collection.
        group.MapGet("/{orderId:guid}/shipments", Results<Ok<List<Shipment>>, NotFound> (Guid orderId, ShipCartStore store) =>
            store.Orders.ContainsKey(orderId)
                ? TypedResults.Ok(store.Shipments.Values.Where(s => s.OrderId == orderId).ToList())
                : TypedResults.NotFound());

        // A business action with rules gets its own endpoint.
        group.MapPost("/{orderId:guid}/cancel", Results<Ok<Order>, NotFound, Conflict<string>> (Guid orderId, ShipCartStore store) =>
        {
            if (!store.Orders.TryGetValue(orderId, out var order))
                return TypedResults.NotFound();

            if (order.Status is "dispatched" or "delivered")
                return TypedResults.Conflict("Orders can't be cancelled after dispatch.");

            var cancelled = order with { Status = "cancelled" };
            store.Orders[orderId] = cancelled;
            return TypedResults.Ok(cancelled);
        });

        return group;
    }
}

public static class ShipmentEndpoints
{
    public static RouteGroupBuilder MapShipments(this RouteGroupBuilder group)
    {
        group.MapGet("/{shipmentId:guid}", Results<Ok<Shipment>, NotFound> (Guid shipmentId, ShipCartStore store) =>
            store.Shipments.TryGetValue(shipmentId, out var shipment)
                ? TypedResults.Ok(shipment)
                : TypedResults.NotFound());

        group.MapGet("/{shipmentId:guid}/tracking-events", Results<Ok<IReadOnlyList<TrackingEvent>>, NotFound> (Guid shipmentId, ShipCartStore store) =>
            store.Shipments.TryGetValue(shipmentId, out var shipment)
                ? TypedResults.Ok(shipment.TrackingEvents)
                : TypedResults.NotFound());

        return group;
    }
}

// Representations: shaped for clients, not copied from database tables.
public record Product(int Id, string Sku, string Slug, string Name, decimal Price, string Currency, bool InStock);
public record OrderItem(int ProductId, int Quantity, decimal UnitPrice);
public record Order(Guid Id, string Status, IReadOnlyList<OrderItem> Items, DateTimeOffset CreatedAt);
public record TrackingEvent(string Code, string Description, DateTimeOffset OccurredAt);
public record Shipment(Guid Id, Guid OrderId, string Status, IReadOnlyList<TrackingEvent> TrackingEvents);

// In-memory sample data so the exercise runs without a database.
public class ShipCartStore
{
    public static readonly Guid SampleOrderId = Guid.Parse("8f3c2a4e-5b6d-4e7f-9a1b-2c3d4e5f6a7b");
    public static readonly Guid SampleShipmentId = Guid.Parse("1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d");

    public List<Product> Products { get; } =
    [
        new(1, "SKU-1001", "canvas-tote-bag", "Canvas Tote Bag", 19.99m, "USD", true),
        new(2, "SKU-1002", "stainless-water-bottle", "Stainless Water Bottle", 24.50m, "USD", true),
        new(3, "SKU-1003", "wireless-mouse", "Wireless Mouse", 29.00m, "USD", false)
    ];

    public Dictionary<Guid, Order> Orders { get; } = new()
    {
        [SampleOrderId] = new(SampleOrderId, "placed",
            [new(1, 2, 19.99m), new(2, 1, 24.50m)],
            new DateTimeOffset(2026, 10, 20, 9, 30, 0, TimeSpan.Zero))
    };

    public Dictionary<Guid, Shipment> Shipments { get; } = new()
    {
        [SampleShipmentId] = new(SampleShipmentId, SampleOrderId, "label-created",
            [new("label-created", "Shipping label created", new DateTimeOffset(2026, 10, 20, 11, 0, 0, TimeSpan.Zero))])
    };
}

A few things to notice:

  • Each resource family lives in one group. /orders, its sub-resources, and its actions sit together, so the code mirrors the URL table. As the API grows, you can attach things like authorisation or rate-limiting policies to a whole group with one call, which we’ll use in Chapters 8 and 9.
  • Route constraints enforce the identifier rules. {orderId:guid} means /orders/not-a-guid returns 404 without ever reaching the handler. Constraints are for matching routes, not for validating input; Microsoft’s docs recommend using them to disambiguate routes rather than as validation, and we’ll cover proper validation errors in Chapter 4.
  • Results<...> lists every outcome. The cancel handler can return 200, 404, or 409 Conflict, and the signature says so. Chapter 3 explains why 409 fits a cancellation that breaks a business rule, and Chapter 4 replaces the plain string with a standard Problem Details body.
  • LowercaseUrls only affects URLs that ASP.NET Core generates, such as links built with LinkGenerator. Incoming route matching is case-insensitive by default, so /Orders still matches /orders. The style guide is what keeps your published URLs lowercase.

The [ ... ] collection expressions need C# 12 (.NET 8 or later), which matches Chapter 1’s requirements.

Step 5: Try It

Run the app as in Chapter 1:

dotnet run --project src/ShipCart.Api --urls http://localhost:5000

Then, in a second terminal:

curl -i http://localhost:5000/products/1
curl -i http://localhost:5000/orders/8f3c2a4e-5b6d-4e7f-9a1b-2c3d4e5f6a7b/items
curl -i http://localhost:5000/orders/8f3c2a4e-5b6d-4e7f-9a1b-2c3d4e5f6a7b/shipments
curl -i http://localhost:5000/orders/not-a-guid
curl -i -X POST http://localhost:5000/orders/8f3c2a4e-5b6d-4e7f-9a1b-2c3d4e5f6a7b/cancel

For each request, note the status code and check that the URL follows the style guide. The not-a-guid request should return 404 because the route constraint doesn’t match.

Stretch goals:

  1. Add GET /products/{slug} as an alias that returns the same representation as /products/{productId:int}. Because the integer route has a constraint, both can coexist. Then decide: should the alias redirect to the ID URL with 301, or return the product directly? Write down your reasoning.
  2. Add POST /shipments/{shipmentId:guid}/dispatch that returns 409 Conflict if the shipment has already been dispatched.
  3. Add the singleton GET /cart group. What does the server use to decide whose cart it is, if not an ID in the URL? (You’ll answer this properly in Chapter 8.)

Key Takeaways

  • Find resources by listening for nouns in how the business is described, not by listing database tables.
  • Pair collections and items (/orders, /orders/{orderId}) so URLs are predictable.
  • Use sub-resources for children that can’t exist without their parent, and nest at most one level deep.
  • Use plural nouns, lowercase paths, and hyphens. Keep JSON naming as a separate, consistent rule.
  • Identifiers must be stable and should be opaque to clients. Slugs make good aliases, not primary keys. Unguessable IDs are not a substitute for authorisation.
  • Model business actions with rules as explicit endpoints (POST /orders/{orderId}/cancel), and plain edits with PATCH.
  • Keep tables, technology, service boundaries, and sensitive data out of your URLs.
  • Write a short style guide, and use MapGroup so your code mirrors your resource map.

Further Reading (Primary Sources)


Previous: Chapter 1 — What Makes a Good API? | Table of Contents | Next: Chapter 3 — HTTP Methods, Status Codes, and Semantics Done Right (coming soon)

Chapter 3 Part I: Foundations

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)

More chapters are on the way

You have reached the end of the published chapters. These are coming next:

  1. 4 Request/Response Design and Error Handling with RFC 9457 Problem Details
  2. 5 Pagination, Filtering, Sorting, and Searching
  3. 6 Contract-First Design with OpenAPI
  4. 7 API Versioning and Evolution Without Breaking Clients
  5. 8 Authentication and Authorization: API Keys, OAuth 2.0, OIDC, and JWT
  6. 9 API Security in Practice: The OWASP API Security Top 10 and Rate Limiting
  7. 10 Idempotency, Retries, and Resilient Third-Party Integrations (Polly and Circuit Breakers)
  8. 11 Webhooks and Asynchronous, Event-Driven Integration
  9. 12 Testing, Observability, and Choosing Beyond REST: Contract Tests, OpenTelemetry, GraphQL, and gRPC

Back to the series page