Spring Boot API Design
Designing consistent, maintainable REST APIs with OpenAPI specifications, proper validation, and standardized error handling.
Overview
Well-designed APIs are the contract between your backend and clients (web, mobile, other services). Poor API design creates confusion, bugs, and maintenance nightmares. This guide covers REST API best practices using Spring Boot with OpenAPI-first development.
Focus areas: OpenAPI specifications, request validation, error responses (RFC 7807), pagination, versioning, and HTTP semantics.
Core Principles
- OpenAPI first: Define API contract before implementation
- HTTP semantics: Use correct HTTP methods and status codes
- Input validation: Validate everything from external sources
- RFC 7807 errors: Standardized, machine-readable error format
- DTOs everywhere: Never expose entities directly
- Pagination: All list endpoints must support pagination
Why OpenAPI First?
Traditional approach (code-first):
- Write controller code
- Generate OpenAPI spec from code
- Frontend developers discover API changed (surprise!)
- Backend changes break frontend
OpenAPI-first approach:
- Define API contract in OpenAPI spec (YAML/JSON)
- Both teams review and agree on contract
- Backend implements to spec
- Frontend can mock API from spec (parallel development)
- Contract tests validate implementation matches spec
Benefits:
- API contract is explicit and versioned
- Frontend and backend can work in parallel
- Contract changes require consensus
- Generated clients always match spec
- Documentation automatically correct
For comprehensive OpenAPI specification guidelines, see API: OpenAPI Specifications.
Dependencies
build.gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
}
What each dependency does:
spring-boot-starter-web: REST controllers, Jackson JSON, Tomcatspring-boot-starter-validation: Jakarta Bean Validation (@NotNull, @Valid, etc.)springdoc-openapi-starter-webmvc-ui: Generates OpenAPI spec from code + Swagger UI
OpenAPI Configuration
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Payment Service API")
.version("1.0")
.description("Payment processing and management")
.contact(new Contact()
.name("Platform Team")
.email("[email protected]")))
.servers(List.of(
new Server()
.url("https://api.bank.com")
.description("Production"),
new Server()
.url("https://api-staging.bank.com")
.description("Staging")
));
}
}
application.yml:
springdoc:
api-docs:
path: /api-docs # OpenAPI JSON at this path
swagger-ui:
path: /swagger-ui.html # Interactive documentation
enabled: true
operationsSorter: method # Sort by HTTP method
Access the docs:
- OpenAPI spec:
http://localhost:8080/api-docs - Interactive UI:
http://localhost:8080/swagger-ui.html
REST Controller Design
Basic Controller
@RestController
@RequestMapping("/api/v1/payments")
@RequiredArgsConstructor
@Validated // Enable validation
@Tag(name = "Payments", description = "Payment management operations")
public class PaymentController {
private final PaymentService paymentService;
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // Return 201, not 200
@Operation(summary = "Create a payment", description = "Creates a new payment for a customer")
@ApiResponse(responseCode = "201", description = "Payment created successfully")
@ApiResponse(responseCode = "400", description = "Invalid request")
public PaymentResponse createPayment(
@Valid @RequestBody PaymentRequest request) {
var payment = paymentService.createPayment(request);
return PaymentMapper.toResponse(payment);
}
@GetMapping("/{id}")
@Operation(summary = "Get payment by ID")
@ApiResponse(responseCode = "200", description = "Payment found")
@ApiResponse(responseCode = "404", description = "Payment not found")
public PaymentResponse getPayment(
@PathVariable @NotBlank String id) {
var payment = paymentService.getPayment(id);
return PaymentMapper.toResponse(payment);
}
@PatchMapping("/{id}/status")
@Operation(summary = "Update payment status")
public PaymentResponse updateStatus(
@PathVariable String id,
@Valid @RequestBody UpdateStatusRequest request) {
var payment = paymentService.updateStatus(id, request.status());
return PaymentMapper.toResponse(payment);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 for successful delete
@Operation(summary = "Cancel a payment")
public void cancelPayment(@PathVariable String id) {
paymentService.cancelPayment(id);
// No body returned - 204 No Content
}
}
Design decisions:
@Validatedon class: Enables method-level validation (@NotBlank on path variables)@Validon request body: Triggers Bean Validation on the DTO@Operation: OpenAPI metadata (shows in Swagger UI docs)@ApiResponse: Documents possible responses with status codes- Status codes:
201 Created: For POST that creates resource200 OK: For GET/PATCH that returns data204 No Content: For DELETE or operations with no response body
@Tag: Groups endpoints in OpenAPI docs
HTTP Methods and Semantics
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {
// POST: Create new resource, not idempotent
@PostMapping
public PaymentResponse createPayment(@RequestBody PaymentRequest request) {
// Calling twice creates two payments (different IDs)
}
// PUT: Replace entire resource, idempotent
@PutMapping("/{id}")
public PaymentResponse replacePayment(
@PathVariable String id,
@RequestBody PaymentRequest request) {
// Calling twice has same effect - resource replaced with same data
}
// PATCH: Update part of resource, may be idempotent
@PatchMapping("/{id}")
public PaymentResponse updatePayment(
@PathVariable String id,
@RequestBody UpdatePaymentRequest request) {
// Update only provided fields
}
// GET: Read resource, safe and idempotent
@GetMapping("/{id}")
public PaymentResponse getPayment(@PathVariable String id) {
// Calling multiple times doesn't change state
}
// DELETE: Remove resource, idempotent
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deletePayment(@PathVariable String id) {
// Calling twice has same effect - resource is deleted
}
}
Idempotent means calling the operation multiple times has the same effect as calling it once:
GET /payments/123- Safe to retryPUT /payments/123- Safe to retry (same update)DELETE /payments/123- Safe to retry (already deleted = still deleted)POST /payments- NOT safe to retry (creates duplicate payments)
Why this matters: Clients can safely retry idempotent requests on network failures without worrying about duplicates.
Request Validation
DTOs with Validation
public record PaymentRequest(
@NotBlank(message = "Customer ID is required")
@Size(max = 50, message = "Customer ID too long")
String customerId,
@NotNull(message = "Amount is required")
@DecimalMin(value = "0.01", message = "Amount must be positive")
@DecimalMax(value = "999999.99", message = "Amount too large")
@Digits(integer = 6, fraction = 2, message = "Invalid amount format")
BigDecimal amount,
@NotBlank(message = "Currency is required")
@Pattern(regexp = "^[A-Z]{3}$", message = "Currency must be 3-letter code")
String currency,
@Size(max = 500, message = "Description too long")
String description
) {}
Controller method:
@PostMapping
public PaymentResponse createPayment(@Valid @RequestBody PaymentRequest request) {
// If validation fails, Spring throws MethodArgumentNotValidException
// Global exception handler catches it and returns 400 Bad Request
return paymentService.createPayment(request);
}
What happens on validation failure:
- Client sends request with
amount: -100 @DecimalMinvalidation fails- Spring throws
MethodArgumentNotValidException - Exception handler (below) catches it
- Returns
400 Bad Requestwith error details
Global Exception Handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Request validation failed"
);
problem.setTitle("Validation Error");
problem.setType(URI.create("https://docs.bank.com/errors/validation"));
// Collect all validation errors
var errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(existing, replacement) -> existing // Keep first error per field
));
problem.setProperty("errors", errors);
return problem;
}
@ExceptionHandler(PaymentNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleNotFound(PaymentNotFoundException ex) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
"Payment not found"
);
problem.setTitle("Not Found");
problem.setType(URI.create("https://docs.bank.com/errors/not-found"));
problem.setProperty("paymentId", ex.getPaymentId());
log.warn("Payment not found: {}", ex.getPaymentId());
return problem;
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ProblemDetail handleGenericError(Exception ex) {
log.error("Unexpected error", ex);
// Don't expose internal details to clients
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"An error occurred processing your request"
);
}
}
RFC 7807 Problem Details response:
{
"type": "https://docs.bank.com/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "Request validation failed",
"errors": {
"amount": "Amount must be positive",
"currency": "Currency must be 3-letter code"
}
}
Why RFC 7807:
- Standardized format clients can parse reliably
typeURI links to documentationerrorsobject lists all validation failures- Machine-readable (clients can handle programmatically)
Pagination
ALWAYS paginate list endpoints. Even if you have 10 items today, you'll have 10,000 tomorrow.
Spring Data Pagination
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {
@GetMapping
@Operation(summary = "List payments with pagination")
public Page<PaymentResponse> listPayments(
@RequestParam(required = false) PaymentStatus status,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
Page<Payment> payments = paymentService.findPayments(status, pageable);
// Map entities to DTOs
return payments.map(PaymentMapper::toResponse);
}
}
Request:
GET /api/v1/payments?page=0&size=20&sort=createdAt,desc&status=PENDING
Response:
{
"content": [
{"id": "pay-1", "amount": 100.00, "status": "PENDING"},
{"id": "pay-2", "amount": 250.50, "status": "PENDING"}
],
"pageable": {
"pageNumber": 0,
"pageSize": 20,
"sort": {"sorted": true, "orders": [{"property": "createdAt", "direction": "DESC"}]}
},
"totalElements": 47,
"totalPages": 3,
"last": false,
"first": true,
"size": 20,
"number": 0
}
What each field means:
content: Current page datapageNumber: Zero-based page indextotalElements: Total items across all pagestotalPages: Total pages availablelast/first: Boolean flags for navigationsize: Items per page
Service layer:
@Service
@Transactional(readOnly = true)
public class PaymentService {
public Page<Payment> findPayments(PaymentStatus status, Pageable pageable) {
if (status == null) {
return repository.findAll(pageable);
}
return repository.findByStatus(status, pageable);
}
}
Repository:
@Repository
public interface PaymentRepository extends JpaRepository<Payment, String> {
Page<Payment> findByStatus(PaymentStatus status, Pageable pageable);
}
Why pagination matters:
- Loading 10,000 items in one response kills performance
- Client can't render that much data anyway
- Database query takes longer
- Network transfer slower
- More memory on both client and server
API Versioning
Multiple versioning strategies exist. Choose one and stick with it.
URL Path Versioning (Recommended)
// v1 controller
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentControllerV1 {
@GetMapping("/{id}")
public PaymentResponseV1 getPayment(@PathVariable String id) {
// v1 returns basic fields only
return new PaymentResponseV1(
payment.getId(),
payment.getAmount(),
payment.getStatus()
);
}
}
// v2 controller
@RestController
@RequestMapping("/api/v2/payments")
public class PaymentControllerV2 {
@GetMapping("/{id}")
public PaymentResponseV2 getPayment(@PathVariable String id) {
// v2 includes new fields
return new PaymentResponseV2(
payment.getId(),
payment.getAmount(),
payment.getStatus(),
payment.getCustomerId(), // New in v2
payment.getCreatedAt() // New in v2
);
}
}
Pros:
- Version is obvious in URL
- Easy to route (reverse proxy, API gateway)
- Different controllers = clear separation
- Can deprecate old version easily
Cons:
- URL changes between versions
- More code duplication
Header Versioning
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@GetMapping(value = "/{id}", headers = "API-Version=1")
public PaymentResponseV1 getPaymentV1(@PathVariable String id) {
// Handle v1
}
@GetMapping(value = "/{id}", headers = "API-Version=2")
public PaymentResponseV2 getPaymentV2(@PathVariable String id) {
// Handle v2
}
}
Request:
GET /api/payments/pay-123
API-Version: 2
Pros:
- URL doesn't change
- Single endpoint for all versions
Cons:
- Version not visible in URL (harder to debug)
- Harder to route at infrastructure level
- More complex controller code
Content Negotiation
Support multiple response formats:
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {
@GetMapping(value = "/{id}", produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
})
public PaymentResponse getPayment(@PathVariable String id) {
// Spring auto-serializes to JSON or XML based on Accept header
return paymentService.getPayment(id);
}
}
Request with JSON:
GET /api/v1/payments/pay-123
Accept: application/json
Request with XML:
GET /api/v1/payments/pay-123
Accept: application/xml
Why support multiple formats:
- JSON for web/mobile
- XML for legacy integrations
- Spring handles serialization automatically
Rate Limiting
Protect your API from abuse. For production deployments, prefer rate limiting at the API gateway or reverse proxy (nginx, AWS API Gateway) since it operates before requests reach your application. For application-level rate limiting, use Resilience4j which is already on the classpath.
// application.yml
resilience4j:
ratelimiter:
instances:
payments-api:
limit-for-period: 100 # Requests per refresh period
limit-refresh-period: 1s # Reset window
timeout-duration: 0 # Don't wait; reject immediately
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {
@PostMapping
@RateLimiter(name = "payments-api", fallbackMethod = "rateLimitFallback")
public PaymentResponse createPayment(@Valid @RequestBody PaymentRequest request) {
return paymentService.createPayment(request);
}
public ResponseEntity<ProblemDetail> rateLimitFallback(
PaymentRequest request, RequestNotPermitted ex) {
var problem = ProblemDetail.forStatus(HttpStatus.TOO_MANY_REQUESTS);
problem.setDetail("Rate limit exceeded. Retry after 1 second.");
return ResponseEntity.status(429)
.header("Retry-After", "1")
.body(problem);
}
}
See Spring Boot Resilience for full Resilience4j setup.
Why rate limiting:
- Prevents a single client from monopolising API capacity
- Protects against DDoS at the application level
- Forces clients to implement exponential backoff
CORS Configuration
For web frontends on different domains:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(
"https://app.bank.com",
"https://app-staging.bank.com"
)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600); // Cache preflight for 1 hour
}
}
What this does:
- Allows specified origins to call API
- Permits HTTP methods listed
- Accepts any headers
- Allows cookies/auth headers (
allowCredentials) - Browser caches CORS preflight for 1 hour
Why CORS matters:
Without CORS configuration, browsers block API calls from web apps on different domains (e.g., frontend at app.bank.com calling API at api.bank.com).
Summary
API Design Checklist:
- OpenAPI first: Define spec before coding
- HTTP semantics: Use correct methods and status codes
- Input validation: Validate all requests with Bean Validation
- RFC 7807 errors: Standardized error responses
- DTOs: Never expose entities in API
- Pagination: All list endpoints must paginate
- Versioning: Choose strategy (URL path recommended)
- Rate limiting: API gateway preferred; Resilience4j at application level
- CORS: Configure for web clients
- Documentation: OpenAPI spec + Swagger UI
HTTP Status Codes:
200 OK: Successful GET/PUT/PATCH with body201 Created: Successful POST that creates resource204 No Content: Successful DELETE or operation with no response400 Bad Request: Validation failure404 Not Found: Resource doesn't exist429 Too Many Requests: Rate limit exceeded500 Internal Server Error: Unexpected server error
Anti-Patterns:
- Returning entities directly (lazy loading exceptions, too much data)
- Missing validation (allows garbage data)
- Generic error messages (can't debug)
- No pagination (loads thousands of items)
- Using POST for everything (breaks HTTP semantics)
Cross-References:
- See Input Validation for comprehensive validation patterns
- See API Design Guidelines for REST principles and best practices
- See API Contracts for contract-first development
- See API Versioning for versioning strategies
- See OpenAPI Specifications for spec-first development
- See Spring Boot Security for validation and security
- See Rate Limiting for rate limiting strategies across platforms