REST API Design: Cleanly Separating Resources, Verbs, Idempotency and Status Codes
AI generated
{ }
GET
REST API Design · HTTP · Idempotency · Status Codes · Resources
REST API Design: Cleanly Separating Resources, Verbs, Idempotency
and Status Codes

Many REST APIs use HTTP as a transport protocol without ever tapping into its semantics. Resources modeled incorrectly, verbs misused, idempotency not guaranteed and status codes chosen arbitrarily, these are not questions of style but design errors with direct consequences for caching, retry logic and client implementation.

17 min read GET · POST · PUT · PATCH · DELETE · Idempotency · 2xx · 4xx · 5xx RFC 7231 · RFC 7807 · Symfony 7 · REST constraints

1. Resource Modeling: Nouns Instead of Verbs in URLs

The basic rule of REST resource modeling states: URLs identify resources (things), not actions (verbs). /getUser, /createOrder or /deleteProduct are RPC patterns, not REST URLs. The REST alternative: /users/{id}, /orders, /products/{id}. The HTTP verb takes on the semantics of the action. This separation is not semantic purism, it has practical consequences: REST-compliant URLs enable generic proxies, caches and gateways to decide how to handle a request based purely on the HTTP verb and the URL.

Pluralization is a convention, not a requirement, but it creates consistency: /users for the collection, /users/{id} for a single element. Hierarchical resources are expressed through the URL structure: /orders/{id}/items for the items of an order. Flat hierarchies are better than deep ones: /order-items/{id} is often more sensible than /orders/{id}/items/{itemId}/details/{detailId}. The rule of thumb: more than two levels often signal a modeling problem.

# REST-compliant URL structure, examples from a shop system

# Collections (Plural)
GET    /products              # List all products (with pagination)
POST   /products              # Create a new product

# Single resource
GET    /products/42           # Get product 42
PUT    /products/42           # Replace product 42 completely
PATCH  /products/42           # Partially update product 42
DELETE /products/42           # Delete product 42

# Sub-resources (max. 2 levels deep)
GET    /orders/99/items       # List items of order 99
POST   /orders/99/items       # Add item to order 99
DELETE /orders/99/items/5     # Remove item 5 from order 99

# WRONG (RPC-style URLs, never do this)
# POST /createProduct
# GET  /getProductById?id=42
# POST /deleteProduct/42
# GET  /product/getActiveOnes

2. HTTP Verbs: Semantics Instead of Convention

HTTP verbs carry semantic meaning that goes beyond mere convention and is evaluated by proxies, caches and browsers. GET is safe and idempotent: no side effects, repeatable without consequence. Proxies and caches are allowed to store and replay GET requests. POST is neither safe nor idempotent: it creates side effects and can create duplicate resources if repeated. PUT is idempotent but not safe: it replaces a resource completely, and repeated execution has the same effect. PATCH is neither safe nor idempotent per specification, but it can be implemented idempotently. DELETE is idempotent: after the first successful delete, there is nothing left to delete.

A common mistake is implementing all write operations as POST. POST /products/42/update instead of PUT /products/42 or PATCH /products/42. This prevents clients and proxies from making use of the correct semantics. Another mistake is attaching a request body to DELETE requests. HTTP technically allows it, but many proxies ignore or strip DELETE body data. If a DELETE request needs parameters, they belong in the URL or as query parameters.

3. Idempotency: GET, PUT, DELETE vs. POST and PATCH

Idempotency means: executing the same operation multiple times has the same effect as executing it once. This property is crucial for retry logic: if a client is not sure whether its request reached the server (for example after a network timeout), it can safely retry an idempotent request. A non-idempotent request, by contrast, cannot be blindly retried, it could lead to duplicate orders, duplicate payments or other unwanted side effects.

For POST, there is a mechanism to add idempotency: the Idempotency-Key header (widely used in payment APIs such as Stripe). The client generates a UUID for each operation and sends it as a header. The server stores the result under this UUID and returns the cached result on a retry instead of executing the operation again. This mechanism is essential for critical operations such as orders and payments. In Symfony, it can be implemented as middleware that checks the key before the controller is reached.

 ['onRequest', 20],
            KernelEvents::RESPONSE => ['onResponse', 0],
        ];
    }

    public function onRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        if ($request->getMethod() !== 'POST') {
            return;
        }

        $key = $request->headers->get('Idempotency-Key');
        if ($key === null || strlen($key) < 8 || strlen($key) > 255) {
            return; // Key optional, only enforce on /payments/* routes
        }

        $cacheKey = 'idempotency_' . hash('sha256', $key . $request->getPathInfo());
        $cached = $this->idempotencyCache->get($cacheKey, function (ItemInterface $item) {
            $item->expiresAfter(self::TTL);
            return null; // Not yet cached
        });

        if ($cached !== null) {
            // Replay cached response
            $response = new JsonResponse(
                json_decode($cached['body'], true),
                $cached['status'],
                array_merge($cached['headers'], ['X-Idempotent-Replayed' => 'true'])
            );
            $event->setResponse($response);
        }

        $request->attributes->set('_idempotency_key', $cacheKey);
    }

    public function onResponse(ResponseEvent $event): void
    {
        $request = $event->getRequest();
        $cacheKey = $request->attributes->get('_idempotency_key');
        if ($cacheKey === null) {
            return;
        }

        $response = $event->getResponse();
        if ($response->getStatusCode() >= 500) {
            return; // Don't cache server errors
        }

        $this->idempotencyCache->delete($cacheKey);
        $this->idempotencyCache->get($cacheKey, function (ItemInterface $item) use ($response) {
            $item->expiresAfter(self::TTL);
            return [
                'status' => $response->getStatusCode(),
                'body' => $response->getContent(),
                'headers' => ['Content-Type' => $response->headers->get('Content-Type')],
            ];
        });
    }
}

4. Status Codes: Precise Instead of Approximate

HTTP status codes are semantic signals, not convention. Returning 200 when a resource was not found, or using 500 for validation errors, breaks the expectations of every client and proxy. The most common status code mistakes in REST APIs: 200 for everything, 400 for all client errors without distinction, and 500 for all server errors. Correct mapping requires an understanding of the respective semantics. 200 OK: resource found and returned. 201 Created: new resource created, the Location header contains the URL. 204 No Content: successful, no response body (typical for DELETE and successful PATCH operations that do not return a body).

For client errors: 400 Bad Request for syntactically malformed requests (invalid JSON). 401 Unauthorized: no valid token present. 403 Forbidden: token valid, but no permission for this resource. 404 Not Found: resource does not exist. 409 Conflict: conflict with the current state (for example optimistic locking, duplicate email address). 422 Unprocessable Entity: JSON syntactically correct, but validation fails (missing required fields, invalid values). 429 Too Many Requests: rate limit exceeded. 503 Service Unavailable: server temporarily unreachable (maintenance, overload).

5. Modeling Sub-Resources and Relationships

Relationships between resources are modeled in REST in two ways: as embedded data (inline in the representation) or as a separate sub-resource with its own URL. The decision depends on how the data is typically queried. Order items are almost always fetched together with the order, so embedding makes sense. A user profile picture is optional and needed less often, so a separate resource is better. URL hierarchies that go too deep (/users/{id}/addresses/{aId}/phones/{pId}) are a signal that the modeling should be simplified.

For many-to-many relationships, standalone resources for the relationship itself are often a good fit: /product-categories/{productId}/{categoryId} instead of modeling the relationship only through nested URLs. This enables clean CRUD operations on the relationship. For representing relationships in the response representation, two approaches are common: IDs (compact, requires follow-up requests) or embedded objects with a ?embed=category parameter (flexible, but more complex to implement).

6. Actions on Resources: When Verbs Make Sense

Not every API operation naturally maps to CRUD on a resource. Canceling an order, publishing an article or sending an email are actions that are hard to model as a plain resource update. The REST-compliant solution: model actions as a sub-resource. POST /orders/{id}/cancellations instead of POST /orders/{id}/cancel. Creating a "cancellation" is a POST operation on a new resource that implies the order is being cancelled.

Alternatively, the state of the resource can be updated explicitly via PATCH: PATCH /orders/{id} with {"status": "cancelled"}. This is simpler, but less expressive for complex state transitions with additional parameters (cancellation reason, partial amounts, etc.). The rule of thumb: if the action results in a new entity or has multiple parameters, use a sub-resource. If it is a simple state transition, a PATCH on the main resource is sufficient.

# OpenAPI: actions as sub-resources, REST-compliant modeling
paths:
  # Cancel an order, the cancellation is its own resource
  /orders/{orderId}/cancellations:
    post:
      operationId: cancelOrder
      summary: "Cancel an order"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  enum: [customer_request, fraud, out_of_stock]
                refundAmount:
                  type: number
                  format: float
      responses:
        "201":
          description: "Cancellation created, order is now cancelled"
          headers:
            Location:
              schema:
                type: string
              description: "URL of the cancellation resource"
        "409":
          description: "Order already cancelled or shipped"
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  # Publish a product, status transition via PATCH
  /products/{id}:
    patch:
      operationId: updateProduct
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum: [draft, published, archived]

7. Content Negotiation and the Accept Header

Content negotiation allows an API to serve different representations of the same resource, depending on what the client is asking for. The Accept header signals which MIME types the client understands. Accept: application/json is the default for REST APIs. Accept: text/csv can make sense for export endpoints. Accept: application/vnd.mironsoft.v2+json is a vendor-specific media type that enables versioning through the header instead of the URL.

In Symfony, content negotiation is available via the Request::getPreferredFormat() mechanism. A common source of errors: if the client sends an Accept header the server does not support, the server must respond with 406 Not Acceptable, not with the default format and no warning. 406 is often forgotten or not implemented in practice, which leads to silent format errors the client only notices when parsing.

8. Comparison: Anti-Patterns vs. REST-Compliant Design

The most common REST design mistakes in practice are not a lack of knowledge of the HTTP specification, but habits carried over from RPC development or quick ad-hoc design without systematic decisions. The following table shows the most widespread anti-patterns and their REST-compliant alternatives.

Anti-Pattern Problem REST-Compliant Alternative Consequence
POST /getProduct Verb in URL, wrong method GET /products/42 Caching and proxies work correctly
200 for 404 Clients cannot tell the difference 404 Not Found Monitoring and retry logic work correctly
POST for update Idempotency lost PUT (full) / PATCH (partial) Safe retries possible
400 for everything No distinction between auth/validation 401 / 403 / 422 precisely Clients can react correctly
No Location header on 201 Client has to guess the URL Location: /products/43 header No follow-up request needed

9. Summary

REST-compliant API design is not dogma, but a toolbox of proven conventions that fundamentally simplifies caching, retry logic and client implementation. Model resources using nouns in URLs. Use HTTP verbs according to their semantic meaning, not as convention. Guarantee idempotency for GET, PUT and DELETE, and retrofit it for POST via idempotency keys. Assign status codes precisely: 201 with a Location header, 422 for validation errors, 409 for conflicts. Use sub-resources for complex actions.

The practical benefit of these principles shows up above all in collaboration between teams: an API design that correctly uses HTTP semantics is immediately understandable to experienced HTTP clients, without extra documentation for every method. Proxies, load balancers and API gateways can automatically make the correct decisions based on the verb and the URL. This reduces misconfiguration and debugging effort across the entire infrastructure.

REST API Design: The Essentials at a Glance

Resources

URLs identify things (nouns), not actions. Plural for collections. Max. 2 levels in the hierarchy. Sub-resources for relationships.

HTTP Verbs

GET: safe + idempotent. PUT: idempotent (full replace). PATCH: partial. DELETE: idempotent. POST: creates new resources, not idempotent.

Status Codes

201 + Location on create. 204 on delete. Separate 401 vs. 403. 422 for validation. 409 for conflicts. 429 for rate limits.

Idempotency

POST with an Idempotency-Key header for critical operations. Server caches the result for 24h and replays it on retry.

10. FAQ: REST API Design

1Difference between PUT vs. PATCH?
PUT: full replacement (all fields). PATCH: partial update (only sent fields). PUT is idempotent per spec, PATCH is not necessarily.
2401 vs. 403?
401: no token present, logging in helps. 403: token valid, but no access to this resource for this user.
3Why a Location header on 201?
URL of the new resource directly in the header. Client does not have to guess, no follow-up request needed. Many HTTP clients follow the Location header automatically.
4What is idempotency?
Multiple executions equal the same effect as a single one. Enables safe retries on network timeouts without duplicate orders or duplicate payments.
5Model actions like "cancel" RESTfully?
Sub-resource: POST /orders/{id}/cancellations. Or state transition: PATCH /orders/{id} with {status: cancelled}. Prefer sub-resource for complex parameters.
6400 vs. 422?
400: syntactically wrong (invalid JSON). 422: syntactically correct, but validation fails (required field missing, business rule violated).
7How deep should URL hierarchies be?
A maximum of two levels. Deeper signals a modeling problem. Prefer flat resources (/order-items/{id}).
8Content negotiation?
Client signals the desired format via the Accept header. Server responds in the matching format. 406 Not Acceptable if the format is not supported.
9Idempotency-Key for POST?
Client sends a UUID as an Idempotency-Key header. Server caches the result for 24h. A repeated request returns the cached result.
10DELETE without a body?
Many proxies ignore DELETE bodies. Parameters in the URL or query string, never in the body. HTTP allows it, infrastructure often ignores it.