Spring Boot REST API Best Practices
A practical guide to robust, evolvable Spring Boot REST APIs: resource modeling, validation, error handling, versioning, security, and documentation.
Introduction: why REST API “best practices” matter
A good API is a conversation that’s easy to follow. The paths make sense, the errors are clear, and changes don’t surprise people. In practice, that means using predictable URLs, validating input up front, returning consistent errors, and having a plan for versioning and security.
This isn’t about perfection or over‑engineering. It’s about small habits that make teams faster and integrations calmer. The sections below walk through the pieces you’ll use most often, with straightforward examples.
Resource modeling and URLs
Resource-oriented URLs describe things rather than actions. Using plural nouns for collections and stable IDs for items helps consumers predict paths, while shallow hierarchies convey natural containment without over-coupling.
- Collections are typically plural, while single items are addressed by ID:
/orders
and/orders/{orderId}
. - HTTP verbs represent actions; keeping verbs out of the path keeps URLs noun-focused (e.g.,
POST /orders
,GET /orders/{id}
,PUT/PATCH /orders/{id}
,DELETE /orders/{id}
). - Relationships and containment can be shown hierarchically when helpful:
/orders/{id}/items
,/customers/{id}/orders
. Shallow nesting (≤2 levels) stays readable. - Domain actions can be expressed as sub-resources (e.g.,
POST /orders/{id}/cancellation
) rather than verb-like paths. - Lowercase, hyphen-separated segments and extension-less paths keep names consistent and easy to scan.
// Good
GET /api/v1/orders
GET /api/v1/orders/{id}
GET /api/v1/orders/{id}/items
// Avoid verbs and RPC-ish names
POST /api/v1/createOrder // avoid
DTOs and validation
Using DTOs, which are consumer-focused data shapes decoupled from persistence entities, is preferable and recommended because it stabilizes the API contract and prevents leaking internals. Validating inputs at the edge helps fail fast with clear, field-level feedback.
- Request/response DTOs remain independent from JPA entities, which avoids exposing lazy fields, internal IDs, or persistence concerns.
- Jakarta Bean Validation annotations (e.g.,
@NotNull
,@NotBlank
,@Positive
,@Email
,@Pattern
) express constraints; nested objects are validated via@Valid
. - Enums and numeric ranges communicate domain rules, which protects invariants and improves error messages.
- Validation errors are translated into a consistent API error shape, avoiding raw stack traces and ambiguous 500s.
- Explicit mapping (e.g., MapStruct or manual mappers) provides control over which fields are exposed and how they’re named.
public record CreateOrderItemDTO(
@NotNull Long menuItemId,
@Positive int quantity
) {}
public record CreateOrderDTO(
@NotEmpty List<@Valid CreateOrderItemDTO> items
) {}
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) { this.service = service; }
@PostMapping
public ResponseEntity<OrderResponseDTO> create(@Valid @RequestBody CreateOrderDTO in) {
OrderResponseDTO out = service.create(in);
return ResponseEntity.status(HttpStatus.CREATED).body(out);
}
}
Consistent error handling (Problem Details)
A predictable error format enables clients to automate handling and display. RFC 7807 (Problem Details) describes fields such as type
, title
, status
, detail
, and instance
for interoperable errors.
- Known exceptions map to standard HTTP status codes: 400 for validation, 401/403 for auth, 404 for not found, 409 for conflicts, 422 for semantic errors.
- Machine-friendly error codes and per-field details make client-side error rendering and retries reliable.
- Stack traces or internal class names are not exposed; a correlation/trace ID helps support investigations.
- Setting
Content-Type: application/problem+json
and a consistent schema keeps errors uniform across endpoints.
@ControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<Map<String,Object>> handleValidation(MethodArgumentNotValidException ex) {
var errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.groupingBy(FieldError::getField,
Collectors.mapping(DefaultMessageSourceResolvable::getDefaultMessage, Collectors.toList())));
Map<String,Object> body = Map.of(
"type", "https://example.com/problems/validation",
"title", "Validation Failed",
"status", 400,
"errors", errors,
"timestamp", Instant.now().toString()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(body);
}
}
Versioning strategy
Clear version boundaries help manage breaking changes, while a documented deprecation policy sets expectations for removals and migration.
- Path-based versions (e.g.,
/api/v1
) are simple and cache-friendly; media-type or header versions offer stricter contract control. - Query-param versioning tends to be less clear than path or media-type approaches.
- Deprecations can be documented in OpenAPI and release notes; the
Sunset
header communicates timelines. - Within a version, changes remain additive to preserve backward compatibility.
// URI versioning
GET /api/v1/orders
GET /api/v2/orders // breaking changes live here
// Header versioning example (custom)
GET /api/orders
Accept: application/vnd.example.order+json;version=2
Pagination, filtering, sorting
Consistent query parameters and clear metadata make list endpoints predictable and performant, especially at scale.
- Common parameters include
page
,size
, andsort=field,asc|desc
; cappingsize
protects the service from heavy requests. - Total counts and navigation links aid pagination UIs; stable ordering yields deterministic pages.
- Filtering via query params (e.g.,
status=READY
, ranges likecreatedAfter=...
) narrows result sets efficiently. - Whitelisting sortable/filterable fields avoids injection risks and unbounded database work.
// Requests
GET /api/v1/orders?page=0&size=20&sort=createdAt,desc&status=READY
// Response shape (example)
{
"content": [ /* items */ ],
"page": 0,
"size": 20,
"totalElements": 128,
"totalPages": 7,
"links": {"next": "/api/v1/orders?page=1&size=20"}
}
Security and auth
Every endpoint benefits from authentication and authorization, which reduce risk exposure and limit access to appropriate identities.
- HTTPS is enforced end-to-end; JWT/OAuth2 tokens are validated, and scopes/roles are checked at controller and service layers.
- Least privilege and clear segregation (e.g., separate admin routes) constrain potential damage.
- Stateless auth suits APIs; when sessions are used, CSRF protections are applied.
- Secrets and tokens are never logged; keys are rotated and stored in secure systems (vault/KMS).
@RestController
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ADMIN')")
class AdminController {
@GetMapping("/stats")
public StatsDTO stats() { /* ... */ return new StatsDTO(); }
}
// SecurityConfig snippet (Spring Security 6)
@Bean SecurityFilterChain http(HttpSecurity http) throws Exception {
return http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(reg -> reg
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt())
.build();
}
Idempotency and safety
Designing for retries reduces user-facing errors. Safe methods do not change state, and idempotent operations return the same outcome when repeated.
GET
is safe by definition;PUT
andDELETE
are idempotent;POST
can be made idempotent via client-supplied identifiers or anIdempotency-Key
.- Deduplication is achieved by storing the idempotency key and response for a defined window.
- Idempotency should hold across network retries and timeouts to prevent duplicated side effects (e.g., double charges).
// Client sends a unique key per logical create attempt
POST /api/v1/payments
Idempotency-Key: 3a7b... // repeat with same key returns the same result
Documentation (OpenAPI/Swagger)
A self-describing API keeps the contract in sync with the implementation and helps consumers explore and integrate faster.
- Controller and DTO annotations allow the OpenAPI generator to capture types, constraints, examples, and auth details.
- Error schemas and response codes clarify failure modes; required scopes and headers are documented alongside endpoints.
- Documentation generation can be automated in CI and published via Swagger UI in appropriate environments.
// build.gradle snippet
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0"
// UI at /swagger-ui.html or /swagger-ui/index.html
Stability and backward compatibility
Backward compatibility allows evolution without breaking consumers. Additive changes are favored, while removals are planned and communicated.
- New optional fields can be added; changing field meaning or types is avoided within the same version.
- Deprecations are marked in OpenAPI and release notes, with clear timelines toward removal.
- Clients are typically tolerant in reading (ignore unknown fields) and strict in writing (omit unstable fields).
Testing and performance
Correctness and performance are supported by layered tests and measurement, from fast unit tests to end-to-end checks.
- Unit tests cover business logic; slice tests (e.g.,
@WebMvcTest
) verify web layers; integration tests exercise the real HTTP stack. - Test data builders and isolated fixtures keep tests readable and deterministic, reducing flakiness.
- Contract tests or schema validation against OpenAPI detect breaking changes before release.
- Latencies and throughput are measured against budgets; profiling identifies hotspots prior to optimization.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService service;
@Test void createsOrder() throws Exception {
when(service.create(any())).thenReturn(new OrderResponseDTO(1L, BigDecimal.TEN));
mvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"items\":[{\"menuItemId\":1,\"quantity\":2}]}") )
.andExpect(status().isCreated());
}
}