📐 System Design — API Design Week

API Design & REST Best Practices

Production-grade REST API design for senior Java developers: resource modeling, versioning strategies, idempotency keys for payment APIs, HATEOAS, OpenAPI 3, ETags, rate limiting, backward compatibility, and error design — with full Spring Boot code throughout.

Spring Boot 3.x Spring HATEOAS OpenAPI 3 / Springdoc Spring Security Hibernate Validator Redis Rate Limiting Jackson Spring Data JPA Kafka Events RFC 7807
12
Deep Sections
50+
Code Snippets
10 YOE
Depth Level
Production Focus
🏗️
1.1 The 6 REST Constraints — What They Actually Mean for Your Spring Boot APIs Core

REST (Representational State Transfer) is an architectural style, not a protocol. Roy Fielding defined 6 constraints in his 2000 dissertation. An API is only truly RESTful if it satisfies all of them — most "REST APIs" actually only satisfy the first 3 or 4, which is fine for practical use.

1. Client-Server

Separate UI concerns from data storage concerns. Client doesn't know how data is stored; server doesn't know how UI renders data. Your React frontend calling Spring Boot API already satisfies this.

2. Stateless

Each request must contain all information needed to process it. Server stores NO client session state. JWT tokens satisfy this — session data is in the token, not in server memory.

3. Cacheable

Responses must declare whether they are cacheable. Clients can reuse cached responses. Use Cache-Control, ETag, Last-Modified headers. Spring's @Cacheable + HTTP caching layers.

4. Uniform Interface

Resource identification in requests (URI), manipulation through representations, self-descriptive messages, HATEOAS. This is the hardest to fully implement. Most APIs stop at resource URIs.

5. Layered System

Client doesn't know if it's talking to origin server or intermediary (LB, CDN, Gateway). Your Spring Cloud Gateway sits between React and services — client is unaware of topology.

6. Code on Demand (Optional)

Server can extend client functionality by sending executable code (JavaScript). Optional. Rarely used in API design but technically REST allows this.

🏆 Richardson Maturity Model — Where Are Your APIs? Level 0 (HTTP Tunnel): Single endpoint, all operations via POST, like RPC over HTTP. Most SOAP services.
Level 1 (Resources): Multiple URIs, one per resource. But still using GET for everything.
Level 2 (HTTP Verbs): Proper use of GET/POST/PUT/DELETE with correct status codes. This is where most production REST APIs live.
Level 3 (Hypermedia / HATEOAS): Responses include links to related actions. Client discovers API dynamically. GitHub API, HAL-based APIs.

Senior interview answer: Aim for Level 2 in all APIs. Add Level 3 selectively for public-facing APIs where discoverability matters (API documentation via links) or complex state machines (order workflow where valid next actions change per state).
📐
2.1 URL Design Rules with E-Commerce Examples Core

The single most important rule: URIs identify resources (nouns), HTTP methods describe actions (verbs). Never put verbs in URIs. A "resource" is a thing: an order, a user, a product. It's not an action like "process" or "cancel".

❌ Bad URI Design (verb-heavy, RPC style)
POST /createOrder
GET  /getOrderById?id=123
POST /cancelOrder/123
POST /processPayment
GET  /getUserOrders?userId=456
POST /updateOrderStatus
GET  /getProductsByCategory
POST /addItemToCart
DELETE /removeItemFromCart
✅ Good URI Design (resource-oriented, noun-based)
POST   /orders
GET    /orders/123
PATCH  /orders/123  {"status": "CANCELLED"}
POST   /orders/123/payments
GET    /users/456/orders
PATCH  /orders/123  {"status": "PROCESSING"}
GET    /categories/{slug}/products
POST   /carts/me/items
DELETE /carts/me/items/{productId}
RuleCorrectIncorrectWhy It Matters
Plural nouns/orders, /users/order, /userCollection vs item distinction: /orders = collection, /orders/123 = item
Lowercase, hyphens/order-items/orderItems, /order_itemsURLs are case-sensitive on most servers. Hyphens are readable.
No file extensions/orders/123/orders/123.jsonUse Accept header for content negotiation instead
No trailing slash/orders/123/orders/123/Avoids ambiguity. Some frameworks treat them as different routes.
Hierarchy for ownership/users/456/orders/orders?userId=456Hierarchy expresses "orders owned by user". Also enables auth: verify user owns resource at path parse time.
Max 3 levels deep/orders/123/items/789/users/1/orders/2/items/3/reviews/4Deep nesting is hard to maintain. Beyond level 3, use query params or top-level resource with filter.
Singleton sub-resources/users/me/profile/users/456/profile/me is the authenticated user. Avoids exposing ID in URL, enables any user to call it.
Java
Spring Boot — E-Commerce API Design
/**
 * E-Commerce Order API — Full resource hierarchy
 * Business domain: Orders, Items, Payments, Shipments
 */
@RestController
@RequestMapping("/v2/orders")
@RequiredArgsConstructor
@Validated
public class OrderController {

    // ─── Collection Operations ──────────────────────────────────────────

    @GetMapping                         // GET /v2/orders → paginated list
    public ResponseEntity<Page<OrderSummaryDTO>> listOrders(
            @RequestParam(defaultValue = "0") @Min(0) int page,
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
            @RequestParam(required = false) OrderStatus status,
            @RequestParam(defaultValue = "createdAt,desc") String sort) {
        // See Section 5 for full pagination/filtering implementation
        return ResponseEntity.ok(orderService.listOrders(page, size, status, sort));
    }

    @PostMapping                        // POST /v2/orders → create new order
    public ResponseEntity<OrderDTO> createOrder(
            @Valid @RequestBody CreateOrderRequest request,
            @RequestHeader("Idempotency-Key") String idempotencyKey) {
        OrderDTO order = orderService.createOrder(request, idempotencyKey);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(order.getId()).toUri();
        return ResponseEntity.created(location).body(order);
        // 201 Created + Location: /v2/orders/uuid-1234 header
    }

    // ─── Single Resource Operations ─────────────────────────────────────

    @GetMapping("/{orderId}")           // GET /v2/orders/123
    public ResponseEntity<OrderDTO> getOrder(
            @PathVariable UUID orderId,
            @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
        OrderDTO order = orderService.findById(orderId);
        String etag = "\"" + order.getVersion() + "\"";
        if (etag.equals(ifNoneMatch)) {
            return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
        }
        return ResponseEntity.ok()
            .eTag(etag)
            .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS).mustRevalidate())
            .body(order);
    }

    @PatchMapping("/{orderId}")          // PATCH /v2/orders/123 → partial update
    public ResponseEntity<OrderDTO> updateOrder(
            @PathVariable UUID orderId,
            @Valid @RequestBody UpdateOrderRequest request,
            @RequestHeader("If-Match") String ifMatch) {
        // If-Match: optimistic locking — reject if resource changed since client read it
        return ResponseEntity.ok(orderService.updateOrder(orderId, request, ifMatch));
    }

    @DeleteMapping("/{orderId}")         // DELETE /v2/orders/123 → cancel/soft-delete
    public ResponseEntity<Void> cancelOrder(@PathVariable UUID orderId) {
        orderService.cancelOrder(orderId);
        return ResponseEntity.noContent().build(); // 204 No Content
    }

    // ─── Sub-Resource Operations ─────────────────────────────────────────

    @GetMapping("/{orderId}/items")      // GET /v2/orders/123/items
    public ResponseEntity<List<OrderItemDTO>> getOrderItems(@PathVariable UUID orderId) {
        return ResponseEntity.ok(orderService.getItems(orderId));
    }

    @PostMapping("/{orderId}/payments")   // POST /v2/orders/123/payments
    public ResponseEntity<PaymentDTO> initiatePayment(
            @PathVariable UUID orderId,
            @Valid @RequestBody PaymentRequest request,
            @RequestHeader("Idempotency-Key") String idempotencyKey) {
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(paymentService.process(orderId, request, idempotencyKey));
    }

    @GetMapping("/{orderId}/shipments")  // GET /v2/orders/123/shipments
    public ResponseEntity<List<ShipmentDTO>> getShipments(@PathVariable UUID orderId) {
        return ResponseEntity.ok(shipmentService.getByOrder(orderId));
    }
}

// ─── Singleton sub-resource: current user's cart ─────────────────────
@RestController
@RequestMapping("/v2/carts/me")
public class CartController {

    @GetMapping                              // GET /v2/carts/me
    public ResponseEntity<CartDTO> getMyCart(@AuthenticationPrincipal UserDetails user) {
        return ResponseEntity.ok(cartService.getCart(user.getUsername()));
    }

    @PostMapping("/items")                  // POST /v2/carts/me/items
    public ResponseEntity<CartDTO> addItem(
            @Valid @RequestBody AddCartItemRequest req,
            @AuthenticationPrincipal UserDetails user) {
        return ResponseEntity.ok(cartService.addItem(user.getUsername(), req));
    }

    @PutMapping("/items/{productId}")       // PUT /v2/carts/me/items/prod-456 (replace qty)
    public ResponseEntity<CartDTO> updateItemQuantity(
            @PathVariable UUID productId,
            @Valid @RequestBody CartItemQuantityRequest req,
            @AuthenticationPrincipal UserDetails user) {
        return ResponseEntity.ok(cartService.setQuantity(user.getUsername(), productId, req));
    }

    @DeleteMapping("/items/{productId}")    // DELETE /v2/carts/me/items/prod-456
    public ResponseEntity<Void> removeItem(
            @PathVariable UUID productId,
            @AuthenticationPrincipal UserDetails user) {
        cartService.removeItem(user.getUsername(), productId);
        return ResponseEntity.noContent().build();
    }
}
🔢
3.1 Safe vs Idempotent — The Critical Distinction (Most Developers Get Wrong) Advanced

This is one of the most important API concepts for senior interviews. Safe means the operation has no side effects on the server state (read-only). Idempotent means calling the operation multiple times with the same input produces the same server state as calling it once.

MethodSafe?Idempotent?When to UseResponse BodySuccess Code
GET✅ Yes✅ YesRetrieve resource or collection. Never modify state.Resource representation200 OK
POST❌ No❌ NoCreate new resource. Process a command. Non-safe, non-idempotent → use Idempotency-Key header.Created resource + Location header201 Created
PUT❌ No✅ YesReplace entire resource at known URI. If resource doesn't exist, creates it (can return 201).Updated resource or empty200/204
PATCH❌ No⚠️ MaybePartial update. Non-idempotent if patch applies relative changes ("add 5 to quantity"). Idempotent if absolute ("set quantity to 5").Updated resource200 OK
DELETE❌ No✅ YesDelete resource. Second DELETE on same resource returns 404 but that's still idempotent (server state is the same: resource gone).Empty (204) or result message204 No Content
HEAD✅ Yes✅ YesSame as GET but no response body. Used for checking if resource exists, getting headers (Content-Length) before downloading.None (headers only)200 OK
OPTIONS✅ Yes✅ YesDiscover allowed methods for a resource. Browser CORS preflight sends OPTIONS. Spring handles automatically.Allow header listing methods200 OK
⚠️ PATCH Idempotency Trap — Senior Interview Gotcha PATCH is not necessarily idempotent. Non-idempotent PATCH: PATCH /orders/123 {"quantity": "+1"} — calling this 3 times adds 3 to quantity. Idempotent PATCH: PATCH /orders/123 {"quantity": 5} — calling 3 times always results in quantity=5. Design your PATCH bodies to set absolute values (not deltas) wherever possible, or use Idempotency-Key headers and deduplicate on the server side.
HTTP
PUT vs PATCH — Concrete Examples
# ─── PUT: Replace entire resource ──────────────────────────────────────────
# If you PUT and omit a field, that field is cleared/nulled

PUT /v2/orders/ord-123/address HTTP/1.1
Content-Type: application/json

# Must send ALL address fields — partial = intentional null
{
  "street":    "123 Main St",
  "city":      "Bangalore",
  "state":     "Karnataka",
  "zipCode":   "560001",
  "country":   "IN",
  "landmark":  null          # explicitly clearing this
}

200 OK  # or 201 Created if it didn't exist before

# ─── PATCH: Partial update (JSON Merge Patch — RFC 7396) ───────────────────
# Only send fields you want to change. Other fields are untouched.
# null in PATCH = explicitly set to null. Absent field = leave unchanged.

PATCH /v2/orders/ord-123/address HTTP/1.1
Content-Type: application/merge-patch+json
If-Match: "abc123"  # optimistic locking ETag

{
  "zipCode": "560100"  # only update zipCode — all other fields untouched
}

200 OK

# ─── JSON Patch (RFC 6902) — for complex partial operations ────────────────

PATCH /v2/orders/ord-123 HTTP/1.1
Content-Type: application/json-patch+json

[
  { "op": "replace", "path": "/status",              "value": "CONFIRMED" },
  { "op": "add",     "path": "/tags/-",              "value": "priority"  },
  { "op": "remove",  "path": "/promotionCode"                             },
  { "op": "copy",    "path": "/billingAddress",      "from": "/shippingAddress" }
]
Java
Spring Boot — Complete Status Code Usage
/**
 * Every important status code with when to use it.
 * These are the ones that come up in senior Java interviews.
 */
@RestController
public class StatusCodeExamples {

    // 200 OK — successful GET, PUT, PATCH
    @GetMapping("/orders/{id}")
    public ResponseEntity<OrderDTO> get(@PathVariable UUID id) {
        return ResponseEntity.ok(orderService.find(id)); // 200
    }

    // 201 Created — resource was created, Location header tells client where it is
    @PostMapping("/orders")
    public ResponseEntity<OrderDTO> create(@RequestBody CreateOrderRequest req) {
        OrderDTO created = orderService.create(req);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(created.getId()).toUri();
        return ResponseEntity.created(location).body(created); // 201 + Location
    }

    // 202 Accepted — request received, processing asynchronously
    @PostMapping("/orders/{id}/export")
    public ResponseEntity<AsyncJobDTO> exportOrder(@PathVariable UUID id) {
        AsyncJobDTO job = exportService.startExport(id); // queued to Kafka
        return ResponseEntity.accepted()               // 202
            .header("Location", "/jobs/" + job.getId())  // poll for status here
            .body(job);
    }

    // 204 No Content — successful DELETE or update with no body to return
    @DeleteMapping("/orders/{id}")
    public ResponseEntity<Void> delete(@PathVariable UUID id) {
        orderService.cancel(id);
        return ResponseEntity.noContent().build(); // 204
    }

    // 206 Partial Content — for range requests (large file downloads)
    @GetMapping("/exports/{jobId}/file")
    public ResponseEntity<Resource> downloadFile(
            @PathVariable UUID jobId,
            @RequestHeader(value = "Range", required = false) String range) {
        // Spring's ResourceHttpRequestHandler handles Range natively
        return exportService.getFileResponse(jobId, range); // 200 or 206
    }

    // 304 Not Modified — client cache is fresh (with ETag)
    // (see Section 11 for full ETag implementation)

    // 400 Bad Request — validation failed, malformed JSON
    // (thrown automatically by @Valid + @ExceptionHandler)

    // 401 Unauthorized — not authenticated (missing/invalid token)
    // Spring Security handles this via AuthenticationEntryPoint

    // 403 Forbidden — authenticated but not authorized
    // Spring Security handles this via AccessDeniedHandler

    // 404 Not Found — resource doesn't exist
    public ResponseEntity<OrderDTO> getOrThrow(UUID id) {
        return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElseThrow(() -> new ResourceNotFoundException("Order not found: " + id));
    }

    // 409 Conflict — state conflict (duplicate resource, optimistic locking)
    // Throw when trying to create a resource that already exists
    // Or when ETag doesn't match (concurrent modification)

    // 410 Gone — resource existed but is permanently deleted (not coming back)
    // Better than 404 for deprecated API versions: use 410 to signal "moved on"
    @GetMapping("/v1/orders")
    public ResponseEntity<Void> v1Deprecated() {
        return ResponseEntity.status(HttpStatus.GONE)
            .header("Link", "; rel=\"successor-version\"")
            .build(); // 410 with Link header pointing to v2
    }

    // 422 Unprocessable Entity — semantically invalid (JSON valid, but business rules fail)
    // e.g. quantity=-1 parses fine but is invalid business logic

    // 429 Too Many Requests — rate limit exceeded
    // Add Retry-After header to tell client when to try again

    // 503 Service Unavailable — circuit breaker open, dependency down
    // Add Retry-After header
}
🏷️
4.1 4 Versioning Strategies — When to Use Each (with Senior-Level Trade-offs) Advanced

API versioning is one of the most debated topics in API design. There's no universally correct answer — each approach has different trade-offs. Senior engineers know when to break the "rules" based on context.

StrategyExampleAdvantagesDisadvantagesBest For
URI Path Version/v2/ordersHighly visible, easy to test in browser, easy to route in gateway, easy to log/monitor per versionTechnically breaks REST (version is not part of the resource identity). URI proliferation.Public APIs, mobile clients (hard to change headers), most common in practice
Accept HeaderAccept: application/vnd.myapp.v2+jsonClean URLs, proper content negotiation per RFC, cacheableHard to test in browser/Postman, not cacheable by CDN unless Vary header set, complex routingMature internal APIs, clients that control headers easily
Custom HeaderX-API-Version: 2Clean URLs, easy to route at gateway (SCG header predicate)Non-standard, not cacheable by intermediaries, not part of RFCInternal microservices where you control all clients
Query Parameter/orders?version=2Easy to add, easy to test in browserQuery params should be for filtering, not versioning. Caching issues.Simple versioning needs, quick deprecation flags
🎯 Senior Interview Answer: Which versioning strategy should you use? Pragmatic answer: Use URI path versioning for public REST APIs. Reason: it's universally understood, trivially testable, easy to document, and simple to route in Spring Cloud Gateway via path predicates. The "it breaks REST purity" argument is academic — most successful APIs (GitHub, Stripe, Twilio) use URI versioning.

Strategic answer: Use header versioning when: (1) you have a strict API contract requiring clean URLs, (2) you serve multiple API versions and want CDN caching (set Vary: Accept), or (3) you use content negotiation anyway (different response formats per client).

What to avoid: Query parameter versioning for production APIs — it pollutes the query string, interferes with filtering/sorting params, and signals that versioning was an afterthought.
Java
Spring Boot — All 4 Versioning Strategies Implementation
/* ─────────────────────────────────────────────────────────────────────────
   STRATEGY 1: URI PATH VERSIONING (most common, recommended for public APIs)
   ───────────────────────────────────────────────────────────────────────── */
@RestController
@RequestMapping("/v2/orders")  // version in base path
public class OrderControllerV2 {
    @GetMapping("/{id}")
    public OrderDTOV2 getOrder(@PathVariable UUID id) {
        return orderService.findV2(id); // new format: additional fields, renamed fields
    }
}

@RestController
@RequestMapping("/v1/orders")  // keep v1 alive during deprecation window
public class OrderControllerV1 {
    @GetMapping("/{id}")
    public ResponseEntity<OrderDTOV1> getOrder(@PathVariable UUID id) {
        return ResponseEntity.ok()
            .header("Deprecation", "true")                      // RFC 8594
            .header("Sunset",      "Sat, 31 Dec 2025 23:59:59 GMT") // RFC 8594 shutdown date
            .header("Link",        "; rel=\"successor-version\"")
            .body(orderService.findV1(id));
    }
}

/* ─────────────────────────────────────────────────────────────────────────
   STRATEGY 2: ACCEPT HEADER VERSIONING (pure REST, content negotiation)
   ───────────────────────────────────────────────────────────────────────── */
@RestController
@RequestMapping("/orders")  // clean URL, no version
public class OrderControllerNegotiated {

    // GET /orders/123   Accept: application/vnd.myapp.v2+json
    @GetMapping(value = "/{id}", produces = "application/vnd.myapp.v2+json")
    public OrderDTOV2 getV2(@PathVariable UUID id) {
        return orderService.findV2(id);
    }

    // GET /orders/123   Accept: application/vnd.myapp.v1+json  (or application/json fallback)
    @GetMapping(value = "/{id}",
        produces = {"application/vnd.myapp.v1+json", "application/json"})
    public OrderDTOV1 getV1(@PathVariable UUID id) {
        return orderService.findV1(id);
    }
}

/* ─────────────────────────────────────────────────────────────────────────
   STRATEGY 3: CUSTOM REQUEST HEADER (good for internal microservices)
   ───────────────────────────────────────────────────────────────────────── */
@RestController
@RequestMapping("/orders")
public class OrderControllerHeaderVersioned {

    // X-API-Version: 2 → new handler; handled in Spring Cloud Gateway too
    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public OrderDTOV2 getV2(@PathVariable UUID id) {
        return orderService.findV2(id);
    }

    @GetMapping("/{id}")  // default (v1 or latest)
    public OrderDTOV1 getDefault(@PathVariable UUID id) {
        return orderService.findV1(id);
    }
}

/* ─────────────────────────────────────────────────────────────────────────
   VERSION MAPPING CONFIG — Programmatic routing with Conditions
   ───────────────────────────────────────────────────────────────────────── */
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        // Add /api prefix to all @RestController methods automatically
        configurer.addPathPrefix("/api",
            HandlerTypePredicate.forAnnotation(RestController.class));
    }
}
Java
DTO Versioning — How to Evolve Response Shape Without Breaking Clients
/**
 * v1: Original order response
 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrderDTOV1(
    Long     id,          // v1 used Long IDs
    String   customerId,
    String   status,      // plain String in v1
    BigDecimal total,
    String   createdAt   // ISO string in v1
) {}

/**
 * v2: Breaking changes (required new major version):
 * - id changed from Long to UUID (breaking!)
 * - status changed to OrderStatus enum
 * - added: items list, address object, metadata map
 * - renamed: customerId → userId
 * - createdAt changed from String to OffsetDateTime
 *
 * Non-breaking changes (can add to v1 without version bump):
 * - Adding NEW optional fields
 * - Adding new enum values (if clients use default handling)
 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrderDTOV2(
    UUID          id,           // changed from Long → UUID (BREAKING)
    UUID          userId,       // renamed from customerId (BREAKING)
    OrderStatus   status,      // enum instead of String (BREAKING if clients use == comparison)
    MonetaryAmount total,       // added currency field
    List<OrderItemDTO> items,   // new field (additive = non-breaking)
    AddressDTO    address,      // new field
    OffsetDateTime createdAt,   // typed correctly now
    Map<String,Object> metadata // extensibility bag
) {}

// Mapper: converts from domain to V1 and V2 DTOs
@Component
public class OrderDTOMapper {
    public OrderDTOV2 toV2(Order order) {
        return new OrderDTOV2(
            order.getId(), order.getUserId(), order.getStatus(),
            order.getTotal(), order.getItems().stream().map(this::toItemDTO).toList(),
            order.getAddress(), order.getCreatedAt(), order.getMetadata()
        );
    }

    public OrderDTOV1 toV1(Order order) {
        return new OrderDTOV1(
            order.getLegacyId(),        // downcast UUID to Long (or map)
            order.getUserId().toString(), // v1 expected String
            order.getStatus().name(),    // enum → String for v1
            order.getTotal().getAmount(),// just the number, no currency
            order.getCreatedAt().toString()
        );
    }
}
5.1 Cursor vs Offset Pagination — When Each Breaks and Why Cursor Wins for Scale Expert

This is a senior-level API design question. Most candidates know ?page=2&size=20. Senior engineers know why offset pagination breaks at scale and when to use cursor-based pagination.

⚠️ Offset Pagination — What Goes Wrong Problem 1 — Performance: LIMIT 20 OFFSET 10000 forces the database to scan and discard 10,000 rows before returning 20. On a 10M-row orders table, page 500 takes 500× longer than page 1.

Problem 2 — Data shifting: If a new order is inserted between page 1 and page 2 requests, page 2 will return a duplicate of the last row from page 1 (everything shifted by one). In a real-time feed, users see duplicates.

Problem 3 — Deletion gap: If a row is deleted between page requests, a row is silently skipped.

When it's acceptable: Admin dashboards, reporting pages with low row counts (<10k), batch jobs with snapshots.
✅ Cursor Pagination — Why It Scales How it works: Instead of "skip N rows," use "give me rows after this cursor." Cursor encodes a stable position (e.g., createdAt + id as a base64 string). Query becomes: WHERE (created_at, id) < (cursor_time, cursor_id) ORDER BY created_at DESC, id DESC LIMIT 20

Result: Always O(1) index seek regardless of page number. No duplicates on insert. No gaps on delete.

When required: Infinite scroll feeds, real-time data (orders, events), high-volume APIs (Kafka consumer offsets, Stripe API, GitHub events), Snowflake large result sets.
Java + SQL
Full Cursor Pagination Implementation — Spring Boot + JPA
/* ─── Cursor-based pagination for Order list ────────────────────────────── */

// API contract:
// GET /v2/orders?limit=20                          → first page
// GET /v2/orders?limit=20&after=eyJpZCI6...        → subsequent pages
// Response includes: data[], nextCursor, hasNext

@Data
public class CursorPageRequest {
    @Min(1) @Max(100)
    private int limit = 20;
    private String after;   // base64 encoded cursor (opaque to client)

    public Optional<CursorValue> decodeCursor() {
        if (after == null) return Optional.empty();
        try {
            String json = new String(Base64.getDecoder().decode(after));
            return Optional.of(objectMapper.readValue(json, CursorValue.class));
        } catch (Exception e) {
            throw new InvalidCursorException("Cursor is malformed or expired");
        }
    }
}

public record CursorValue(OffsetDateTime createdAt, UUID id) {}

@Service
public class OrderPaginationService {

    private final EntityManager em;
    private final ObjectMapper mapper;

    public CursorPage<OrderSummaryDTO> listOrders(
            CursorPageRequest req, OrderFilter filter) {

        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Order> query = cb.createQuery(Order.class);
        Root<Order> root = query.from(Order.class);

        List<Predicate> predicates = new ArrayList<>();

        // ─── Apply user-supplied filters ──────────────────────────────────
        if (filter.getStatus() != null) {
            predicates.add(cb.equal(root.get("status"), filter.getStatus()));
        }
        if (filter.getFromDate() != null) {
            predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), filter.getFromDate()));
        }

        // ─── Cursor condition — keyset pagination ─────────────────────────
        req.decodeCursor().ifPresent(cursor -> {
            // WHERE (created_at, id) < (cursor.createdAt, cursor.id) — for DESC order
            // Using compound keyset: handles ties on createdAt correctly
            predicates.add(cb.or(
                cb.lessThan(root.get("createdAt"), cursor.createdAt()),
                cb.and(
                    cb.equal(root.get("createdAt"), cursor.createdAt()),
                    cb.lessThan(root.get("id"), cursor.id())  // UUID lexical comparison
                )
            ));
        });

        query.where(cb.and(predicates.toArray(new Predicate[0])))
             .orderBy(cb.desc(root.get("createdAt")), cb.desc(root.get("id")));

        // Fetch limit+1 to determine if there's a next page
        List<Order> results = em.createQuery(query)
            .setMaxResults(req.getLimit() + 1)
            .getResultList();

        boolean hasNext = results.size() > req.getLimit();
        List<Order> page = hasNext ? results.subList(0, req.getLimit()) : results;

        String nextCursor = null;
        if (hasNext && !page.isEmpty()) {
            Order last = page.get(page.size() - 1);
            var cursorValue = new CursorValue(last.getCreatedAt(), last.getId());
            nextCursor = Base64.getEncoder()
                .encodeToString(mapper.writeValueAsBytes(cursorValue));
        }

        return new CursorPage<>(page.stream().map(mapper::toSummaryDTO).toList(),
                                   nextCursor, hasNext, page.size());
    }
}

// Response shape:
// {
//   "data": [{...}, {...}, ...],      → 20 items
//   "nextCursor": "eyJjcmVhdGVkQXQi...",  → opaque string, null if no more
//   "hasNext": true,
//   "count": 20
// }
//
// Client usage:
// 1st request: GET /v2/orders?limit=20
// 2nd request: GET /v2/orders?limit=20&after=eyJjcmVhdGVkQXQi...
// React: useInfiniteQuery with getNextPageParam = (lastPage) => lastPage.nextCursor
Java
Advanced Filtering — Spring Data Specifications + DSL
/* ─── Filter DSL: GET /v2/orders?status=PENDING&minTotal=100&maxTotal=500
              &customerId=uuid&fromDate=2024-01-01&tags=priority,urgent ─── */

@Data
public class OrderFilter {
    private OrderStatus status;
    private BigDecimal  minTotal;
    private BigDecimal  maxTotal;
    private UUID        customerId;
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate   fromDate;
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate   toDate;
    private List<String> tags;
    private String      search; // free text search on order items
}

// Spring Data Specification — composable filter predicates
public class OrderSpecifications {

    public static Specification<Order> withFilter(OrderFilter filter) {
        return Specification
            .where(hasStatus(filter.getStatus()))
            .and(totalBetween(filter.getMinTotal(), filter.getMaxTotal()))
            .and(forCustomer(filter.getCustomerId()))
            .and(createdBetween(filter.getFromDate(), filter.getToDate()))
            .and(hasTags(filter.getTags()))
            .and(searchText(filter.getSearch()));
    }

    private static Specification<Order> hasStatus(OrderStatus status) {
        return (root, query, cb) ->
            status == null ? null : cb.equal(root.get("status"), status);
    }

    private static Specification<Order> totalBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            List<Predicate> p = new ArrayList<>();
            if (min != null) p.add(cb.greaterThanOrEqualTo(root.get("total"), min));
            if (max != null) p.add(cb.lessThanOrEqualTo(root.get("total"), max));
            return p.isEmpty() ? null : cb.and(p.toArray(new Predicate[0]));
        };
    }

    private static Specification<Order> hasTags(List<String> tags) {
        return (root, query, cb) -> {
            if (tags == null || tags.isEmpty()) return null;
            // Join to tags collection (@ElementCollection)
            Join<Order, String> tagJoin = root.join("tags", JoinType.INNER);
            query.distinct(true);
            return tagJoin.in(tags);
        };
    }

    private static Specification<Order> searchText(String text) {
        return (root, query, cb) -> {
            if (text == null || text.isBlank()) return null;
            String pattern = "%" + text.toLowerCase() + "%";
            // Search across order ID, customer name, item names
            Join<Order, OrderItem> items = root.join("items", JoinType.LEFT);
            query.distinct(true);
            return cb.or(
                cb.like(cb.lower(root.get("id").as(String.class)), pattern),
                cb.like(cb.lower(items.get("productName")), pattern)
            );
        };
    }
}

// ─── Multi-field Sorting — Safe against SQL injection ────────────────
public class SafeSortParser {

    private static final Set<String> ALLOWED_SORT_FIELDS = Set.of(
        "createdAt", "total", "status", "updatedAt"
    );

    // sort=createdAt,desc:total,asc → Sort by createdAt DESC then total ASC
    public static Sort parse(String sortParam) {
        if (sortParam == null) return Sort.by(Sort.Order.desc("createdAt"));

        List<Sort.Order> orders = Arrays.stream(sortParam.split(":"))
            .map(part -> {
                String[] tokens = part.split(",");
                String field = tokens[0].trim();
                if (!ALLOWED_SORT_FIELDS.contains(field)) {
                    throw new InvalidSortFieldException("Invalid sort field: " + field);
                }
                Sort.Direction dir = tokens.length > 1
                    ? Sort.Direction.fromString(tokens[1].trim())
                    : Sort.Direction.ASC;
                return new Sort.Order(dir, field);
            }).toList();

        return Sort.by(orders);
    }
}
🔑
6.1 Full Idempotency Key Implementation — Payment APIs That Don't Double-Charge Expert

Non-idempotent POST operations create the most dangerous failure mode in financial APIs: the network times out, client retries, and the operation executes twice. For payment APIs, this means double-charging customers. The solution is server-side idempotency using a client-provided key.

The contract: Client sends a unique Idempotency-Key header with each request. Server stores a mapping of key → response. If the key is seen again (any time within TTL), return the stored response without re-executing. Client can retry safely with the same key.

01
Client generates UUID v4 for every new operation
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 — client generates this before the first attempt. If the request succeeds on the first try, the key is used and discarded. If it fails (network timeout, 5xx), client retries with the same key.
02
Server checks key in idempotency store (Redis/DB)
Before executing business logic, server looks up the key. If found and status is COMPLETED → return cached response immediately. If found and status is PROCESSING → 409 Conflict (another request with same key is in-flight). If not found → proceed.
03
Lock the key, execute, store result
Set key status to PROCESSING (with TTL). Execute the business operation. Store result with key. Set status to COMPLETED. The lock prevents concurrent duplicate execution (race condition: two simultaneous retries with same key).
04
Return cached response on duplicate detection
If key is seen again, return exact same HTTP status code and body as the first execution. Include Idempotent-Replayed: true header so client knows it's a replay (useful for debugging).
Java
Production Idempotency — Redis-Backed with AOP Interceptor
/* ─── IdempotencyRecord stored in Redis ─────────────────────────────── */
public record IdempotencyRecord(
    String key,
    IdempotencyStatus status,      // PROCESSING | COMPLETED | FAILED
    int    httpStatus,
    String responseBodyJson,
    Map<String, String> responseHeaders,
    String requestFingerprint,     // hash of request body — detect key reuse with diff body
    Instant createdAt
) {}

/* ─── Idempotency Service ─────────────────────────────────────────────── */
@Service
public class IdempotencyService {

    private static final Duration KEY_TTL = Duration.ofHours(24); // 24h window
    private static final Duration LOCK_TTL = Duration.ofSeconds(30); // in-progress lock
    private static final String PREFIX = "idempotency:";

    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;

    public IdempotencyResult checkOrLock(String key, String requestBodyHash) {
        String redisKey = PREFIX + key;

        // Check if key already exists
        String existing = redis.opsForValue().get(redisKey);
        if (existing != null) {
            IdempotencyRecord record = deserialize(existing);

            if (!record.requestFingerprint().equals(requestBodyHash)) {
                // Same key, different body — client is misusing the key
                throw new IdempotencyKeyReuseException(
                    "Idempotency-Key was previously used with a different request body. "
                    + "Generate a new key for different requests.");
            }

            if (record.status() == IdempotencyStatus.PROCESSING) {
                throw new IdempotencyKeyInUseException(
                    "A request with this Idempotency-Key is currently being processed");
            }

            return IdempotencyResult.replay(record); // Return cached response
        }

        // Set lock: NX = only if Not eXists (atomic set-if-absent)
        Boolean locked = redis.opsForValue()
            .setIfAbsent(redisKey, processingRecord(key, requestBodyHash), LOCK_TTL);

        if (!Boolean.TRUE.equals(locked)) {
            // Race condition: another thread just set the key — retry check
            return checkOrLock(key, requestBodyHash);
        }

        return IdempotencyResult.proceed(); // First request, proceed with execution
    }

    public void storeResult(String key, int status, String body,
                              Map<String,String> headers, String requestHash) {
        String redisKey = PREFIX + key;
        IdempotencyRecord record = new IdempotencyRecord(
            key, IdempotencyStatus.COMPLETED, status, body, headers,
            requestHash, Instant.now());
        redis.opsForValue().set(redisKey, serialize(record), KEY_TTL);
    }
}

/* ─── Spring AOP — Apply idempotency to any @IdempotentOperation method ─ */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface IdempotentOperation {
    String keyHeader() default "Idempotency-Key";
}

@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class IdempotencyAspect {

    private final IdempotencyService idempotencyService;
    private final HttpServletRequest  httpRequest;
    private final ObjectMapper         mapper;

    @Around("@annotation(idempotentOp)")
    public Object handleIdempotency(ProceedingJoinPoint pjp,
                                       IdempotentOperation idempotentOp) throws Throwable {
        String key = httpRequest.getHeader(idempotentOp.keyHeader());
        if (key == null || key.isBlank()) {
            throw new MissingIdempotencyKeyException(
                "Header 'Idempotency-Key' is required for this operation");
        }

        // Fingerprint the request body to detect key reuse with different payloads
        String bodyHash = hashRequestBody(pjp.getArgs());

        IdempotencyResult result = idempotencyService.checkOrLock(key, bodyHash);
        if (result.isReplay()) {
            log.debug("Returning cached idempotency response for key: {}", key);
            return result.getCachedResponse();
        }

        try {
            Object response = pjp.proceed(); // Execute the actual business logic
            idempotencyService.storeResult(key,
                extractStatus(response), serialize(response),
                extractHeaders(response), bodyHash);
            return response;
        } catch (Exception e) {
            idempotencyService.markFailed(key, e);
            throw e;
        }
    }
}

/* ─── Usage in Payment Controller ───────────────────────────────────── */
@PostMapping("/v2/payments")
@IdempotentOperation                // AOP intercepts this
public ResponseEntity<PaymentDTO> processPayment(
        @Valid @RequestBody PaymentRequest request,
        @RequestHeader("Idempotency-Key") String idempotencyKey) {
    // Safe to call: duplicate calls return same response, no double-charge
    PaymentDTO payment = paymentService.processPayment(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(payment);
}
POST /v2/payments HTTP/1.1 Content-Type: application/json Authorization: Bearer eyJhbGc... Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 ← client-generated UUID { "orderId": "ord-123", "amount": 299.99, "currency": "INR", "method": "CARD" } ── FIRST CALL → 201 Created ── Idempotent-Replayed: false { "paymentId": "pay-456", "status": "PENDING", "amount": 299.99 } ── RETRY (same key, timeout) → 201 Created (cached) ── Idempotent-Replayed: true ← same status, same body, no double-charge { "paymentId": "pay-456", "status": "PENDING", "amount": 299.99 } ── WRONG: same key, different body → 422 Unprocessable ── { "error": "IDEMPOTENCY_KEY_REUSE", "detail": "Key was used with different request" }
🚨
7.1 RFC 7807 ProblemDetail + Global Exception Handler — Complete Production Setup Advanced

RFC 7807 "Problem Details for HTTP APIs" defines a standard JSON format for error responses. Spring 6 / Spring Boot 3 includes native support via ProblemDetail. Before this, every team invented their own error format, making API integration painful. Use the standard.

RFC 7807 Problem Details Schema
{
  "type":     "https://api.myapp.com/errors/order-not-found",   // URI identifying error type
  "title":    "Order Not Found",                                 // human-readable summary
  "status":   404,                                               // HTTP status code (must match)
  "detail":   "Order ord-123 does not exist or was deleted",    // specific to this occurrence
  "instance": "/v2/orders/ord-123",                             // URI of the failing request

  // Custom extension fields (any additional context):
  "orderId":  "ord-123",
  "timestamp": "2024-01-15T10:30:00Z",
  "traceId":  "abc123def456",                                   // for log correlation
  "errors":   [                                                  // for validation errors
    { "field": "quantity", "code": "MUST_BE_POSITIVE", "message": "must be > 0" }
  ]
}
Java
Global Exception Handler — Spring Boot 3 + RFC 7807
/* ─── Custom exception hierarchy ─────────────────────────────────────── */
public abstract class ApiException extends RuntimeException {
    private final HttpStatus status;
    private final String     errorCode; // machine-readable code for clients

    protected ApiException(HttpStatus status, String code, String message) {
        super(message);
        this.status = status;
        this.errorCode = code;
    }
}

public class ResourceNotFoundException extends ApiException {
    public ResourceNotFoundException(String message) {
        super(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND", message);
    }
}

public class BusinessRuleException extends ApiException {
    private final Map<String, Object> context;
    public BusinessRuleException(String code, String msg, Map<String,Object> context) {
        super(HttpStatus.UNPROCESSABLE_ENTITY, code, msg);
        this.context = context;
    }
}

public class OptimisticLockException extends ApiException {
    public OptimisticLockException(String resourceType, UUID id) {
        super(HttpStatus.CONFLICT, "CONCURRENT_MODIFICATION",
            resourceType + " " + id + " was modified concurrently. Retry with latest ETag.");
    }
}

/* ─── Global Exception Handler ────────────────────────────────────────── */
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleNotFound(
            ResourceNotFoundException ex, HttpServletRequest req) {
        return buildProblem(ex, req);
    }

    @ExceptionHandler(BusinessRuleException.class)
    public ResponseEntity<ProblemDetail> handleBusinessRule(
            BusinessRuleException ex, HttpServletRequest req) {
        ResponseEntity<ProblemDetail> response = buildProblem(ex, req);
        // Add business context to problem detail
        response.getBody().setProperty("context", ex.getContext());
        return response;
    }

    // MethodArgumentNotValidException is from @Valid failing
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers, HttpStatusCode status, WebRequest request) {

        ProblemDetail problem = ProblemDetail
            .forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request validation failed");
        problem.setType(URI.create("https://api.myapp.com/errors/validation-failed"));
        problem.setTitle("Validation Failed");
        problem.setProperty("timestamp", Instant.now());
        problem.setProperty("traceId", MDC.get("traceId"));

        // Collect ALL field errors (not just the first)
        List<Map<String,Object>> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(fe -> Map.of(
                "field",   fe.getField(),
                "code",    fe.getCode() != null ? fe.getCode() : "INVALID",
                "message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "",
                "rejected",fe.getRejectedValue() != null ? fe.getRejectedValue().toString() : "null"
            )).toList();

        problem.setProperty("errors", errors);
        return ResponseEntity.badRequest().body(problem);
    }

    private ResponseEntity<ProblemDetail> buildProblem(ApiException ex, HttpServletRequest req) {
        ProblemDetail problem = ProblemDetail
            .forStatusAndDetail(ex.getStatus(), ex.getMessage());
        problem.setType(URI.create("https://api.myapp.com/errors/"
            + ex.getErrorCode().toLowerCase().replace('_', '-')));
        problem.setTitle(toTitle(ex.getErrorCode()));
        problem.setInstance(URI.create(req.getRequestURI()));
        problem.setProperty("errorCode", ex.getErrorCode());
        problem.setProperty("timestamp",  Instant.now());
        problem.setProperty("traceId",    MDC.get("traceId"));
        return ResponseEntity.status(ex.getStatus()).body(problem);
    }
}

/* ─── Deep Bean Validation on Request DTOs ──────────────────────────── */
public record CreateOrderRequest(
    @NotNull  @Size(min = 1, max = 50)           List<@Valid OrderItemRequest> items,
    @NotNull  @Valid                               ShippingAddressRequest         address,
    @NotNull  @Valid                               PaymentMethodRequest           payment,
    @Positive                                      BigDecimal                     discountAmount,
    @Size(max = 50)                                String                         notes,
              @Pattern(regexp = "[A-Z]{3}")      String                         currency
) {}

public record OrderItemRequest(
    @NotNull   UUID       productId,
    @Min(1) @Max(100) int        quantity,
    @NotNull @Positive  BigDecimal priceAtOrderTime // validate price hasn't changed
) {}
📚
8.1 Springdoc OpenAPI — Full Configuration with JWT Auth, Examples, Webhooks Advanced
Java + YAML
Springdoc OpenAPI 3 — Production Setup
/* ─── pom.xml / build.gradle dependency ─────────────────────────────── */
// springdoc-openapi-starter-webmvc-ui → Swagger UI at /swagger-ui.html
// springdoc-openapi-starter-webmvc-api → JSON spec at /v3/api-docs

/* ─── application.yml ─────────────────────────────────────────────────── */
springdoc:
  api-docs:
    path: /v3/api-docs
    groups:
      enabled: true
  swagger-ui:
    path: /swagger-ui.html
    operations-sorter: method
    tags-sorter: alpha
    display-request-duration: true
    show-extensions: true
    try-it-out-enabled: true
    default-models-expand-depth: 3
  default-produces-media-type: application/json
  show-actuator: false       # hide Actuator in public docs
  packages-to-scan: com.myapp.api
  group-configs:
    - group: orders-api
      packages-to-scan: com.myapp.api.orders
    - group: users-api
      packages-to-scan: com.myapp.api.users

/* ─── OpenAPI Configuration Bean ─────────────────────────────────────── */
@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("MyApp E-Commerce API")
                .version("2.0.0")
                .description("Order management, user accounts, payments, and inventory")
                .contact(new Contact().name("Platform Team").email("api@myapp.com"))
                .license(new License().name("Proprietary")))
            .externalDocs(new ExternalDocumentation()
                .description("Developer Portal").url("https://developers.myapp.com"))
            .servers(List.of(
                new Server().url("https://api.myapp.com").description("Production"),
                new Server().url("https://staging-api.myapp.com").description("Staging"),
                new Server().url("http://localhost:8080").description("Local")))
            // JWT Bearer Auth — applies globally
            .addSecurityItem(new SecurityRequirement().addList("BearerAuth"))
            .components(new Components()
                .addSecuritySchemes("BearerAuth", new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")
                    .description("JWT token obtained from POST /v2/auth/login"))
                // Reusable parameters
                .addParameters("IdempotencyKey", new Parameter()
                    .name("Idempotency-Key")
                    .in("header")
                    .required(true)
                    .description("Client-generated UUID for idempotent operations")
                    .schema(new StringSchema().format("uuid")
                        .example("550e8400-e29b-41d4-a716-446655440000"))));
    }
}

/* ─── Controller Annotations — Rich Documentation ─────────────────────── */
@RestController
@RequestMapping("/v2/orders")
@Tag(name = "Orders", description = "Order management and lifecycle")
public class OrderController {

    @Operation(
        summary     = "Create new order",
        description = "Creates a new order. Requires Idempotency-Key header to prevent duplicate orders.",
        security    = {@SecurityRequirement(name = "BearerAuth")}
    )
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Order created",
            headers = {
                @Header(name = "Location", description = "URI of the created order"),
                @Header(name = "Idempotent-Replayed", description = "true if this is a replayed response")
            },
            content = @Content(schema = @Schema(implementation = OrderDTO.class),
                examples = @ExampleObject(value = """
                    {"id":"ord-123","status":"PENDING","total":{"amount":299.99,"currency":"INR"}}
                    """))),
        @ApiResponse(responseCode = "400", description = "Validation failed",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
        @ApiResponse(responseCode = "409", description = "Duplicate idempotency key with different body"),
        @ApiResponse(responseCode = "422", description = "Business rule violation (e.g. item out of stock)")
    })
    @PostMapping
    public ResponseEntity<OrderDTO> createOrder(
            @Parameter(description = "Order details", required = true)
            @Valid @RequestBody CreateOrderRequest request,
            @Parameter(ref = "#/components/parameters/IdempotencyKey")
            @RequestHeader("Idempotency-Key") String idempotencyKey) {
        return ResponseEntity.created(location).body(order);
    }
}
9.1 ETags for Cache Validation and Optimistic Concurrency Control Advanced

ETags (Entity Tags) serve two purposes: (1) Cache validation — client can ask "has this changed?" without re-downloading. (2) Optimistic locking — client includes ETag in If-Match header on updates; server rejects if another client modified in between.

Weak vs Strong ETags: Strong ETag ("abc123") means byte-for-byte identical. Weak ETag (W/"abc123") means semantically equivalent but may differ in whitespace/ordering. Use strong ETags for conditional GET+range requests. Either works for conditional PUT/PATCH.

# ─── Cache validation flow ──────────────────────────────────────────────── # Client: First request — gets resource + ETag GET /v2/orders/ord-123 HTTP/1.1 HTTP/1.1 200 OK ETag: "v3" ← version number or MD5 of response body Cache-Control: max-age=60, must-revalidate Last-Modified: Mon, 15 Jan 2024 10:30:00 GMT { "id": "ord-123", "status": "PENDING", "total": 299.99 } # Client: Conditional GET (after cache expires) — include ETag GET /v2/orders/ord-123 HTTP/1.1 If-None-Match: "v3" ← "only send body if ETag has changed" HTTP/1.1 304 Not Modified ← No body, no bandwidth! Client uses cached version ETag: "v3" # ─── Optimistic locking for updates ─────────────────────────────────────── # Client reads order → gets ETag "v3" PATCH /v2/orders/ord-123 HTTP/1.1 If-Match: "v3" ← "only update if STILL at version 3" { "notes": "Please gift wrap" } HTTP/1.1 200 OK ETag: "v4" ← new version after update # If another client already updated to v4 before us: HTTP/1.1 412 Precondition Failed { "type": "...", "title": "Precondition Failed", "detail": "Resource version mismatch. Fetch latest and retry." }
Java
Spring Boot — ETag + Cache-Control Full Implementation
/* ─── ShallowEtagHeaderFilter — automatic ETag from response body ──────
   Add to Spring Security filter chain or as @Bean (simple but less control) */
@Bean
public ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
    return new ShallowEtagHeaderFilter();
    // Automatically computes MD5 of response body as ETag
    // Handles If-None-Match: returns 304 if ETag matches
    // Works automatically — but re-executes controller on every request
}

/* ─── Deep ETag: use entity version number (better performance) ─────── */
@GetMapping("/{orderId}")
public ResponseEntity<OrderDTO> getOrder(
        @PathVariable UUID orderId,
        @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {

    Order order = orderService.findOrThrow(orderId);

    // ETag from DB version (JPA @Version field) — O(1), no body serialization
    String etag = "\"" + order.getVersion() + "\"";

    // Conditional GET: return 304 if client has current version
    if (etag.equals(ifNoneMatch)) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
    }

    return ResponseEntity.ok()
        .eTag(etag)                                        // ETag header
        .lastModified(order.getUpdatedAt())                // Last-Modified header
        .cacheControl(CacheControl
            .maxAge(60, TimeUnit.SECONDS)                 // cache 60s
            .mustRevalidate()                              // after 60s, revalidate with server
            .noTransform())                                // don't let CDN modify body
        .body(orderMapper.toDTO(order));
}

/* ─── PATCH with If-Match: optimistic locking ───────────────────────── */
@PatchMapping("/{orderId}")
public ResponseEntity<OrderDTO> patchOrder(
        @PathVariable UUID orderId,
        @Valid @RequestBody PatchOrderRequest req,
        @RequestHeader("If-Match") String ifMatch) {

    Order current = orderService.findOrThrow(orderId);
    String currentEtag = "\"" + current.getVersion() + "\"";

    // Reject if client's ETag doesn't match current version
    if (!currentEtag.equals(ifMatch)) {
        ProblemDetail problem = ProblemDetail
            .forStatusAndDetail(HttpStatus.PRECONDITION_FAILED,
                "Order was modified since you last fetched it. "
                + "Re-fetch (current ETag: " + currentEtag + ") and retry.");
        problem.setProperty("currentEtag", currentEtag);
        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).body(problem);
    }

    Order updated = orderService.patch(orderId, req);
    return ResponseEntity.ok()
        .eTag("\"" + updated.getVersion() + "\"")
        .body(orderMapper.toDTO(updated));
}

/* ─── Cache-Control directives quick reference ──────────────────────── */
// max-age=3600          : cache 1 hour (browser and shared caches)
// s-maxage=600          : CDN/proxy cache 10min (overrides max-age for shared)
// private               : only browser cache (no CDN) — use for user-specific data
// public                : any cache can store (CDN, proxy, browser)
// no-cache              : always revalidate with server (ETag) — not "don't cache"!
// no-store              : never cache (sensitive data: passwords, payment details)
// must-revalidate       : after max-age, MUST revalidate before serving stale
// stale-while-revalidate: serve stale while fetching fresh in background (low latency)
// immutable             : never revalidate (for versioned static assets: /assets/app.v2.js)

// Examples for REST API endpoints:
CacheControl.noStore();                              // payment details, auth tokens
CacheControl.noCache();                              // must revalidate but can cache
CacheControl.maxAge(60, TimeUnit.SECONDS).mustRevalidate(); // order details
CacheControl.maxAge(24, TimeUnit.HOURS).cachePublic();     // product catalog
CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate();   // user profile
🚦
10.1 Multi-Level Rate Limiting — Token Bucket with Redis Lua Script Expert

Production rate limiting is not just "max 100 requests per minute." You need multiple dimensions: per IP (bot protection), per user (fair use), per API key tier (free vs paid plans), per endpoint (POST is more expensive than GET). The token bucket algorithm is best because it allows bursting — a user can do 200 requests quickly if they haven't used their allowance recently.

Lua + Java
Redis Lua — Atomic Token Bucket Rate Limiter
-- ─── token_bucket.lua — runs atomically in Redis ─────────────────────
-- Keys: [1]=bucket key, Values: [1]=capacity, [2]=refillRate/sec, [3]=requestedTokens
local key              = KEYS[1]
local capacity         = tonumber(ARGV[1]) -- max tokens in bucket
local refill_rate      = tonumber(ARGV[2]) -- tokens added per second
local requested        = tonumber(ARGV[3]) -- tokens needed for this request
local now              = tonumber(ARGV[4]) -- current epoch seconds

local bucket           = redis.call("HMGET", key, "tokens", "last_refill")
local current_tokens   = tonumber(bucket[1]) or capacity
local last_refill      = tonumber(bucket[2]) or now

-- Calculate tokens to add based on elapsed time
local elapsed  = math.max(0, now - last_refill)
local add      = math.floor(elapsed * refill_rate)
local tokens   = math.min(capacity, current_tokens + add)

if tokens < requested then
    -- Not enough tokens: reject request
    local wait_seconds = math.ceil((requested - tokens) / refill_rate)
    redis.call("HSET", key, "tokens", tokens, "last_refill", now)
    redis.call("EXPIRE", key, math.ceil(capacity / refill_rate) + 1)
    return {0, tokens, wait_seconds}  -- allowed=false, remaining, retry_after
else
    -- Enough tokens: allow request
    tokens = tokens - requested
    redis.call("HSET", key, "tokens", tokens, "last_refill", now)
    redis.call("EXPIRE", key, math.ceil(capacity / refill_rate) + 1)
    return {1, tokens, 0}              -- allowed=true, remaining, retry_after=0
end
Java
Multi-Tier Rate Limiting — Free/Paid/Admin Plans
/* ─── Rate Limit Tiers ────────────────────────────────────────────────── */
public enum ApiTier {
    FREE   (60,   1.0,  1),  // 60 tokens, 1/sec refill, 1 per POST
    BASIC  (300,  5.0,  1),  // 300 tokens, 5/sec refill
    PRO    (1000, 20.0, 1),  // 1000 tokens, 20/sec refill
    ADMIN  (10000,100.0,1); // effectively unlimited

    final int    capacity;
    final double refillRate;
    final int    mutationCost; // POST/PUT/DELETE cost more tokens
}

/* ─── Rate Limiter Service ────────────────────────────────────────────── */
@Service
public class RateLimiterService {

    private final RedisTemplate<String, String> redis;
    private final RedisScript<List> tokenBucketScript;

    public RateLimitResult checkLimit(String userId, ApiTier tier,
                                        HttpMethod method) {
        // Different keys for different dimensions
        String userKey  = "rl:user:"   + userId;           // per user
        String globalKey= "rl:global:" + userId;           // global daily limit

        int tokens = isMutation(method) ? tier.mutationCost * 2 : 1;
        long now = Instant.now().getEpochSecond();

        List<Long> result = redis.execute(tokenBucketScript,
            List.of(userKey),
            String.valueOf(tier.capacity),
            String.valueOf(tier.refillRate),
            String.valueOf(tokens),
            String.valueOf(now));

        boolean allowed        = result.get(0) == 1L;
        long    remaining      = result.get(1);
        long    retryAfterSecs = result.get(2);

        return new RateLimitResult(allowed, remaining, tier.capacity,
            LocalDateTime.now().plusSeconds(retryAfterSecs));
    }
}

/* ─── Rate Limit Filter ─────────────────────────────────────────────────── */
@Component
@Order(10)
public class RateLimitFilter extends OncePerRequestFilter {

    private final RateLimiterService rateLimiter;

    @Override
    protected void doFilterInternal(HttpServletRequest  req,
                                       HttpServletResponse res,
                                       FilterChain         chain)
            throws IOException, ServletException {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        String         userId = extractUserId(auth);
        ApiTier        tier   = extractTier(auth);
        HttpMethod     method = HttpMethod.valueOf(req.getMethod());

        RateLimitResult result = rateLimiter.checkLimit(userId, tier, method);

        // Always add rate limit headers (RateLimit spec — IETF draft)
        res.setHeader("RateLimit-Limit",     String.valueOf(tier.capacity));
        res.setHeader("RateLimit-Remaining", String.valueOf(result.getRemaining()));
        res.setHeader("RateLimit-Reset",     result.getResetTime().toString());

        if (!result.isAllowed()) {
            res.setHeader("Retry-After", String.valueOf(result.getRetryAfterSeconds()));
            res.setContentType("application/problem+json");
            res.setStatus(429);
            res.getWriter().write("""
                {
                  "type": "https://api.myapp.com/errors/rate-limit-exceeded",
                  "title": "Too Many Requests",
                  "status": 429,
                  "detail": "Rate limit exceeded. Retry after %d seconds.",
                  "retryAfter": %d
                }
                """.formatted(result.getRetryAfterSeconds(), result.getRetryAfterSeconds()));
            return;
        }

        chain.doFilter(req, res);
    }
}
🔄
11.1 Breaking vs Non-Breaking Changes — What You Can and Cannot Change Safely Expert
✅ Non-Breaking Changes (SAFE — no version bump needed) • Add new optional fields to response body (clients ignore unknown)
• Add new optional request fields with defaults
• Add new endpoints (GET /v2/orders/summary)
• Add new enum values IF clients use default handling
• Add new error codes to documented error responses
• Change field order in JSON response
• Add new HTTP methods to existing resources
• Change caching headers (max-age, etc.)
• Fix bugs that make responses more correct
• Add pagination to previously non-paginated list endpoint
   → BUT: must keep backward-compatible default behavior
❌ Breaking Changes (REQUIRE new API version)Remove a field from response
Rename a field (customerId → userId)
Change field type (String → Integer, Long → UUID)
• Change field from nullable to required
• Add a required request field
• Change URL structure (/orders/{id} → /orders/by-id/{id})
• Change HTTP method (POST → PUT for same operation)
• Change status codes (200 → 204 for an operation)
• Remove an endpoint
• Change pagination behavior incompatibly
• Add required authentication to previously open endpoint
Java + YAML
Deprecation Strategy — RFC 8594 Sunset Header + Migration Path
/* ─── Deprecation Interceptor — adds deprecation headers to v1 responses ─ */
@Component
public class DeprecationInterceptor implements HandlerInterceptor {

    // config: map of deprecated path patterns to sunset dates
    private static final Map<String, DeprecationInfo> DEPRECATED_PATHS = Map.of(
        "/v1/", new DeprecationInfo(
            "2025-12-31T23:59:59Z",        // sunset date (RFC 3339)
            "https://api.myapp.com/v2",    // successor version
            "https://docs.myapp.com/v1-v2-migration" // migration guide
        )
    );

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res,
                                Object handler) {
        String path = req.getRequestURI();
        DEPRECATED_PATHS.entrySet().stream()
            .filter(e -> path.startsWith(e.getKey()))
            .findFirst()
            .ifPresent(e -> {
                DeprecationInfo info = e.getValue();
                // RFC 8594 headers — clients like Postman show warning badges for these
                res.setHeader("Deprecation", "true");
                res.setHeader("Sunset",      info.getSunsetDate());
                // Link header: rel=successor-version points to replacement
                res.setHeader("Link", String.format(
                    "<%s>; rel=\"successor-version\", <%s>; rel=\"deprecation\"",
                    info.getSuccessorUrl(), info.getMigrationGuideUrl()));
            });
        return true;
    }
}

/* ─── Graceful field addition — backward compatible evolution ──────── */
// v1 clients receive: {"id": "ord-123", "status": "PENDING"}
// v2 clients receive: {"id": "ord-123", "status": "PENDING", "statusDetail": {...}}
// Key: @JsonInclude(NON_NULL) + additive fields only

@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrderDTO(
    UUID        id,
    OrderStatus status,
    MoneyDTO    total,
    // These are NEW fields — null means they're absent in JSON (NON_NULL)
    // Old clients simply ignore them without breaking
    StatusDetailDTO statusDetail,  // new in v2.1 — nullable = absent for v1 clients
    List<OrderItemDTO> items,       // new in v2.0 — nullable
    @JsonProperty("_links")         // HATEOAS links — new in v2.2
    Map<String, LinkDTO> links
) {}

/* ─── Contract Testing with Pact ─────────────────────────────────────── */
// Provider-side verification test (runs in CI/CD pipeline)
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("order-service")
@PactBroker(host = "pact.myapp.internal")
public class OrderServiceContractTest {

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void pactVerificationTestTemplate(PactVerificationContext context) {
        context.verifyInteraction();
        // Verifies that order-service satisfies all contracts published by consumers
        // Fails CI if any existing consumer contract is violated by new code
    }
}
🔗
12.1 HATEOAS with Spring HATEOAS + HAL — Order State Machine Navigation Advanced

HATEOAS makes APIs self-documenting and state-aware. For an order in PENDING state, the response includes links to: confirm, cancel, add items. For an order in SHIPPED state, links are: track, request-return. The client never hardcodes what actions are available — it discovers them from the response. This is invaluable for complex state machines where valid actions change per state.

Java + JSON
Spring HATEOAS — Order State Machine with HAL
/* ─── Order resource model ───────────────────────────────────────────── */
public class OrderModel extends RepresentationModel<OrderModel> {
    private UUID        id;
    private OrderStatus status;
    private MoneyDTO    total;
    // Links are added by assembler based on current state
}

/* ─── Assembler: adds state-dependent links ──────────────────────────── */
@Component
public class OrderModelAssembler implements
        RepresentationModelAssembler<Order, OrderModel> {

    @Override
    public OrderModel toModel(Order order) {
        OrderModel model = map(order);

        // Always include self link
        model.add(linkTo(methodOn(OrderController.class)
            .getOrder(order.getId(), null)).withSelfRel());

        // State-dependent action links
        switch (order.getStatus()) {
            case DRAFT:
                model.add(linkTo(methodOn(OrderController.class)
                    .confirmOrder(order.getId())).withRel("confirm"));
                model.add(linkTo(methodOn(OrderController.class)
                    .cancelOrder(order.getId())).withRel("cancel"));
                model.add(linkTo(methodOn(OrderController.class)
                    .getOrderItems(order.getId())).withRel("items"));
                break;
            case CONFIRMED:
                model.add(linkTo(methodOn(OrderController.class)
                    .initiatePayment(order.getId(), null, null)).withRel("pay"));
                model.add(linkTo(methodOn(OrderController.class)
                    .cancelOrder(order.getId())).withRel("cancel"));
                break;
            case SHIPPED:
                model.add(linkTo(methodOn(ShipmentController.class)
                    .getShipments(order.getId())).withRel("track"));
                model.add(linkTo(methodOn(ReturnController.class)
                    .requestReturn(order.getId(), null)).withRel("request-return"));
                break;
            case DELIVERED:
                model.add(linkTo(methodOn(ReviewController.class)
                    .createReview(order.getId(), null)).withRel("review"));
                model.add(linkTo(methodOn(ReturnController.class)
                    .requestReturn(order.getId(), null)).withRel("request-return"));
                break;
            case CANCELLED:
                model.add(linkTo(methodOn(OrderController.class)
                    .createOrder(null, null)).withRel("reorder"));
                break;
        }

        // Always include user context links
        model.add(linkTo(methodOn(OrderController.class)
            .listOrders(0, 20, null, null)).withRel("orders"));

        return model;
    }
}

/* ─── Response: HAL+JSON format ──────────────────────────────────────── */
// GET /v2/orders/ord-123
// Content-Type: application/hal+json
//
// {
//   "id": "ord-123",
//   "status": "CONFIRMED",
//   "total": {"amount": 299.99, "currency": "INR"},
//   "_links": {
//     "self":    {"href": "https://api.myapp.com/v2/orders/ord-123"},
//     "pay":     {"href": "https://api.myapp.com/v2/orders/ord-123/payments"},
//     "cancel":  {"href": "https://api.myapp.com/v2/orders/ord-123"},
//     "orders":  {"href": "https://api.myapp.com/v2/orders{?page,size,status}", "templated": true}
//   }
// }
// Notice: NO "confirm" link (already confirmed), NO "track" link (not shipped yet)
// Client knows exactly what actions are currently valid without checking docs!
🎯
13.1 8 Critical API Design Interview Questions Expert

Q: How do you ensure idempotency in payment APIs? What happens when the network fails mid-request?

Answer: Client generates a UUID Idempotency-Key before the first attempt. Server atomically checks a Redis key: if exists+COMPLETED → return cached response. If PROCESSING → 409 (in-flight). If absent → set lock, execute payment, store result with 24h TTL.

The key insight is that network failures can occur at three points: (1) before server receives request — client retries safely. (2) during processing — server has lock set to PROCESSING; client retries until COMPLETED or FAILED. (3) after processing but before client receives response — client retries and gets the cached COMPLETED response. In all cases, the payment executes exactly once.

Additional resilience: Store idempotency records in the database (not just Redis) if Redis restarts during the window. Use distributed lock with Redis SETNX + TTL to prevent concurrent duplicate execution from parallel retries.


Q: Your API returns 10,000 orders. Client needs to display them in an infinite scroll. How do you design pagination?

Answer: Cursor-based pagination. GET /v2/orders?limit=20 returns first 20 + an opaque nextCursor. Next request: GET /v2/orders?limit=20&after=eyJ....

Cursor is base64-encoded {createdAt, id}. SQL query: WHERE (created_at, id) < (cursorTime, cursorId) ORDER BY created_at DESC, id DESC LIMIT 21 (21 to detect hasNext). The compound keyset handles timestamp ties correctly.

Why not offset: LIMIT 20 OFFSET 10000 causes full table scan discarding 10k rows. At 100k orders, page 5000 takes minutes. Also, insertions between pages cause duplicate rows in subsequent pages — unacceptable for real-time order feeds.


Q: You've released v1 of an API. Now you need to rename a field from customerId to userId. How do you do it without breaking existing clients?

Answer: Renaming a field is a breaking change — requires a new API version. The migration process:

1. Release v2 with userId field. Keep v1 alive with customerId. Add deprecation headers to all v1 responses (Deprecation: true, Sunset: date, Link: rel=successor-version).

2. Notify all registered API consumers with migration timeline (minimum 6 months for production APIs, 3 months if internal).

3. Monitor v1/* traffic via metrics. When v1 traffic drops to near-zero and all known clients have migrated, set v1 endpoints to return 410 Gone with a migration link.

Bridge period option: During transition, you can return BOTH fields in v1 responses (customerId AND userId with same value) — old clients still work, new clients get both. Use @JsonAlias on the input side to accept both in v1.


Q: Design a rate limiting system for an e-commerce API with multiple client tiers (free, paid, enterprise).

Answer: Token bucket algorithm with Redis Lua scripts (atomic operations). Three dimensions: per user, per endpoint cost (POST costs 2 tokens, GET costs 1), per tier capacity.

Tiers: FREE (60 req/min, burst to 120), PAID (1000 req/min, burst to 2000), ENTERPRISE (custom SLA, dedicated Redis namespace).

Always return headers: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. On 429: include Retry-After seconds. Add X-Slow-Down header at 80% capacity to give clients early warning before hard limit.

For Spring Cloud Gateway: Use built-in RequestRateLimiter filter with Redis + KeyResolver by userId (extracted from JWT). This deduplicates logic and ensures rate limiting happens at the gateway before requests hit microservices.


Q: What is the difference between 401 Unauthorized and 403 Forbidden? When does each apply?

Answer: This is misnamed in the HTTP spec, which creates constant confusion:

401 Unauthorized actually means unauthenticated — "I don't know who you are." The response MUST include a WWW-Authenticate header telling the client how to authenticate. Correct use: missing or invalid Bearer token, expired JWT, no session cookie. Spring Security handles this via AuthenticationEntryPoint.

403 Forbidden means unauthorized — "I know who you are, but you don't have permission." No hint about how to get access (since the server knows who you are, it's a deliberate denial). Correct use: authenticated user accessing another user's orders, role-based access failure (@PreAuthorize("hasRole('ADMIN')") failing). Spring Security handles via AccessDeniedHandler.

Security note: For resources where the existence is sensitive (e.g., another user's orders), return 404 instead of 403 to avoid revealing that the resource exists. Attacker probing for order IDs should see 404, not 403 (which confirms the resource exists but they can't access it).