Spring Boot Skills
GitHub
API Design

REST API Conventions

Use when generating REST controllers, DTOs, success response contracts, pagination, HTTP status mapping, or API versioning. For RFC 9457 exception and error response formatting, use problem-details-rfc9457 unless the project explicitly requires a legacy error envelope.

View SKILL.md

Project Contract

Inspect existing controllers, tests and OpenAPI before choosing a response contract. Preserve the project's IDs, response shape and versioning strategy. Do not migrate unrelated endpoints while adding one route. The envelope below is an optional convention for a project that uses it; plain success DTOs are equally valid. A 204 response has no body.

Success and error formats are independent: an existing success envelope can coexist with RFC 9457 errors. Use one consistent error policy; choose the legacy error examples below only when the project already requires that format.

Example success envelope:

{
  "success": true,
  "data": { },
  "error": null,
  "timestamp": "2026-04-13T10:00:00Z"
}

Error response:

{
  "success": false,
  "data": null,
  "error": {
    "code": "ORDER_NOT_FOUND",
    "message": "Order with id 123 not found",
    "details": []
  },
  "timestamp": "2026-04-13T10:00:00Z"
}

ApiResponse Wrapper

@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(
    boolean success,
    T data,
    ApiError error,
    Instant timestamp
) {
    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(true, data, null, Instant.now());
    }

    public static <T> ApiResponse<T> error(String code, String message) {
        return new ApiResponse<>(false, null, new ApiError(code, message, List.of()), Instant.now());
    }
}

public record ApiError(String code, String message, List<String> details) {}

HTTP Status Mapping

Scenario Status
GET — found 200
POST — created resource 201
PUT/PATCH — updated 200
DELETE — deleted 204 (no body)
Validation failure 400
Unauthenticated 401
Forbidden 403
Not found 404
Conflict (duplicate) 409
Unhandled server error 500

URL Conventions

  • Plural nouns for resources: /orders, /users, /products
  • Kebab-case for multi-word: /order-items, not /orderItems
  • Versioning in path: /api/v1/orders URLs, mapped as @RequestMapping("/api/{version}/orders") — a literal /api/v1/orders mapping never matches /api/v1.1/... (404). Route with native API versioning (below), don't duplicate controllers per version
  • Nested resources max 2 levels: /orders/{id}/items ✅, /orders/{id}/items/{itemId}/notes ❌ — flatten to /order-item-notes/{id}
  • IDs: preserve the existing ID type. UUIDs are an option, not an authorization mechanism.
GET    /api/v1/orders              → list (paginated)
POST   /api/v1/orders              → create
GET    /api/v1/orders/{id}         → get one
PUT    /api/v1/orders/{id}         → full update
PATCH  /api/v1/orders/{id}         → partial update
DELETE /api/v1/orders/{id}         → delete
GET    /api/v1/orders/{id}/items   → nested resource

API Versioning (Native in Boot 4)

Spring Boot 4 / Framework 7 route requests by API version natively — never hand-roll it with duplicated V1/V2 controllers, custom RequestConditions, or header if checks.

Pick ONE resolution strategy per API (path segment, header, query param, or media-type param):

spring:
  mvc:                        # WebFlux: same keys under spring.webflux.apiversion.*
    apiversion:
      use:
        path-segment: 1       # /api/v1.1/orders — the mapping MUST declare it: "/api/{version}/orders"
        # header: X-API-Version
        # query-parameter: version
      supported: [1.0, 1.1, 1.2]
      default: 1.0

Route with the version attribute on any mapping annotation:

@GetMapping("/{id}")                            // no version — matches any
public OrderResponse getById(@PathVariable UUID id) { ... }

@GetMapping(value = "/{id}", version = "1.1")   // fixed: matches 1.1 only
public OrderResponseV1_1 getByIdV1_1(@PathVariable UUID id) { ... }

@GetMapping(value = "/{id}", version = "1.2+")  // baseline: 1.2 and supported versions above
public OrderResponseV2 getByIdV2(@PathVariable UUID id) { ... }

The most specific matching version wins. Unsupported version → 400 (InvalidApiVersionException); missing required version → 400 (MissingApiVersionException).

  • Deprecate old versions with StandardApiVersionDeprecationHandler (register via WebMvcConfigurer#configureApiVersioning(ApiVersionConfigurer)) — it emits RFC 9745 Deprecation/Sunset and Link response headers
  • Clients: RestClient/WebClient and HTTP interface clients send versions too — configure .apiVersionInserter(ApiVersionInserter.usePathSegment(1)) (or useHeader("X-API-Version")) and .defaultApiVersion("1.2") on the builder, matching the server's strategy

Pagination

{
  "success": true,
  "data": {
    "content": [...],
    "page": 0,
    "size": 20,
    "totalElements": 150,
    "totalPages": 8,
    "last": false
  }
}

Query params: ?page=0&size=20&sort=createdAt,desc

Use Spring Data Pageable in controllers:

@GetMapping
public ApiResponse<PageResponse<OrderResponse>> list(Pageable pageable) {
    return ApiResponse.ok(PageResponse.from(orderService.findAll(pageable).map(OrderResponse::from)));
}

Cap the page size. A bare Pageable accepts ?size=100000 from any client — one request can drag your whole table into memory. Spring's default cap is 2000, still too high for most APIs:

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100   # requests above this are silently clamped

The compiled PageResponse fixes the JSON pagination contract. Mapping entities to DTOs alone does not stabilize Spring Data PageImpl serialization. Spring Data's org.springframework.data.web.PagedModel is another option when its shape fits the API. Validate sort fields against an allowlist and add an ID tie-breaker to non-unique sorts.

Legacy Envelope Exception Handler

// Extends ResponseEntityExceptionHandler: without it the Exception.class catch-all turns
// malformed JSON, 405, bad path variables and unknown URLs into 500. Full version, verified
// against Boot 3 and 4: templates/GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNotFound(EntityNotFoundException ex) {
        return ResponseEntity.status(404).body(ApiResponse.error("NOT_FOUND", ex.getMessage()));
    }

    @ExceptionHandler(AccessDeniedException.class) // @PreAuthorize denials surface here, not in the filter
    public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
        return ResponseEntity.status(403).body(ApiResponse.error("FORBIDDEN", "Access denied"));
    }

    @Override // an @ExceptionHandler for this type would clash with the superclass at startup
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<String> details = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage()).toList();
        return ResponseEntity.status(400).body(ApiResponse.validationError(details));
    }

    @Override // keep the framework's status + headers, return the envelope
    protected ResponseEntity<Object> handleExceptionInternal(
            Exception ex, Object body, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        return ResponseEntity.status(status).headers(headers)
            .body(ApiResponse.error("HTTP_" + status.value(), "Request could not be processed"));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception ex) {
        return ResponseEntity.status(500).body(ApiResponse.error("INTERNAL_ERROR", "An unexpected error occurred"));
    }
}

Gotchas

  • Agent keeps a literal @RequestMapping("/api/v1/orders") with path-segment versioning — versioned URLs like /api/v1.1/orders return 404; declare the segment as {version}
  • Agent writes an Exception.class catch-all in an advice that doesn't extend ResponseEntityExceptionHandler — malformed JSON, 405, type-mismatched path variables and unknown URLs all become 500
  • Agent imposes a new envelope - preserve the existing success contract.
  • Agent invents competing error handlers - use the project's established error contract.
  • Agent puts exception handlers in controllers — always use @RestControllerAdvice
  • Agent changes ID types while adding an endpoint - retain the existing ID scheme.
  • Agent accepts unbounded Pageable — set spring.data.web.pageable.max-page-size or one request can pull the whole table
  • Agent serializes PageImpl directly - map entities to DTOs, then wrap in PageResponse.
  • Agent hand-rolls versioning with duplicated /v1//v2 controllers — Boot 4 has native API versioning: version attribute on mappings + spring.mvc.apiversion.*
  • Agent adds spring-boot-starter-web — renamed spring-boot-starter-webmvc in Boot 4 (MockMvc tests: spring-boot-starter-webmvc-test)
  • Agent uses @JsonComponent or Jackson2ObjectMapperBuilderCustomizer to tune serialization — Jackson 3 renames: @JacksonComponent, JsonMapperBuilderCustomizer; declare JsonMapper beans, not generic ObjectMapper
  • Agent tests controllers with bare @SpringBootTest expecting MockMvc — Boot 4 no longer auto-provides it; add @AutoConfigureMockMvc (or the new RestTestClient via @AutoConfigureRestTestClient)
FROM THE REPOSITORY

Examples & templates

bad-controller.javaexamples
Source on GitHub
// ❌ BAD — all the common mistakes in one controller

@RestController
@RequestMapping("/orders")          // preserve the project route prefix
public class OrderController {

    @Autowired                       // field injection
    private OrderRepository orderRepository;  // direct repo, no service layer

    @PostMapping
    public Order createOrder(@RequestBody CreateOrderRequest request) { // returns entity directly
        Order order = new Order();   // no factory method
        order.setEmail(request.getEmail());
        return orderRepository.save(order); // entity leakage; missing creation status
    }

    @GetMapping("/{id}")
    public Map<String, Object> getOrder(@PathVariable Long id) { // ID type is not the problem; authorization still matters
        Optional<Order> order = orderRepository.findById(id);
        if (!order.isPresent()) {
            return Map.of("error", "not found"); // ad-hoc error, no status code
        }
        return Map.of("data", order.get()); // raw map, entity leakage
    }

    @GetMapping
    public List<Order> getAllOrders() { // no pagination, returns all rows
        return orderRepository.findAll();
    }

    @DeleteMapping("/{id}")
    public String delete(@PathVariable Long id) {
        orderRepository.deleteById(id);
        return "deleted"; // 200 with body instead of 204
    }
}
good-controller.javaexamples
Source on GitHub
// ✅ GOOD — proper response envelope, correct status, no entity leakage

@RestController
@RequestMapping("/api/{version}/orders") // path-segment versioning: /api/v1/orders, /api/v1.1/orders
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<ApiResponse<OrderResponse>> create(
        @Valid @RequestBody CreateOrderRequest request
    ) {
        Order order = orderService.createOrder(request);
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(ApiResponse.ok(OrderResponse.from(order)));
    }

    @GetMapping("/{id}")
    public ApiResponse<OrderResponse> getById(@PathVariable UUID id) {
        return ApiResponse.ok(OrderResponse.from(orderService.findById(id)));
    }

    @GetMapping
    public ApiResponse<PageResponse<OrderResponse>> list(Pageable pageable) {
        return ApiResponse.ok(PageResponse.from(orderService.findAll(pageable).map(OrderResponse::from)));
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable UUID id) {
        orderService.delete(id);
    }
}
ApiResponse.javatemplates
Source on GitHub
package com.example.common.api;

import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.List;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(
    boolean success,
    T data,
    ApiError error,
    Instant timestamp
) {
    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(true, data, null, Instant.now());
    }

    public static <T> ApiResponse<T> error(String code, String message) {
        return new ApiResponse<>(false, null,
            new ApiError(code, message, List.of()), Instant.now());
    }

    public static <T> ApiResponse<T> validationError(List<String> violations) {
        return new ApiResponse<>(false, null,
            new ApiError("VALIDATION_FAILED", "Input validation failed", violations), Instant.now());
    }

    public record ApiError(String code, String message, List<String> details) {}
}
GlobalExceptionHandler.javatemplates
Source on GitHub
package com.example.common.exception;

import com.example.common.api.ApiResponse;
import jakarta.persistence.EntityNotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.util.List;

/**
 * Extends ResponseEntityExceptionHandler so Spring MVC's own client errors keep their
 * status: malformed JSON (400), wrong method (405), bad path variable (400), unknown
 * URL (404), unsupported media type (415). Without it, the Exception.class catch-all
 * below turns all of them into 500.
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNotFound(EntityNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ApiResponse.error("NOT_FOUND", ex.getMessage()));
    }

    // @PreAuthorize denials are thrown inside the dispatcher, so the security filter's
    // AccessDeniedHandler never sees them — map them here or the catch-all returns 500.
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .body(ApiResponse.error("FORBIDDEN", "Access denied"));
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        List<String> violations = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .toList();
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ApiResponse.validationError(violations));
    }

    // Every other framework exception handled by the superclass ends up here: keep its
    // status and headers (e.g. Allow on 405), but return the project's envelope.
    @Override
    protected ResponseEntity<Object> handleExceptionInternal(
            Exception ex, Object body, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        HttpStatus resolved = HttpStatus.resolve(status.value());
        String code = resolved != null ? resolved.name() : "HTTP_" + status.value();
        return ResponseEntity.status(status).headers(headers)
            .body(ApiResponse.error(code, "Request could not be processed"));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception ex) {
        log.error("Unhandled exception", ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ApiResponse.error("INTERNAL_ERROR", "An unexpected error occurred"));
    }
}
PageResponse.javatemplates
Source on GitHub
package com.example.common.api;

import java.util.List;
import org.springframework.data.domain.Page;

public record PageResponse<T>(
        List<T> content, int page, int size, long totalElements, int totalPages, boolean last) {
    public PageResponse {
        content = List.copyOf(content);
    }

    public static <T> PageResponse<T> from(Page<T> source) {
        return new PageResponse<>(source.getContent(), source.getNumber(), source.getSize(),
            source.getTotalElements(), source.getTotalPages(), source.isLast());
    }
}

Verification scope

Compiled sources
Envelope, page DTO and handler, both trees
Runtime checks
Pagination, HTTP errors and fixture create/retrieve

Coverage describes checked-in fixtures, not a guarantee for every snippet or project.

Coverage matrix & agent evidence
ADD TO YOUR AGENT

Install Boot 4 skills

claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-4-skills@spring-boot-skills