REST Constraints & Richardson Maturity Model
What REST actually means, the 6 constraints, and where most APIs really sit on the maturity scale
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.
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).
Resource & URL Design — The Rules and Why
Naming, hierarchy, sub-resources, singleton resources, and e-commerce API design decisions
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".
POST /createOrder GET /getOrderById?id=123 POST /cancelOrder/123 POST /processPayment GET /getUserOrders?userId=456 POST /updateOrderStatus GET /getProductsByCategory POST /addItemToCart DELETE /removeItemFromCart
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}
| Rule | Correct | Incorrect | Why It Matters |
|---|---|---|---|
| Plural nouns | /orders, /users | /order, /user | Collection vs item distinction: /orders = collection, /orders/123 = item |
| Lowercase, hyphens | /order-items | /orderItems, /order_items | URLs are case-sensitive on most servers. Hyphens are readable. |
| No file extensions | /orders/123 | /orders/123.json | Use 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=456 | Hierarchy 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/4 | Deep 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. |
/** * 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(); } }
HTTP Methods & Status Codes — Complete Guide
Safe vs idempotent, PATCH vs PUT semantics, every status code you need to know and when
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.
| Method | Safe? | Idempotent? | When to Use | Response Body | Success Code |
|---|---|---|---|---|---|
| GET | ✅ Yes | ✅ Yes | Retrieve resource or collection. Never modify state. | Resource representation | 200 OK |
| POST | ❌ No | ❌ No | Create new resource. Process a command. Non-safe, non-idempotent → use Idempotency-Key header. | Created resource + Location header | 201 Created |
| PUT | ❌ No | ✅ Yes | Replace entire resource at known URI. If resource doesn't exist, creates it (can return 201). | Updated resource or empty | 200/204 |
| PATCH | ❌ No | ⚠️ Maybe | Partial update. Non-idempotent if patch applies relative changes ("add 5 to quantity"). Idempotent if absolute ("set quantity to 5"). | Updated resource | 200 OK |
| DELETE | ❌ No | ✅ Yes | Delete resource. Second DELETE on same resource returns 404 but that's still idempotent (server state is the same: resource gone). | Empty (204) or result message | 204 No Content |
| HEAD | ✅ Yes | ✅ Yes | Same 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 | ✅ Yes | Discover allowed methods for a resource. Browser CORS preflight sends OPTIONS. Spring handles automatically. | Allow header listing methods | 200 OK |
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.
# ─── 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" } ]
/** * 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 }
API Versioning Strategies — Complete Comparison
URI path, header, query param, content negotiation — when each is appropriate and how Spring supports them
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.
| Strategy | Example | Advantages | Disadvantages | Best For |
|---|---|---|---|---|
| URI Path Version | /v2/orders | Highly visible, easy to test in browser, easy to route in gateway, easy to log/monitor per version | Technically 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 Header | Accept: application/vnd.myapp.v2+json | Clean URLs, proper content negotiation per RFC, cacheable | Hard to test in browser/Postman, not cacheable by CDN unless Vary header set, complex routing | Mature internal APIs, clients that control headers easily |
| Custom Header | X-API-Version: 2 | Clean URLs, easy to route at gateway (SCG header predicate) | Non-standard, not cacheable by intermediaries, not part of RFC | Internal microservices where you control all clients |
| Query Parameter | /orders?version=2 | Easy to add, easy to test in browser | Query params should be for filtering, not versioning. Caching issues. | Simple versioning needs, quick deprecation flags |
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.
/* ───────────────────────────────────────────────────────────────────────── 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)); } }
/** * 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() ); } }
Pagination, Filtering & Sorting — Production Design
Offset vs cursor-based pagination, Spring Data Pageable, composite filters, multi-field sort
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.
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.
createdAt + id as a base64 string). Query becomes: WHERE (created_at, id) < (cursor_time, cursor_id) ORDER BY created_at DESC, id DESC LIMIT 20Result: 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.
/* ─── 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
/* ─── 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); } }
Idempotency — Payment API Safety & Exactly-Once Semantics
Idempotency keys, database-level deduplication, Kafka exactly-once, and retry-safe API design
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.
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.Idempotent-Replayed: true header so client knows it's a replay (useful for debugging)./* ─── 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); }
Validation & Error Handling — RFC 7807 Problem Details
Consistent error responses, Bean Validation, ProblemDetail in Spring 6, machine-readable errors
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.
{
"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" }
]
}
/* ─── 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 ) {}
OpenAPI 3 & Springdoc — Production Documentation
Auto-generated docs, custom schemas, security schemes, examples, and contract-first development
/* ─── 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); } }
HTTP Caching & ETags — Conditional Requests
Cache-Control directives, ETags, If-None-Match, If-Match, Last-Modified — full implementation
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.
/* ─── 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
Rate Limiting Per Client — Token Bucket, Redis Lua, Spring Cloud Gateway
Per-user, per-IP, per-API-key rate limiting with proper Retry-After headers and graduated throttling
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.
-- ─── 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
/* ─── 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); } }
Backward Compatibility — Evolving APIs Without Breaking Clients
Breaking vs non-breaking changes, deprecation strategy, contract testing with Pact
• 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
• 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
/* ─── 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 } }
HATEOAS — Hypermedia Links for State Machine APIs
Spring HATEOAS, HAL format, order workflow navigation, when HATEOAS adds real value
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.
/* ─── 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!
Senior Interview Q&A — Real Questions at 10 YOE Level
Questions that test design judgment, not memorization — with architect-level answers
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).