Building REST APIs
Properly
Most Spring Boot tutorials teach you how to make a CRUD API. This section teaches you how to design, build, version, validate, and harden APIs the way senior engineers do it at production scale — from request lifecycle to global error handling to pagination contracts.
- REST Principles — What REST Actually Means
- HTTP Request Lifecycle in Spring MVC
- Controllers — @RestController Done Right
- DTOs — Why You Never Expose Entities
- Validation — Bean Validation & Custom Validators
- Global Exception Handling
- ResponseEntity & HTTP Status Codes
- Pagination, Filtering & Sorting
- API Versioning Strategies
- OpenAPI & Swagger Documentation
- Idempotency & Safe Methods
- API Design Best Practices & Anti-Patterns
REST Principles — What REST Actually Means
REST (Representational State Transfer) is not a protocol, a framework, or a specification. It is an architectural style — a set of constraints defined by Roy Fielding in his 2000 PhD dissertation. Most APIs called "REST" violate several of its constraints. Understanding the real principles helps you build APIs that are genuinely maintainable at scale.
An API is RESTful only if it satisfies: (1) Client-Server, (2) Statelessness, (3) Cacheability, (4) Uniform Interface, (5) Layered System, (6) Code on Demand (optional). Most APIs violate statelessness or uniform interface — often unknowingly.
The Constraints That Actually Matter in Practice
Statelessness means each request must contain all information needed to process it. The server holds no session state between requests. Authentication tokens, user context, pagination cursors — all must be sent with each request.
Storing user session data in HttpSession on the server breaks statelessness and makes horizontal scaling painful — every node must share session storage or use sticky sessions. JWT tokens and stateless authentication are the solution.
# WRONG — relies on server-side session state
POST /login → sets JSESSIONID cookie
GET /profile → server reads session for userId
# CORRECT — all state in the request
POST /auth/token → returns JWT
GET /users/me → Authorization: Bearer <jwt> (token contains userId)
Uniform Interface is the most important constraint. It has four sub-constraints: resource identification in requests (URIs), manipulation through representations, self-descriptive messages, and HATEOAS (hypermedia as the engine of application state).
# Resources = nouns. Operations = HTTP methods.
# WRONG — verb-based (RPC style, not REST)
GET /getUser?id=123
POST /createOrder
POST /deleteProduct/456
POST /updateUserProfile
# CORRECT — resource-based
GET /users/123
POST /orders
DELETE /products/456
PUT /users/123/profile
# Collections vs. single resources
GET /orders # list all orders (collection)
POST /orders # create a new order
GET /orders/789 # get specific order
PUT /orders/789 # full update
PATCH /orders/789 # partial update
DELETE /orders/789 # delete
GET — read, safe, idempotent, cacheable
POST — create (or action), NOT idempotent, NOT safe
PUT — full replace, idempotent
PATCH — partial update, NOT necessarily idempotent
DELETE — delete, idempotent (deleting twice = same result)
HEAD — like GET but no body (check existence, get headers)
OPTIONS — describe what methods are allowed on a resource
# Safe = no server-side state change
# Idempotent = calling N times = same result as calling once
# NEVER use GET for write operations
# GET /users/123/delete ← WRONG — crawlers and link prefetchers will delete your data
# 2xx SUCCESS
200 OK — GET/PUT/PATCH success with response body
201 Created — POST success; include Location header pointing to new resource
204 No Content — DELETE success or PUT/PATCH with no response body
# 3xx REDIRECTION
301 Moved Permanently — resource has a new permanent URI
304 Not Modified — cached version is still valid (ETag/Last-Modified match)
# 4xx CLIENT ERRORS
400 Bad Request — malformed request, validation failure
401 Unauthorized — not authenticated (missing/invalid token)
403 Forbidden — authenticated but not authorized for this resource
404 Not Found — resource does not exist
405 Method Not Allowed — tried DELETE on a read-only resource
409 Conflict — state conflict (duplicate, optimistic lock failure)
410 Gone — resource permanently deleted
422 Unprocessable Entity — semantically invalid (validation errors)
429 Too Many Requests — rate limit exceeded
# 5xx SERVER ERRORS
500 Internal Server Error — unexpected exception, something broke
502 Bad Gateway — upstream service error
503 Service Unavailable — overloaded or in maintenance
504 Gateway Timeout — upstream service timed out
A practical scale for REST compliance: Level 0 — one URI, one HTTP method (SOAP, XML-RPC). Level 1 — multiple URIs but still POST for everything. Level 2 — proper HTTP verbs + status codes (what most people call "REST"). Level 3 — HATEOAS: responses include links to related actions. Most production APIs target Level 2. Level 3 is rarely worth the complexity unless you're building a truly discoverable API.
HTTP Request Lifecycle in Spring MVC
Every HTTP request your API receives travels through a precise pipeline. Understanding this pipeline is essential for debugging, performance optimization, and building cross-cutting features like authentication, logging, and rate limiting.
Filters vs. Interceptors vs. AOP
Three different mechanisms for cross-cutting concerns — each operates at a different level of the pipeline. Choose the right one for the right job.
Servlet Filters operate at the lowest level — below Spring MVC. They see the raw HttpServletRequest and HttpServletResponse. Use for: authentication token extraction, request/response logging, CORS headers, request body caching for re-reading.
@Component
@Order(1) // lower number = earlier in filter chain
public class RequestIdFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String requestId = UUID.randomUUID().toString();
MDC.put("requestId", requestId);
response.setHeader("X-Request-Id", requestId);
try {
chain.doFilter(req, res); // proceed to next filter / DispatcherServlet
} finally {
MDC.clear();
}
}
}
HandlerInterceptors operate inside Spring MVC, after DispatcherServlet finds the handler. They have access to the handler method and model. Use for: per-endpoint authorization, timing, audit logging, locale detection.
@Component
public class PerformanceInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
req.setAttribute("startTime", System.currentTimeMillis());
return true; // return false to abort the request
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
Object handler, Exception ex) {
long start = (Long) req.getAttribute("startTime");
long elapsed = System.currentTimeMillis() - start;
log.info("REQUEST {} {} completed in {}ms — status {}",
req.getMethod(), req.getRequestURI(), elapsed, res.getStatus());
}
}
// Register it:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired PerformanceInterceptor performanceInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(performanceInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/health");
}
}
AOP Aspects operate at the Spring bean level — they wrap method calls. Best for: service-layer logging, transaction management, caching, retry logic. They work on any Spring bean, not just controllers.
@Aspect
@Component
public class ServiceAuditAspect {
@Around("execution(* com.myapp.service.*.*(..))")
public Object auditServiceCall(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getSignature().toShortString();
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed();
log.info("SERVICE {} completed in {}ms", method, System.currentTimeMillis() - start);
return result;
} catch (Exception e) {
log.error("SERVICE {} failed after {}ms: {}",
method, System.currentTimeMillis() - start, e.getMessage());
throw e;
}
}
}
Controllers — @RestController Done Right
@RestController is the entry point for every HTTP request. Getting controller design right — thin, focused, properly mapped — prevents the bloated "god controller" that many Spring Boot codebases suffer from.
A controller's job is exactly three things: (1) accept and validate the HTTP request, (2) delegate to the service layer, (3) return the HTTP response. Any business logic in a controller is a design smell. Controllers should be thin enough to fit on one screen.
Complete Controller Example
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor // Lombok: constructor injection for all final fields
@Slf4j
public class OrderController {
private final OrderService orderService;
// GET /api/v1/orders?status=PENDING&page=0&size=20
@GetMapping
public ResponseEntity<Page<OrderSummaryDto>> listOrders(
@RequestParam(required = false) OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt", direction = DESC) Pageable pageable) {
Page<OrderSummaryDto> orders = orderService.findOrders(status, pageable);
return ResponseEntity.ok(orders);
}
// GET /api/v1/orders/123
@GetMapping("/{id}")
public ResponseEntity<OrderDetailDto> getOrder(@PathVariable Long id) {
return orderService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/v1/orders
@PostMapping
public ResponseEntity<OrderDetailDto> createOrder(
@Valid @RequestBody CreateOrderRequest request,
@AuthenticationPrincipal UserDetails currentUser) {
OrderDetailDto created = orderService.createOrder(request, currentUser.getUsername());
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created); // 201 Created
}
// PATCH /api/v1/orders/123/status
@PatchMapping("/{id}/status")
public ResponseEntity<OrderDetailDto> updateStatus(
@PathVariable Long id,
@Valid @RequestBody UpdateOrderStatusRequest request) {
OrderDetailDto updated = orderService.updateStatus(id, request.getStatus());
return ResponseEntity.ok(updated);
}
// DELETE /api/v1/orders/123
@DeleteMapping("/{id}")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancelOrder(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
}
Request Parameter Binding
@RestController
@RequestMapping("/api/v1")
public class BindingExamplesController {
// @PathVariable — from the URL path
// GET /products/42
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable Long id) { ... }
// @RequestParam — from query string
// GET /search?q=laptop&category=electronics&page=0
@GetMapping("/search")
public Page<Product> search(
@RequestParam String q,
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "0") int page) { ... }
// @RequestBody — from request body (JSON → object via Jackson)
// POST /products body: {"name":"Laptop","price":999.99}
@PostMapping("/products")
public Product create(@Valid @RequestBody CreateProductRequest body) { ... }
// @RequestHeader — from HTTP headers
@GetMapping("/data")
public Data getData(
@RequestHeader("X-Api-Key") String apiKey,
@RequestHeader(value = "Accept-Language", required = false) String locale) { ... }
// @CookieValue — from cookies
@GetMapping("/me")
public User getUser(@CookieValue(value = "session", required = false) String session) { ... }
// @ModelAttribute — from form data (multipart/form-data)
@PostMapping("/upload")
public String upload(@ModelAttribute UploadForm form) { ... }
// Multiple path variables
// GET /users/123/orders/456
@GetMapping("/users/{userId}/orders/{orderId}")
public Order getUserOrder(@PathVariable Long userId, @PathVariable Long orderId) { ... }
}
Content Negotiation
@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {
// Accept different input formats
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public Report createFromJson(@RequestBody ReportRequest request) { ... }
// Return different output formats
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public Report getAsJson(@PathVariable Long id) { ... }
@GetMapping(value = "/{id}/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> getAsPdf(@PathVariable Long id) {
byte[] pdf = reportService.generatePdf(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report-" + id + ".pdf\"")
.body(pdf);
}
// File upload
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadResult upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) throw new BadRequestException("File is empty");
if (file.getSize() > 10 * 1024 * 1024) throw new BadRequestException("File too large (max 10MB)");
return storageService.store(file);
}
}
DTOs — Why You Never Expose Entities
One of the most common beginner mistakes in Spring Boot is returning JPA entities directly from REST controllers. This seems convenient but creates serious problems in production. Understanding why DTOs exist — and how to use them correctly — is a mark of a mature backend engineer.
Why Entities Must Not Be API Contracts
Security
Entities often contain sensitive fields (password hashes, internal flags, audit columns). Returning the entity exposes all of them unless you remember to annotate each one with @JsonIgnore — easy to forget.
Lazy Loading Explosions
Jackson serializing a JPA entity with lazy relationships will trigger N+1 queries or throw LazyInitializationException — depending on whether the session is still open.
API Stability
Database schema changes (rename a column, add a field) immediately change your API contract — breaking all clients. DTOs decouple your storage model from your API model.
Shape Flexibility
Different endpoints need different shapes. A list endpoint needs a summary DTO (5 fields). A detail endpoint needs a full DTO (30 fields). You can't have both with one entity.
DTO Patterns
// Request DTOs: what the client sends to us
// Rule: validate EVERYTHING at the boundary — trust nothing from clients
public record CreateOrderRequest(
@NotBlank(message = "Customer ID is required")
String customerId,
@NotEmpty(message = "Order must contain at least one item")
@Valid // cascade validation into list elements
List<OrderItemRequest> items,
@NotNull
@Valid
ShippingAddressRequest shippingAddress,
@Size(max = 500)
String notes
) {}
public record OrderItemRequest(
@NotNull Long productId,
@Min(value = 1, message = "Quantity must be at least 1")
@Max(value = 100, message = "Cannot order more than 100 of one item")
int quantity
) {}
public record ShippingAddressRequest(
@NotBlank String street,
@NotBlank String city,
@NotBlank @Size(min = 2, max = 2) String stateCode,
@NotBlank @Pattern(regexp = "\\d{5}(-\\d{4})?", message = "Invalid ZIP code") String zip,
@NotBlank @Size(min = 2, max = 2) String countryCode
) {}
// Response DTOs: what we send back to clients
// Design for the client's needs, not your database schema
// Summary DTO — for list endpoints (minimal data, fast serialization)
public record OrderSummaryDto(
Long id,
String status,
BigDecimal total,
int itemCount,
LocalDateTime createdAt
) {}
// Detail DTO — for single-item endpoints (full data)
public record OrderDetailDto(
Long id,
String status,
CustomerDto customer,
List<OrderItemDto> items,
ShippingAddressDto shippingAddress,
BigDecimal subtotal,
BigDecimal tax,
BigDecimal total,
String notes,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime estimatedDelivery
) {}
// Nested DTOs
public record OrderItemDto(
Long productId,
String productName,
String sku,
int quantity,
BigDecimal unitPrice,
BigDecimal lineTotal
) {}
// Audit DTO — for admin endpoints (includes internal fields)
public record OrderAuditDto(
Long id,
String status,
String processedBy,
String fraudScore,
List<AuditEventDto> auditTrail
) {}
// Java records are immutable by design — perfect for DTOs
// Compact syntax, auto-generated equals/hashCode/toString
// Simple record DTO
public record UserDto(Long id, String name, String email, String role) {}
// Record with custom logic
public record ProductDto(
Long id,
String name,
BigDecimal price,
String currency,
boolean inStock,
int stockCount
) {
// Compact constructor for normalization
public ProductDto {
Objects.requireNonNull(name, "name must not be null");
name = name.trim(); // normalize on construction
if (price.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Price cannot be negative");
}
}
// Computed fields (not serialized unless you add @JsonProperty)
public String displayPrice() {
return currency + " " + price.setScale(2, RoundingMode.HALF_UP);
}
}
// Jackson serializes records perfectly — constructor parameters become JSON fields
// { "id": 1, "name": "Laptop", "price": 999.99, "currency": "USD",
// "inStock": true, "stockCount": 15 }
// ── Option 1: Manual mapping (simple, explicit, no magic) ──────────
@Service
public class OrderMapper {
public OrderDetailDto toDetailDto(Order order) {
return new OrderDetailDto(
order.getId(),
order.getStatus().name(),
toCustomerDto(order.getCustomer()),
order.getItems().stream().map(this::toItemDto).toList(),
toAddressDto(order.getShippingAddress()),
order.getSubtotal(),
order.getTax(),
order.getTotal(),
order.getNotes(),
order.getCreatedAt(),
order.getUpdatedAt(),
order.getEstimatedDelivery()
);
}
// ... nested mappers
}
// ── Option 2: MapStruct (code-generated, compile-time safe) ────────
// pom.xml: org.mapstruct:mapstruct + org.mapstruct:mapstruct-processor
@Mapper(componentModel = "spring") // makes it a Spring bean
public interface OrderMapper {
@Mapping(source = "status", target = "status",
qualifiedByName = "statusToString")
@Mapping(source = "items", target = "items")
OrderDetailDto toDetailDto(Order order);
OrderSummaryDto toSummaryDto(Order order);
@Named("statusToString")
default String statusToString(OrderStatus status) {
return status.name();
}
List<OrderSummaryDto> toSummaryDtoList(List<Order> orders);
}
// MapStruct generates the implementation at compile time — zero runtime overhead
For simple projects: put mapping methods in the service layer. For complex projects: dedicated *Mapper classes or interfaces (MapStruct). Never put mapping in the controller — it violates single responsibility. Never put it in the entity — it creates a dependency from your domain model to your API model.
Validation — Bean Validation & Custom Validators
Input validation is a non-negotiable security and reliability requirement. Spring Boot integrates with Bean Validation (JSR-380 / Hibernate Validator) to provide declarative, annotation-based validation that runs before your controller method body executes.
Standard Validation Annotations
public record CreateUserRequest(
// String validators
@NotNull // field must not be null
@NotBlank // not null AND not empty/whitespace (use this for strings)
@NotEmpty // not null AND not empty (allows whitespace)
@Size(min = 2, max = 50) // string length range
@Pattern(regexp = "^[a-zA-Z]+$") // regex pattern
@Email // valid email format
String name,
// Numeric validators
@Min(0) // minimum value (inclusive)
@Max(150) // maximum value (inclusive)
@Positive // must be > 0
@PositiveOrZero // must be >= 0
@Negative // must be < 0
@Digits(integer = 10, fraction = 2) // numeric format
@DecimalMin("0.01") // decimal minimum
BigDecimal price,
// Date validators
@Past // date must be in the past
@PastOrPresent
@Future // date must be in the future
@FutureOrPresent
LocalDate birthDate,
// Collection validators
@NotEmpty
@Size(min = 1, max = 10)
@Valid // cascade validation into list elements
List<OrderItemRequest> items,
// Boolean
@AssertTrue(message = "Terms must be accepted")
boolean termsAccepted
) {}
Triggering Validation
// @Valid — standard JSR-380 — triggers validation on the annotated parameter
@PostMapping
public ResponseEntity<UserDto> create(@Valid @RequestBody CreateUserRequest request) {
// If validation fails, MethodArgumentNotValidException is thrown BEFORE this line
}
// @Valid on path/query params — needs @Validated on the class
@RestController
@Validated // enables method-level validation (for @RequestParam, @PathVariable)
public class UserController {
@GetMapping("/{id}")
public User getUser(@PathVariable @Positive Long id) {
// Validates that id > 0 — throws ConstraintViolationException if not
}
@GetMapping
public Page<User> list(
@RequestParam @Min(0) int page,
@RequestParam @Min(1) @Max(100) int size) { ... }
}
// @Validated with groups — validate different constraints for different operations
public interface OnCreate {}
public interface OnUpdate {}
public class UserRequest {
@Null(groups = OnCreate.class) // id must be null when creating
@NotNull(groups = OnUpdate.class) // id must be set when updating
Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
String name;
}
@PostMapping
public ResponseEntity<?> create(@Validated(OnCreate.class) @RequestBody UserRequest req) { ... }
@PutMapping("/{id}")
public ResponseEntity<?> update(@Validated(OnUpdate.class) @RequestBody UserRequest req) { ... }
Custom Validators
// Step 1: Define the annotation
@Documented
@Constraint(validatedBy = UniqueEmailValidator.class)
@Target({ FIELD, PARAMETER })
@Retention(RUNTIME)
public @interface UniqueEmail {
String message() default "Email address is already registered";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Step 2: Implement the validator
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
@Autowired
private UserRepository userRepository;
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null) return true; // let @NotNull handle null check
return !userRepository.existsByEmailIgnoreCase(email);
}
}
// Step 3: Use it
public record RegisterRequest(
@NotBlank @Email @UniqueEmail String email,
@NotBlank @Size(min = 8) String password
) {}
// Step 4: Cross-field validation via class-level constraint
@PasswordMatch // custom class-level constraint
public record ChangePasswordRequest(
@NotBlank String currentPassword,
@NotBlank @Size(min = 8) String newPassword,
@NotBlank String confirmPassword
) {}
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target(TYPE)
@Retention(RUNTIME)
public @interface PasswordMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, ChangePasswordRequest> {
@Override
public boolean isValid(ChangePasswordRequest req, ConstraintValidatorContext ctx) {
return req.newPassword().equals(req.confirmPassword());
}
}
@RequestBody parameter with @Valid, but validation fails. What exception does Spring throw, and where should you handle it?@Valid on a @RequestBody throws MethodArgumentNotValidException. @Validated on @RequestParam/@PathVariable throws ConstraintViolationException. Both should be caught in a @ControllerAdvice class and mapped to a structured 400 Bad Request response — never let them bubble up as a 500.
Global Exception Handling
Unhandled exceptions that leak raw stack traces to API clients are a security vulnerability and a terrible developer experience. A production API needs a centralized, consistent error handling strategy that maps every exception to a structured, meaningful HTTP response.
The Problem with No Global Handler
# Without global exception handling, Spring's default error response is:
{
"timestamp": "2024-01-15T10:30:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/orders/999"
}
# No detail about what went wrong. No validation errors. No error codes.
# Useless to API clients. Hides bugs in development.
# With a proper @ControllerAdvice:
{
"status": 404,
"error": "NOT_FOUND",
"message": "Order with id 999 was not found",
"path": "/api/orders/999",
"timestamp": "2024-01-15T10:30:00Z",
"traceId": "abc123def456"
}
Production-Grade Global Exception Handler
// 1. Standardized error response structure
public record ApiError(
int status,
String error,
String message,
String path,
Instant timestamp,
String traceId,
List<FieldError> fieldErrors // for validation failures
) {
public record FieldError(String field, String message, Object rejectedValue) {}
}
// 2. Custom exception hierarchy
public class AppException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
public AppException(HttpStatus status, String errorCode, String message) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
}
public class ResourceNotFoundException extends AppException {
public ResourceNotFoundException(String resource, Object id) {
super(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND",
resource + " with id " + id + " was not found");
}
}
public class BusinessRuleException extends AppException {
public BusinessRuleException(String message) {
super(HttpStatus.CONFLICT, "BUSINESS_RULE_VIOLATION", message);
}
}
// 3. The global handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// Validation failures — @Valid on @RequestBody
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ApiError.FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(e -> new ApiError.FieldError(
e.getField(), e.getDefaultMessage(), e.getRejectedValue()))
.toList();
ApiError error = new ApiError(400, "VALIDATION_FAILED",
"Request validation failed", request.getRequestURI(),
Instant.now(), MDC.get("requestId"), fieldErrors);
return ResponseEntity.badRequest().body(error);
}
// Validation failures — @Validated on @RequestParam/@PathVariable
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiError> handleConstraintViolation(
ConstraintViolationException ex, HttpServletRequest request) {
List<ApiError.FieldError> fieldErrors = ex.getConstraintViolations().stream()
.map(v -> new ApiError.FieldError(
v.getPropertyPath().toString(), v.getMessage(), v.getInvalidValue()))
.toList();
return ResponseEntity.badRequest().body(new ApiError(
400, "CONSTRAINT_VIOLATION", "Constraint violation",
request.getRequestURI(), Instant.now(), MDC.get("requestId"), fieldErrors));
}
// Our custom application exceptions
@ExceptionHandler(AppException.class)
public ResponseEntity<ApiError> handleAppException(
AppException ex, HttpServletRequest request) {
ApiError error = new ApiError(ex.getStatus().value(), ex.getErrorCode(),
ex.getMessage(), request.getRequestURI(),
Instant.now(), MDC.get("requestId"), List.of());
return ResponseEntity.status(ex.getStatus()).body(error);
}
// Malformed JSON body
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiError> handleUnreadableBody(
HttpMessageNotReadableException ex, HttpServletRequest request) {
return ResponseEntity.badRequest().body(new ApiError(
400, "MALFORMED_REQUEST", "Request body is malformed or unreadable",
request.getRequestURI(), Instant.now(), MDC.get("requestId"), List.of()));
}
// Wrong HTTP method
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiError> handleMethodNotSupported(
HttpRequestMethodNotSupportedException ex, HttpServletRequest request) {
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(new ApiError(
405, "METHOD_NOT_ALLOWED", ex.getMessage(),
request.getRequestURI(), Instant.now(), MDC.get("requestId"), List.of()));
}
// Catch-all — unexpected exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleGeneric(
Exception ex, HttpServletRequest request) {
// Log full stack trace for unexpected errors
log.error("Unhandled exception for request {} {}: {}",
request.getMethod(), request.getRequestURI(), ex.getMessage(), ex);
// Never expose exception details to clients
return ResponseEntity.internalServerError().body(new ApiError(
500, "INTERNAL_ERROR",
"An unexpected error occurred. Please try again or contact support.",
request.getRequestURI(), Instant.now(), MDC.get("requestId"), List.of()));
}
}
The catch-all handler for Exception.class must never include the exception message or stack trace in the response. Stack traces reveal your internal architecture — class names, package structure, library versions. This is a security information leak. Log the full exception server-side, return only a safe generic message to the client.
ResponseEntity & HTTP Status Codes
ResponseEntity gives you complete control over the HTTP response — status code, headers, and body. Knowing when to use it versus just returning a plain object is an important API design decision.
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
// Return plain object — Spring adds 200 OK automatically
@GetMapping("/{id}")
public ProductDto getProduct(@PathVariable Long id) {
return productService.findById(id); // throws 404 exception if not found
}
// Explicit 200 with body
@GetMapping
public ResponseEntity<Page<ProductDto>> list(Pageable pageable) {
return ResponseEntity.ok(productService.findAll(pageable));
}
// 201 Created with Location header (REST standard for POST)
@PostMapping
public ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest req) {
ProductDto created = productService.create(req);
URI location = MvcUriComponentsBuilder
.fromMethodName(ProductController.class, "getProduct", created.id())
.build().toUri();
return ResponseEntity.created(location).body(created);
}
// 204 No Content for successful DELETE / actions with no return value
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
productService.delete(id);
return ResponseEntity.noContent().build();
}
// Conditional response with ETag caching support
@GetMapping("/{id}")
public ResponseEntity<ProductDto> getWithEtag(
@PathVariable Long id, WebRequest webRequest) {
ProductDto product = productService.findById(id);
String etag = "\"" + product.version() + "\"";
// Returns 304 Not Modified if client has current version
if (webRequest.checkNotModified(etag)) {
return null; // Spring MVC handles 304 response
}
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS))
.body(product);
}
// Adding custom response headers
@PostMapping("/{id}/export")
public ResponseEntity<byte[]> export(@PathVariable Long id) {
byte[] data = productService.exportCsv(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "text/csv")
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"product-" + id + ".csv\"")
.header("X-Export-Timestamp", Instant.now().toString())
.body(data);
}
}
Return a plain object when the status is always 200 and you don't need custom headers. Use ResponseEntity when you need: a non-200 status code (201, 204), custom headers (Location, ETag, Content-Disposition), conditional responses, or different status codes on different code paths.
Pagination, Filtering & Sorting
Returning unbounded lists from APIs is one of the most common production mistakes. A table with 10 million rows returned in one response will crash the database, overwhelm the server, time out the client, and ruin your weekend on-call. Pagination is not optional — it is a fundamental API contract.
Spring Data Pageable Integration
// Controller — Spring automatically resolves Pageable from query params
@GetMapping
public ResponseEntity<Page<OrderSummaryDto>> list(
@PageableDefault(
size = 20, // default page size
sort = "createdAt", // default sort field
direction = DESC // default sort direction
) Pageable pageable) {
// Query params: ?page=0&size=20&sort=createdAt,desc&sort=id,asc
Page<OrderSummaryDto> page = orderService.findAll(pageable);
return ResponseEntity.ok(page);
}
// Response shape from Spring Data:
// {
// "content": [...],
// "pageable": { "pageNumber": 0, "pageSize": 20 },
// "totalElements": 1542,
// "totalPages": 78,
// "last": false,
// "first": true,
// "size": 20,
// "number": 0,
// "sort": { "sorted": true, "ascending": false }
// }
// Repository — accepts Pageable directly
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
Page<Order> findByCustomerId(Long customerId, Pageable pageable);
}
// Service
public Page<OrderSummaryDto> findOrders(OrderStatus status, Pageable pageable) {
Page<Order> orders = (status != null)
? orderRepository.findByStatus(status, pageable)
: orderRepository.findAll(pageable);
return orders.map(orderMapper::toSummaryDto); // map preserves pagination metadata
}
Dynamic Filtering with Spring Data Specifications
// Query filter DTO
public record OrderFilterRequest(
OrderStatus status,
Long customerId,
BigDecimal minTotal,
BigDecimal maxTotal,
LocalDate fromDate,
LocalDate toDate,
String searchQuery // searches customer name or order notes
) {}
// Specification builder
public class OrderSpecifications {
public static Specification<Order> withFilter(OrderFilterRequest filter) {
return Specification.where(hasStatus(filter.status()))
.and(hasCustomer(filter.customerId()))
.and(totalBetween(filter.minTotal(), filter.maxTotal()))
.and(createdBetween(filter.fromDate(), filter.toDate()))
.and(matchesSearch(filter.searchQuery()));
}
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) -> {
if (min == null && max == null) return null;
if (min == null) return cb.lessThanOrEqualTo(root.get("total"), max);
if (max == null) return cb.greaterThanOrEqualTo(root.get("total"), min);
return cb.between(root.get("total"), min, max);
};
}
private static Specification<Order> matchesSearch(String search) {
return (root, query, cb) -> {
if (search == null || search.isBlank()) return null;
String like = "%" + search.toLowerCase() + "%";
return cb.or(
cb.like(cb.lower(root.join("customer").get("name")), like),
cb.like(cb.lower(root.get("notes")), like)
);
};
}
}
// Repository — extend JpaSpecificationExecutor
public interface OrderRepository
extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {}
// Service
public Page<OrderSummaryDto> findWithFilter(OrderFilterRequest filter, Pageable pageable) {
Specification<Order> spec = OrderSpecifications.withFilter(filter);
return orderRepository.findAll(spec, pageable).map(orderMapper::toSummaryDto);
}
// Controller — bind filter from query params
@GetMapping
public ResponseEntity<Page<OrderSummaryDto>> list(
OrderFilterRequest filter, // Spring auto-binds from query params
@PageableDefault(size = 20) Pageable pageable) {
return ResponseEntity.ok(orderService.findWithFilter(filter, pageable));
}
// Query: GET /orders?status=PENDING&minTotal=100&fromDate=2024-01-01&page=0&size=20
Cursor-Based Pagination (High-Scale Alternative)
// Offset-based pagination (Spring Data default) has a problem:
// SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 10000
// The database scans 10,020 rows to return 20. Slow at high offsets.
// Cursor-based pagination: always fast regardless of depth
// "Give me 20 orders created before this cursor"
public record CursorPage<T>(
List<T> items,
String nextCursor, // opaque cursor for next page
String prevCursor, // opaque cursor for prev page
boolean hasMore,
int count
) {}
@GetMapping
public ResponseEntity<CursorPage<OrderSummaryDto>> listWithCursor(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Max(100) int limit) {
// Decode cursor (base64-encoded timestamp + id)
LocalDateTime cursorTime = cursor != null
? decodeCursor(cursor) : LocalDateTime.now();
// Single query, no offset, always uses index
List<Order> orders = orderRepository.findBeforeCursor(cursorTime, limit + 1);
boolean hasMore = orders.size() > limit;
if (hasMore) orders = orders.subList(0, limit);
String nextCursor = hasMore ? encodeCursor(orders.getLast()) : null;
List<OrderSummaryDto> dtos = orders.stream().map(orderMapper::toSummaryDto).toList();
return ResponseEntity.ok(new CursorPage<>(dtos, nextCursor, cursor, hasMore, dtos.size()));
}
// Query: GET /orders?cursor=eyJpZCI6MTIzfQ&limit=20
Never allow unlimited page sizes. A client requesting size=1000000 will load a million rows into memory, blow your JVM heap, and take down your service. Always validate: @Max(100) int size. A reasonable production cap is 100–200 items per page. Use cursor pagination for large datasets.
API Versioning Strategies
APIs are promises. Once you ship a contract, clients depend on it. API versioning is how you evolve your API without breaking existing clients. There is no perfect strategy — each has tradeoffs that depend on your consumer base, team structure, and operational maturity.
URI Path Versioning is the most common approach. Version is in the URL: /api/v1/users, /api/v2/users. Simple, cache-friendly, easy to test in a browser.
// Version in RequestMapping prefix
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public UserDtoV1 getUser(@PathVariable Long id) { ... }
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public UserDtoV2 getUser(@PathVariable Long id) { ... }
}
// V1 response: { "id": 1, "fullName": "John Doe", "email": "john@example.com" }
// V2 response: { "id": 1, "firstName": "John", "lastName": "Doe",
// "email": "john@example.com", "profileUrl": "..." }
// Better: share logic, only differ in DTOs
@Service
public class UserService {
public User findById(Long id) { ... } // single implementation
}
@RestController @RequestMapping("/api/v1/users")
public class UserControllerV1 {
public UserDtoV1 getUser(@PathVariable Long id) {
return userMapper.toV1Dto(userService.findById(id));
}
}
@RestController @RequestMapping("/api/v2/users")
public class UserControllerV2 {
public UserDtoV2 getUser(@PathVariable Long id) {
return userMapper.toV2Dto(userService.findById(id));
}
}
Header Versioning keeps URLs clean. Version is in a custom header: X-API-Version: 2 or X-Api-Version: 2024-01-01. More flexible but not cacheable by default CDNs/proxies.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public UserDtoV1 getUserV1(@PathVariable Long id) {
return userMapper.toV1Dto(userService.findById(id));
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public UserDtoV2 getUserV2(@PathVariable Long id) {
return userMapper.toV2Dto(userService.findById(id));
}
}
// Client: GET /api/users/123 X-API-Version: 2
Accept Header / Content Negotiation is the most "REST-pure" approach. Version is encoded in the media type: Accept: application/vnd.myapi.v2+json.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}",
produces = "application/vnd.myapi.v1+json")
public UserDtoV1 getUserV1(@PathVariable Long id) {
return userMapper.toV1Dto(userService.findById(id));
}
@GetMapping(value = "/{id}",
produces = "application/vnd.myapi.v2+json")
public UserDtoV2 getUserV2(@PathVariable Long id) {
return userMapper.toV2Dto(userService.findById(id));
}
}
// Client: GET /api/users/123 Accept: application/vnd.myapi.v2+json
# URI PATH VERSIONING
Pros: Simple, visible, cache-friendly, browser-testable, easy to route
Cons: Version in URL violates REST purists' view of resources
Best for: Public APIs, external developer ecosystem
# HEADER VERSIONING
Pros: Clean URLs, easy to route in API gateways
Cons: Not cacheable without Vary header, invisible in browser
Best for: Internal APIs between services, SDKs
# MEDIA TYPE VERSIONING
Pros: Most REST-correct, allows fine-grained resource versioning
Cons: Complex, bad developer experience, poor tooling support
Best for: Hypermedia APIs with sophisticated clients
# DATE-BASED VERSIONING (Stripe, AWS approach)
# Version = date the API was "frozen" for that client
# Client pins: X-API-Version: 2024-01-01
# Server responds with that date's contract even as API evolves
Pros: Clients only upgrade deliberately; safe incremental evolution
Cons: Complex compatibility matrix to maintain
Best for: Developer platforms with large client bases
Deprecation Strategy
// Use response headers to signal deprecation
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public ResponseEntity<UserDtoV1> getUserV1(@PathVariable Long id) {
UserDtoV1 user = userMapper.toV1Dto(userService.findById(id));
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "Sat, 31 Dec 2024 23:59:59 GMT") // when it dies
.header("Link", "/api/users/" + id + "; rel=\"successor-version\"")
.header("Warning", "299 - \"This API version is deprecated. Migrate to v2.\"")
.body(user);
}
// Log all v1 API usage so you know which clients haven't migrated
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public UserDtoV1 getUserV1(@PathVariable Long id,
@RequestHeader(value = "X-Client-Id", required = false) String clientId) {
log.warn("Deprecated API v1 called by clientId={} for userId={}", clientId, id);
...
}
OpenAPI & Swagger Documentation
An undocumented API is only slightly better than no API. OpenAPI (formerly Swagger) is the industry standard for describing REST APIs in a machine-readable format that enables: auto-generated documentation, client SDK generation, API testing, and contract validation.
// pom.xml:
// <dependency>
// <groupId>org.springdoc</groupId>
// <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
// <version>2.3.0</version>
// </dependency>
// That's it! Swagger UI available at: http://localhost:8080/swagger-ui.html
// Raw OpenAPI JSON at: http://localhost:8080/v3/api-docs
// application.yml customization:
// springdoc:
// api-docs:
// path: /api-docs
// swagger-ui:
// path: /swagger-ui
// operationsSorter: method
// tagsSorter: alpha
// display-request-duration: true
// show-actuator: false # hide actuator endpoints from docs
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "Order management operations")
public class OrderController {
@Operation(
summary = "Create a new order",
description = "Creates an order for the authenticated customer. " +
"Returns 201 with the created order and Location header.",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Order created successfully",
content = @Content(schema = @Schema(implementation = OrderDetailDto.class))),
@ApiResponse(responseCode = "400", description = "Validation failed",
content = @Content(schema = @Schema(implementation = ApiError.class))),
@ApiResponse(responseCode = "401", description = "Not authenticated"),
@ApiResponse(responseCode = "422", description = "Insufficient stock")
})
@PostMapping
public ResponseEntity<OrderDetailDto> createOrder(
@Valid @RequestBody
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Order creation request",
required = true
) CreateOrderRequest request) { ... }
@Parameter(name = "id", description = "Order ID", example = "123")
@GetMapping("/{id}")
public OrderDetailDto getOrder(@PathVariable Long id) { ... }
}
// Global OpenAPI config
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Order Service API")
.version("v1.2.3")
.description("REST API for order management")
.contact(new Contact()
.name("Platform Team")
.email("platform@mycompany.com"))
.license(new License().name("Proprietary")))
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
.components(new Components()
.addSecuritySchemes("bearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
Idempotency & Safe Methods
In distributed systems, networks fail. Clients retry. When a payment request is retried because the client didn't receive a response, you risk charging the customer twice. Idempotency keys prevent this — and understanding them is essential for any financial or transactional API.
An operation is idempotent if performing it multiple times produces the same result as performing it once. GET, PUT, DELETE are idempotent. POST is not — calling POST twice creates two resources. Making POST operations idempotent requires an explicit idempotency key from the client.
// Client sends a unique key with each request:
// POST /api/v1/payments
// Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
// { "amount": 99.99, "customerId": "123" }
// Server stores (key → response) with TTL — typically 24h
@Service
@RequiredArgsConstructor
public class IdempotencyService {
private final RedisTemplate<String, CachedResponse> redisTemplate;
private static final Duration TTL = Duration.ofHours(24);
public Optional<CachedResponse> getCache(String key) {
return Optional.ofNullable(redisTemplate.opsForValue().get("idem:" + key));
}
public void cache(String key, CachedResponse response) {
redisTemplate.opsForValue().set("idem:" + key, response, TTL);
}
}
// Controller with idempotency
@PostMapping("/payments")
public ResponseEntity<PaymentResult> createPayment(
@RequestHeader("Idempotency-Key") @NotBlank String idempotencyKey,
@Valid @RequestBody CreatePaymentRequest request) {
// Check cache first
Optional<CachedResponse> cached = idempotencyService.getCache(idempotencyKey);
if (cached.isPresent()) {
log.info("Returning cached response for idempotency key {}", idempotencyKey);
return ResponseEntity
.status(cached.get().statusCode())
.header("X-Idempotent-Replayed", "true")
.body(cached.get().body());
}
// Process the payment
PaymentResult result = paymentService.process(request);
// Cache the response
idempotencyService.cache(idempotencyKey,
new CachedResponse(201, result));
URI location = buildLocationUri(result.id());
return ResponseEntity.created(location).body(result);
}
Essential for: payment processing, order creation, notification sending, any operation where duplicate execution causes real-world harm. Stripe, Braintree, and most payment APIs require idempotency keys for all POST operations. Implement it early — retrofitting it is painful.
API Design Best Practices & Anti-Patterns
Good API design is a craft. The decisions you make today — naming, error formats, pagination contracts, versioning — will constrain (or enable) your team for years. Senior engineers evaluate these tradeoffs deliberately.
Naming & Structure Conventions
# ── URI Design ─────────────────────────────────────────────────
# Use lowercase, hyphenated slugs (kebab-case) for URIs
GET /order-items/{id} # CORRECT
GET /orderItems/{id} # WRONG (camelCase in URLs)
GET /order_items/{id} # WRONG (underscores in URLs)
# Use plural nouns for collections
GET /users # CORRECT (collection)
GET /user # WRONG (singular)
# Nest resources when child is meaningless without parent
GET /orders/{orderId}/items # CORRECT
GET /items?orderId=123 # OK for cross-cutting queries, not for primary access
# Max 2 levels of nesting — deeper = design smell
GET /users/{id}/orders # OK
GET /users/{id}/orders/{id}/items # OK (maximum)
GET /users/{id}/orders/{id}/items/{id}/details # TOO DEEP
# Actions that don't map to CRUD: use verbs sparingly
POST /orders/{id}/cancel # acceptable for state machine transitions
POST /orders/{id}/approve
POST /payments/{id}/refund
POST /emails/send # NOT /sendEmail
# ── JSON Body Conventions ───────────────────────────────────────
# Use camelCase for JSON fields (JavaScript-friendly)
{ "orderId": 123, "createdAt": "2024-01-15T10:30:00Z" }
# Use ISO 8601 for all dates and timestamps
"date": "2024-01-15" # date only
"createdAt": "2024-01-15T10:30:00Z" # UTC timestamp
"localTime": "2024-01-15T12:30:00+02:00" # with timezone
Top API Anti-Patterns
Exposes database schema, leaks sensitive fields, triggers lazy-loading exceptions, and breaks when the schema changes. Always use dedicated response DTOs.
{ "status": "error", "message": "Not found" } with HTTP 200 breaks every client library, monitoring tool, and API gateway that relies on HTTP status codes. Use proper 4xx/5xx codes.
GET /orders returning all 10 million orders. Always paginate. Always cap page size. Never return more than a few hundred items in one response.
Validation errors return one shape, business errors return another, and unexpected errors return Spring's default format. Clients can't handle errors generically. Use a single error schema everywhere.
Renaming a field, removing a field, or changing a field's type in an existing endpoint will break all clients immediately. Version before you break things.
Requiring 5 API calls to render one screen (under-fetching). Design APIs around use cases, not database tables. Consider composite endpoints or GraphQL if shapes vary wildly per client.
Interview Questions
RequestMappingHandlerMapping, which walks all registered handler methods and scores them against the request's URL, HTTP method, headers, query params, and content type. The handler with the most specific match wins. If multiple handlers match with equal specificity, Spring throws AmbiguousHandlerException. The matched handler is wrapped in a HandlerExecutionChain with interceptors and dispatched.Retry-After header when the limit is hit.