
Spring Boot Web Api
- 1 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Implements Spring Boot 4 REST APIs: controllers, Bean Validation, ProblemDetail (RFC 9457) error handling, API versioning, and declarative HTTP clients.
About
Provides Spring Boot 4 REST API patterns with @RestController, request validation, global ProblemDetail error handling, and declarative @HttpExchange clients across MVC and WebFlux. A developer uses it when building REST endpoints in Spring Boot 4.
- ProblemDetail (RFC 9457) global error handling
- MVC vs WebFlux selection and @HttpExchange clients
Spring Boot Web Api by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,836 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill spring-boot-web-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Implements Spring Boot 4 REST APIs: controllers, Bean Validation, ProblemDetail (RFC 9457) error handling, API versioning, and declarative HTTP clients.
Files
Spring Boot Web API Layer
REST API implementation patterns for Spring Boot 4 with Spring MVC and WebFlux.
Technology Selection
| Choose | When |
|---|---|
| Spring MVC | JPA/JDBC backend, simpler debugging, team knows imperative style |
| Spring WebFlux | High concurrency (10k+ connections), streaming, reactive DB (R2DBC) |
With Virtual Threads (Java 21+), MVC handles high concurrency without WebFlux complexity.
Core Workflow
1. Create controller → 2. Define endpoints → 3. Add validation → 4. Handle exceptions → 5. Configure versioning
See WORKFLOW.md for detailed step-by-step instructions with code examples.
Quick Patterns
See EXAMPLES.md for complete working examples including:
- REST Controller with CRUD operations and pagination (Java + Kotlin)
- Request/Response DTOs with Bean Validation 3.1
- Global Exception Handler using ProblemDetail (RFC 9457)
- Native API Versioning with header configuration
- Jackson 3 Configuration for custom serialization
- Controller Testing with @WebMvcTest
Spring Boot 4 Specifics
- Jackson 3 uses
tools.jacksonpackage (notcom.fasterxml.jackson) - ProblemDetail enabled by default:
spring.mvc.problemdetails.enabled=true - API Versioning via
versionattribute in mapping annotations - @MockitoBean replaces
@MockBeanin tests - @HttpExchange declarative HTTP clients (replaces RestTemplate patterns)
- RestTestClient new fluent API for testing REST endpoints
@HttpExchange Declarative Client (Spring 7)
New declarative HTTP client interface (alternative to RestTemplate/WebClient):
@HttpExchange(url = "/users", accept = "application/json")
public interface UserClient {
@GetExchange("/{id}")
User getUser(@PathVariable Long id);
@PostExchange
User createUser(@RequestBody CreateUserRequest request);
@DeleteExchange("/{id}")
void deleteUser(@PathVariable Long id);
}
// Configuration
@Configuration
class ClientConfig {
@Bean
UserClient userClient(RestClient.Builder builder) {
RestClient restClient = builder.baseUrl("https://api.example.com").build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(UserClient.class);
}
}Benefits: Type-safe, annotation-driven, works with both RestClient and WebClient.
Detailed References
- Workflow: See WORKFLOW.md for detailed step-by-step web API implementation
- Examples: See EXAMPLES.md for complete working code examples
- Troubleshooting: See TROUBLESHOOTING.md for common issues and Boot 4 migration
- Controllers & Validation: See references/CONTROLLERS.md for validation groups, custom validators, content negotiation
- Error Handling: See references/ERROR-HANDLING.md for ProblemDetail patterns, exception hierarchy
- WebFlux Patterns: See references/WEBFLUX.md for reactive endpoints, functional routers, WebTestClient
Related Skills
| Need | Skill |
|---|---|
| DDD concepts | domain-driven-design |
| Data layer for DTOs | spring-boot-data-ddd |
| Controller testing | spring-boot-testing |
| API security | spring-boot-security |
Anti-Pattern Checklist
| Anti-Pattern | Fix |
|---|---|
| Business logic in controllers | Delegate to application services |
| Returning entities directly | Convert to DTOs |
| Generic error messages | Use typed ProblemDetail with error URIs |
| Missing validation | Add @Valid on @RequestBody |
| Blocking calls in WebFlux | Use reactive operators only |
| Catching exceptions silently | Let propagate to @RestControllerAdvice |
Critical Reminders
1. Controllers are thin — Delegate to services, no business logic 2. Validate at the boundary — @Valid on all request bodies 3. Use ProblemDetail — Structured errors for all exceptions 4. Version from day one — Easier than retrofitting 5. `@MockitoBean` not `@MockBean` — Spring Boot 4 change
Spring Boot Web API Examples
Complete working examples for Spring Boot 4 REST API patterns.
REST Controller
Standard CRUD controller with pagination, validation, and proper HTTP status codes.
Java
@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderController {
private final OrderService orderService;
@GetMapping("/{id}")
public OrderDto getById(@PathVariable Long id) {
return orderService.findById(id);
}
@GetMapping
public Page<OrderSummary> list(
@RequestParam(defaultValue = "SUBMITTED") OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt", direction = DESC) Pageable pageable
) {
return orderService.findByStatus(status, pageable);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrderRequest request) {
OrderDto created = orderService.create(request);
URI location = URI.create("/api/orders/" + created.id());
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public OrderDto update(@PathVariable Long id, @Valid @RequestBody UpdateOrderRequest request) {
return orderService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
orderService.delete(id);
}
}Kotlin
@RestController
@RequestMapping("/api/orders")
@Validated
class OrderController(private val orderService: OrderService) {
@GetMapping("/{id}")
fun getById(@PathVariable id: Long): OrderDto = orderService.findById(id)
@PostMapping
fun create(@Valid @RequestBody request: CreateOrderRequest): ResponseEntity<OrderDto> {
val created = orderService.create(request)
return ResponseEntity
.created(URI.create("/api/orders/${created.id}"))
.body(created)
}
}Key points:
- Use
@Validatedat class level for method parameter validation - Return
ResponseEntity.created()with Location header for POST - Use
@PageableDefaultfor sensible pagination defaults
---
Request/Response DTOs
Records for immutable request/response objects with Bean Validation 3.1.
public record CreateOrderRequest(
@NotNull CustomerId customerId,
@NotEmpty List<@Valid OrderLineRequest> lines
) {}
public record OrderLineRequest(
@NotNull ProductId productId,
@Min(1) int quantity
) {}
public record OrderDto(
Long id,
String status,
BigDecimal totalAmount,
List<OrderLineDto> lines,
Instant createdAt
) {
public static OrderDto from(Order order) {
return new OrderDto(
order.getId(),
order.getStatus().name(),
order.getTotal().amount(),
order.getLines().stream().map(OrderLineDto::from).toList(),
order.getCreatedAt()
);
}
}Key points:
- Use records for immutable DTOs
- Add
@ValidbeforeList<>type for nested validation - Provide static factory
from()for entity-to-DTO conversion
---
Global Exception Handler
Structured error responses using RFC 9457 ProblemDetail.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage()
);
problem.setType(URI.create("https://api.example.com/errors/not-found"));
problem.setTitle("Resource Not Found");
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("resourceId", ex.getResourceId());
return problem;
}
@ExceptionHandler(BusinessRuleException.class)
public ProblemDetail handleBusinessRule(BusinessRuleException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()
);
problem.setType(URI.create("https://api.example.com/errors/business-rule"));
problem.setTitle("Business Rule Violation");
return problem;
}
}Key points:
- Extend
ResponseEntityExceptionHandlerfor validation error handling - Use unique URIs for error types (documentation references)
- Add custom properties with
setProperty()for error details
---
Native API Versioning (Spring Boot 4)
Built-in API versioning via header or path.
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping(path = "/{id}", version = "1.0")
public ProductV1 getV1(@PathVariable String id) {
return productService.findByIdV1(id);
}
@GetMapping(path = "/{id}", version = "2.0")
public ProductV2 getV2(@PathVariable String id) {
return productService.findByIdV2(id);
}
}# application.properties
spring.mvc.apiversion.use.header=API-Version
spring.mvc.apiversion.default=1
spring.mvc.apiversion.supported=1,2Key points:
- Use
versionattribute in mapping annotations - Configure default version for backward compatibility
- Header-based versioning keeps URLs clean
---
Jackson 3 Configuration
Custom JSON serialization for Spring Boot 4.
@Configuration
public class JacksonConfig {
@Bean
public Jackson3ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.serializationInclusion(JsonInclude.Include.NON_NULL);
}
}Key points:
- Jackson 3 uses
tools.jacksonpackage (notcom.fasterxml.jackson) - ISO dates by default with
WRITE_DATES_AS_TIMESTAMPSdisabled - Fail on unknown properties for strict API contracts
---
Controller Testing
WebMvcTest with MockitoBean for slice testing.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@Test
void createOrder_ValidInput_ReturnsCreated() throws Exception {
var request = new CreateOrderRequest(CustomerId.generate(), List.of());
var response = new OrderDto(1L, "DRAFT", BigDecimal.ZERO, List.of(), Instant.now());
when(orderService.create(any())).thenReturn(response);
mockMvc.perform(post("/api/orders")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(1));
}
}Key points:
- Use
@MockitoBean(not@MockBean) in Spring Boot 4 - Test validation, status codes, and response structure
- Verify Location header for POST endpoints
Controllers & Validation
Detailed patterns for REST controllers and Bean Validation 3.1.
Table of Contents
- Controller Structure
- Complete CRUD Controller (Java)
- Kotlin Controller
- Bean Validation
- Request DTOs with Validation
- Kotlin Request DTOs
- Validation Groups
- Custom Validator
- Cross-Field Validation
- Content Negotiation
- Response Patterns
- ResponseEntity Usage
- Path Variables & Query Parameters
- File Upload
- Async Controller Methods
Controller Structure
Complete CRUD Controller (Java)
@RestController
@RequestMapping("/api/v1/orders")
@Validated
@Tag(name = "Orders", description = "Order management endpoints")
public class OrderController {
private final OrderService orderService;
private final OrderAssembler assembler;
public OrderController(OrderService orderService, OrderAssembler assembler) {
this.orderService = orderService;
this.assembler = assembler;
}
@GetMapping("/{id}")
@Operation(summary = "Get order by ID")
public ResponseEntity<OrderDto> getById(@PathVariable Long id) {
return orderService.findById(id)
.map(assembler::toDto)
.map(ResponseEntity::ok)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@GetMapping
@Operation(summary = "List orders with pagination")
public Page<OrderSummary> list(
@RequestParam(required = false) OrderStatus status,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate to,
@PageableDefault(size = 20, sort = "createdAt", direction = DESC) Pageable pageable
) {
return orderService.search(new OrderSearchCriteria(status, from, to), pageable);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Create new order")
public ResponseEntity<OrderDto> create(
@Valid @RequestBody CreateOrderRequest request,
UriComponentsBuilder uriBuilder
) {
Order order = orderService.create(request);
URI location = uriBuilder.path("/api/v1/orders/{id}").buildAndExpand(order.getId()).toUri();
return ResponseEntity.created(location).body(assembler.toDto(order));
}
@PutMapping("/{id}")
@Operation(summary = "Update order")
public OrderDto update(
@PathVariable Long id,
@Valid @RequestBody UpdateOrderRequest request
) {
return assembler.toDto(orderService.update(id, request));
}
@PatchMapping("/{id}/status")
@Operation(summary = "Update order status")
public OrderDto updateStatus(
@PathVariable Long id,
@Valid @RequestBody UpdateStatusRequest request
) {
return assembler.toDto(orderService.updateStatus(id, request.status()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Delete order")
public void delete(@PathVariable Long id) {
orderService.delete(id);
}
@PostMapping("/{id}/submit")
@Operation(summary = "Submit order for processing")
public OrderDto submit(@PathVariable Long id) {
return assembler.toDto(orderService.submit(id));
}
}Kotlin Controller
@RestController
@RequestMapping("/api/v1/orders")
@Validated
class OrderController(
private val orderService: OrderService,
private val assembler: OrderAssembler
) {
@GetMapping("/{id}")
fun getById(@PathVariable id: Long): ResponseEntity<OrderDto> =
orderService.findById(id)
?.let { assembler.toDto(it) }
?.let { ResponseEntity.ok(it) }
?: throw OrderNotFoundException(id)
@PostMapping
fun create(
@Valid @RequestBody request: CreateOrderRequest,
uriBuilder: UriComponentsBuilder
): ResponseEntity<OrderDto> {
val order = orderService.create(request)
val location = uriBuilder.path("/api/v1/orders/{id}").buildAndExpand(order.id).toUri()
return ResponseEntity.created(location).body(assembler.toDto(order))
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(@PathVariable id: Long) = orderService.delete(id)
}Bean Validation
Request DTOs with Validation
public record CreateOrderRequest(
@NotNull(message = "Customer ID is required")
CustomerId customerId,
@NotEmpty(message = "Order must have at least one line")
@Size(max = 100, message = "Maximum 100 lines per order")
List<@Valid OrderLineRequest> lines,
@Size(max = 500, message = "Notes must be under 500 characters")
String notes
) {}
public record OrderLineRequest(
@NotNull(message = "Product ID is required")
ProductId productId,
@Min(value = 1, message = "Quantity must be at least 1")
@Max(value = 1000, message = "Quantity cannot exceed 1000")
int quantity,
@DecimalMin(value = "0.01", message = "Price must be positive")
BigDecimal unitPrice
) {}Kotlin Request DTOs
data class CreateOrderRequest(
@field:NotNull(message = "Customer ID is required")
val customerId: CustomerId,
@field:NotEmpty(message = "Order must have at least one line")
@field:Size(max = 100)
val lines: List<@Valid OrderLineRequest>,
@field:Size(max = 500)
val notes: String? = null
)
data class OrderLineRequest(
@field:NotNull
val productId: ProductId,
@field:Min(1) @field:Max(1000)
val quantity: Int,
@field:DecimalMin("0.01")
val unitPrice: BigDecimal
)Note: Kotlin requires @field: prefix for annotations to target the backing field.
Validation Groups
// Define groups
public interface OnCreate {}
public interface OnUpdate {}
// Use in DTO
public record OrderRequest(
@Null(groups = OnCreate.class, message = "ID must be null on create")
@NotNull(groups = OnUpdate.class, message = "ID required on update")
Long id,
@NotNull(groups = {OnCreate.class, OnUpdate.class})
String name
) {}
// Apply in controller
@PostMapping
public OrderDto create(@Validated(OnCreate.class) @RequestBody OrderRequest request) { }
@PutMapping("/{id}")
public OrderDto update(@Validated(OnUpdate.class) @RequestBody OrderRequest request) { }Custom Validator
// Annotation
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = ValidOrderStatusTransitionValidator.class)
public @interface ValidOrderStatusTransition {
String message() default "Invalid status transition";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
OrderStatus from();
}
// Validator
public class ValidOrderStatusTransitionValidator
implements ConstraintValidator<ValidOrderStatusTransition, OrderStatus> {
private OrderStatus fromStatus;
@Override
public void initialize(ValidOrderStatusTransition annotation) {
this.fromStatus = annotation.from();
}
@Override
public boolean isValid(OrderStatus toStatus, ConstraintValidatorContext context) {
if (toStatus == null) return true;
return fromStatus.canTransitionTo(toStatus);
}
}Cross-Field Validation
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "End date must be after start date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, DateRangeRequest> {
@Override
public boolean isValid(DateRangeRequest request, ConstraintValidatorContext context) {
if (request.startDate() == null || request.endDate() == null) return true;
return request.endDate().isAfter(request.startDate());
}
}
@ValidDateRange
public record DateRangeRequest(
@NotNull LocalDate startDate,
@NotNull LocalDate endDate
) {}Content Negotiation
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// Multiple representations
@GetMapping(value = "/{id}", produces = {APPLICATION_JSON_VALUE, APPLICATION_XML_VALUE})
public OrderDto getById(@PathVariable Long id) {
return orderService.findById(id);
}
// Specific format endpoint
@GetMapping(value = "/{id}/pdf", produces = APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> getAsPdf(@PathVariable Long id) {
byte[] pdf = orderService.generatePdf(id);
return ResponseEntity.ok()
.header(CONTENT_DISPOSITION, "attachment; filename=order-" + id + ".pdf")
.body(pdf);
}
// Accept specific content type
@PostMapping(consumes = APPLICATION_JSON_VALUE)
public OrderDto createFromJson(@Valid @RequestBody CreateOrderRequest request) { }
@PostMapping(consumes = "text/csv")
public List<OrderDto> createFromCsv(@RequestBody String csvContent) { }
}Response Patterns
ResponseEntity Usage
// Created with location header
@PostMapping
public ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrderRequest request) {
Order order = orderService.create(request);
return ResponseEntity
.created(URI.create("/api/orders/" + order.getId()))
.body(OrderDto.from(order));
}
// No content
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
orderService.delete(id);
return ResponseEntity.noContent().build();
}
// Conditional response
@GetMapping("/{id}")
public ResponseEntity<OrderDto> getById(
@PathVariable Long id,
@RequestHeader(value = IF_NONE_MATCH, required = false) String ifNoneMatch
) {
Order order = orderService.findById(id);
String etag = "\"" + order.getVersion() + "\"";
if (etag.equals(ifNoneMatch)) {
return ResponseEntity.status(NOT_MODIFIED).build();
}
return ResponseEntity.ok()
.eTag(etag)
.body(OrderDto.from(order));
}Path Variables & Query Parameters
@GetMapping("/{orderId}/lines/{lineId}")
public OrderLineDto getLine(
@PathVariable Long orderId,
@PathVariable Long lineId
) { }
// Matrix variables (rare)
@GetMapping("/filter/{criteria}")
public List<OrderDto> filter(
@MatrixVariable Map<String, String> criteria
) { }
// URL: /filter/status=SUBMITTED;minAmount=100
// Optional query params
@GetMapping
public Page<OrderDto> search(
@RequestParam(required = false) String query,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "DESC") Sort.Direction direction,
Pageable pageable
) { }File Upload
@PostMapping(value = "/{id}/attachments", consumes = MULTIPART_FORM_DATA_VALUE)
public AttachmentDto uploadAttachment(
@PathVariable Long id,
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String description
) {
if (file.isEmpty()) {
throw new BadRequestException("File is empty");
}
if (file.getSize() > 10_000_000) {
throw new BadRequestException("File too large (max 10MB)");
}
return attachmentService.store(id, file, description);
}
// Multiple files
@PostMapping(value = "/{id}/attachments/batch", consumes = MULTIPART_FORM_DATA_VALUE)
public List<AttachmentDto> uploadMultiple(
@PathVariable Long id,
@RequestParam("files") List<MultipartFile> files
) {
return files.stream()
.map(f -> attachmentService.store(id, f, null))
.toList();
}Async Controller Methods
@GetMapping("/{id}/report")
public CompletableFuture<ReportDto> generateReport(@PathVariable Long id) {
return CompletableFuture.supplyAsync(() -> reportService.generate(id));
}
// Streaming response
@GetMapping(value = "/{id}/events", produces = TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents(@PathVariable Long id) {
SseEmitter emitter = new SseEmitter(30_000L);
eventService.subscribe(id, event -> {
try {
emitter.send(event);
} catch (IOException e) {
emitter.completeWithError(e);
}
});
return emitter;
}Error Handling with ProblemDetail
RFC 9457 (formerly RFC 7807) compliant error responses in Spring Boot 4.
Table of Contents
- ProblemDetail Structure
- Global Exception Handler
- Java
- Kotlin
- Exception Hierarchy
- Error Type Registry
- Custom ProblemDetail Subclass
- Per-Controller Exception Handling
- Testing Error Responses
- Configuration
- Best Practices
ProblemDetail Structure
{
"type": "https://api.example.com/errors/order-not-found",
"title": "Order Not Found",
"status": 404,
"detail": "Order with ID 12345 was not found",
"instance": "/api/orders/12345",
"orderId": 12345,
"timestamp": "2025-12-20T10:30:00Z"
}| Field | Required | Description |
|---|---|---|
type | Yes | URI identifying error type (for client handling) |
title | Yes | Short human-readable summary |
status | Yes | HTTP status code |
detail | No | Human-readable explanation |
instance | No | URI of specific occurrence |
| Custom fields | No | Additional context (orderId, timestamp, etc.) |
Global Exception Handler
Java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final String ERROR_BASE_URI = "https://api.example.com/errors/";
// Domain exceptions
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
ex.getMessage()
);
problem.setType(URI.create(ERROR_BASE_URI + "resource-not-found"));
problem.setTitle("Resource Not Found");
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("resourceType", ex.getResourceType());
problem.setProperty("resourceId", ex.getResourceId());
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(BusinessRuleViolationException.class)
public ProblemDetail handleBusinessRule(BusinessRuleViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY,
ex.getMessage()
);
problem.setType(URI.create(ERROR_BASE_URI + "business-rule-violation"));
problem.setTitle("Business Rule Violation");
problem.setProperty("ruleCode", ex.getRuleCode());
return problem;
}
@ExceptionHandler(ConcurrentModificationException.class)
public ProblemDetail handleConcurrency(ConcurrentModificationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"Resource was modified by another request. Please retry."
);
problem.setType(URI.create(ERROR_BASE_URI + "concurrent-modification"));
problem.setTitle("Concurrent Modification");
return problem;
}
// Validation errors (override from ResponseEntityExceptionHandler)
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Validation failed for the request"
);
problem.setType(URI.create(ERROR_BASE_URI + "validation-error"));
problem.setTitle("Validation Error");
List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(error -> Map.of(
"field", error.getField(),
"message", error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid value",
"rejectedValue", String.valueOf(error.getRejectedValue())
))
.toList();
problem.setProperty("errors", errors);
problem.setProperty("errorCount", errors.size());
return ResponseEntity.of(problem).build();
}
// Constraint violations (path/query params)
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Request parameters validation failed"
);
problem.setType(URI.create(ERROR_BASE_URI + "constraint-violation"));
problem.setTitle("Parameter Validation Error");
List<Map<String, String>> errors = ex.getConstraintViolations().stream()
.map(v -> Map.of(
"path", v.getPropertyPath().toString(),
"message", v.getMessage()
))
.toList();
problem.setProperty("errors", errors);
return problem;
}
// Catch-all for unexpected exceptions
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred. Please try again later."
);
problem.setType(URI.create(ERROR_BASE_URI + "internal-error"));
problem.setTitle("Internal Server Error");
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("traceId", MDC.get("traceId"));
return problem;
}
}Kotlin
@RestControllerAdvice
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
companion object {
private const val ERROR_BASE_URI = "https://api.example.com/errors/"
}
@ExceptionHandler(ResourceNotFoundException::class)
fun handleNotFound(ex: ResourceNotFoundException, request: HttpServletRequest): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Not found").apply {
type = URI.create("${ERROR_BASE_URI}resource-not-found")
title = "Resource Not Found"
instance = URI.create(request.requestURI)
setProperty("resourceId", ex.resourceId)
setProperty("timestamp", Instant.now())
}
@ExceptionHandler(BusinessRuleViolationException::class)
fun handleBusinessRule(ex: BusinessRuleViolationException): ProblemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.message ?: "").apply {
type = URI.create("${ERROR_BASE_URI}business-rule-violation")
title = "Business Rule Violation"
setProperty("ruleCode", ex.ruleCode)
}
}Exception Hierarchy
// Base exception
public abstract class DomainException extends RuntimeException {
private final String errorCode;
protected DomainException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
// Specific exceptions
public class ResourceNotFoundException extends DomainException {
private final String resourceType;
private final Object resourceId;
public ResourceNotFoundException(String resourceType, Object resourceId) {
super(resourceType + " with ID " + resourceId + " was not found", "RESOURCE_NOT_FOUND");
this.resourceType = resourceType;
this.resourceId = resourceId;
}
// Convenience factory methods
public static ResourceNotFoundException order(Long id) {
return new ResourceNotFoundException("Order", id);
}
public static ResourceNotFoundException customer(CustomerId id) {
return new ResourceNotFoundException("Customer", id.value());
}
}
public class BusinessRuleViolationException extends DomainException {
private final String ruleCode;
public BusinessRuleViolationException(String message, String ruleCode) {
super(message, "BUSINESS_RULE_VIOLATION");
this.ruleCode = ruleCode;
}
public static BusinessRuleViolationException emptyOrder() {
return new BusinessRuleViolationException(
"Cannot submit an empty order",
"ORDER_EMPTY"
);
}
public static BusinessRuleViolationException insufficientStock(ProductId productId) {
return new BusinessRuleViolationException(
"Insufficient stock for product " + productId.value(),
"INSUFFICIENT_STOCK"
);
}
}Error Type Registry
Define error URIs in a central place:
public final class ErrorTypes {
private static final String BASE = "https://api.example.com/errors/";
// 4xx Client Errors
public static final URI VALIDATION_ERROR = URI.create(BASE + "validation-error");
public static final URI RESOURCE_NOT_FOUND = URI.create(BASE + "resource-not-found");
public static final URI BUSINESS_RULE = URI.create(BASE + "business-rule-violation");
public static final URI CONCURRENT_MODIFICATION = URI.create(BASE + "concurrent-modification");
public static final URI UNAUTHORIZED = URI.create(BASE + "unauthorized");
public static final URI FORBIDDEN = URI.create(BASE + "forbidden");
// 5xx Server Errors
public static final URI INTERNAL_ERROR = URI.create(BASE + "internal-error");
public static final URI SERVICE_UNAVAILABLE = URI.create(BASE + "service-unavailable");
private ErrorTypes() {}
}Custom ProblemDetail Subclass
For consistent additional fields:
public class ApiProblemDetail extends ProblemDetail {
private Instant timestamp;
private String traceId;
public ApiProblemDetail(HttpStatus status, String detail) {
super(status.value());
setDetail(detail);
this.timestamp = Instant.now();
this.traceId = MDC.get("traceId");
}
public Instant getTimestamp() { return timestamp; }
public String getTraceId() { return traceId; }
public static ApiProblemDetail notFound(String detail) {
ApiProblemDetail problem = new ApiProblemDetail(HttpStatus.NOT_FOUND, detail);
problem.setType(ErrorTypes.RESOURCE_NOT_FOUND);
problem.setTitle("Resource Not Found");
return problem;
}
}Per-Controller Exception Handling
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// Controller-specific handler
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
ex.getMessage()
);
problem.setType(ErrorTypes.RESOURCE_NOT_FOUND);
problem.setTitle("Order Not Found");
problem.setProperty("orderId", ex.getOrderId());
return problem;
}
}Testing Error Responses
@WebMvcTest(OrderController.class)
class OrderControllerErrorTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@Test
void getOrder_NotFound_ReturnsProblemDetail() throws Exception {
when(orderService.findById(999L))
.thenThrow(ResourceNotFoundException.order(999L));
mockMvc.perform(get("/api/orders/999"))
.andExpect(status().isNotFound())
.andExpect(content().contentType(APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.type").value(containsString("resource-not-found")))
.andExpect(jsonPath("$.title").value("Resource Not Found"))
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.resourceId").value("999"));
}
@Test
void createOrder_InvalidInput_ReturnsValidationErrors() throws Exception {
String invalidRequest = """
{
"customerId": null,
"lines": []
}
""";
mockMvc.perform(post("/api/orders")
.contentType(APPLICATION_JSON)
.content(invalidRequest))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.type").value(containsString("validation-error")))
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[?(@.field == 'customerId')]").exists())
.andExpect(jsonPath("$.errors[?(@.field == 'lines')]").exists());
}
}Configuration
# Enable ProblemDetail for all Spring MVC exceptions (default in Boot 4)
spring.mvc.problemdetails.enabled=true
# Include exception message in response (development only!)
server.error.include-message=always
server.error.include-binding-errors=always
server.error.include-stacktrace=never
server.error.include-exception=falseBest Practices
1. Use URIs for types — Enables client-side error handling logic 2. Keep titles short — Human-readable category, not full explanation 3. Put details in detail — Specific explanation for this occurrence 4. Add traceId — Correlate with logs for debugging 5. Don't expose internals — No stack traces or internal paths in production 6. Document error types — Publish your error type URIs in API docs 7. Test error responses — Verify ProblemDetail structure in tests
WebFlux Reactive Patterns
Spring WebFlux for non-blocking reactive APIs.
Table of Contents
- When to Use WebFlux
- Annotated Controllers
- Java
- Kotlin with Coroutines
- Functional Router
- Java
- Kotlin coRouter DSL
- Reactive Operators Patterns
- WebTestClient
- Server-Sent Events (SSE)
- WebSocket
- Critical WebFlux Rules
When to Use WebFlux
| Use WebFlux | Use MVC (with Virtual Threads) |
|---|---|
| 10k+ concurrent connections | Standard REST APIs |
| Streaming real-time data | JPA/JDBC databases |
| Reactive databases (R2DBC, MongoDB Reactive) | Team unfamiliar with reactive |
| Microservices with many remote calls | Simpler debugging needed |
| Event-driven architectures | Blocking libraries in stack |
Spring Boot 4 recommendation: With Virtual Threads (spring.threads.virtual.enabled=true), MVC handles high concurrency without WebFlux complexity.
Annotated Controllers
Java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
@GetMapping("/{id}")
public Mono<OrderDto> getById(@PathVariable Long id) {
return orderService.findById(id)
.map(OrderDto::from)
.switchIfEmpty(Mono.error(new OrderNotFoundException(id)));
}
@GetMapping
public Flux<OrderDto> list(@RequestParam(required = false) OrderStatus status) {
return orderService.findByStatus(status)
.map(OrderDto::from);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<OrderDto> create(@Valid @RequestBody Mono<CreateOrderRequest> request) {
return request
.flatMap(orderService::create)
.map(OrderDto::from);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> delete(@PathVariable Long id) {
return orderService.delete(id);
}
// Streaming response
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OrderDto> streamOrders() {
return orderService.streamNewOrders()
.map(OrderDto::from);
}
}Kotlin with Coroutines
@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderService) {
@GetMapping("/{id}")
suspend fun getById(@PathVariable id: Long): OrderDto =
orderService.findById(id)?.let { OrderDto.from(it) }
?: throw OrderNotFoundException(id)
@GetMapping
fun list(@RequestParam status: OrderStatus?): Flow<OrderDto> =
orderService.findByStatus(status).map { OrderDto.from(it) }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(@Valid @RequestBody request: CreateOrderRequest): OrderDto =
OrderDto.from(orderService.create(request))
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
suspend fun delete(@PathVariable id: Long) = orderService.delete(id)
}Functional Router
Java
@Configuration
public class OrderRouter {
@Bean
public RouterFunction<ServerResponse> orderRoutes(OrderHandler handler) {
return RouterFunctions.route()
.path("/api/orders", builder -> builder
.GET("/{id}", accept(APPLICATION_JSON), handler::getById)
.GET("", accept(APPLICATION_JSON), handler::list)
.POST("", contentType(APPLICATION_JSON), handler::create)
.DELETE("/{id}", handler::delete)
)
.filter(this::errorHandler)
.build();
}
private Mono<ServerResponse> errorHandler(
ServerRequest request,
HandlerFunction<ServerResponse> next
) {
return next.handle(request)
.onErrorResume(OrderNotFoundException.class, e ->
ServerResponse.notFound().build())
.onErrorResume(IllegalArgumentException.class, e ->
ServerResponse.badRequest()
.bodyValue(ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, e.getMessage())));
}
}
@Component
public class OrderHandler {
private final OrderService orderService;
public Mono<ServerResponse> getById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return orderService.findById(id)
.flatMap(order -> ServerResponse.ok().bodyValue(OrderDto.from(order)))
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono<ServerResponse> list(ServerRequest request) {
Optional<OrderStatus> status = request.queryParam("status")
.map(OrderStatus::valueOf);
Flux<OrderDto> orders = orderService.findByStatus(status.orElse(null))
.map(OrderDto::from);
return ServerResponse.ok().body(orders, OrderDto.class);
}
public Mono<ServerResponse> create(ServerRequest request) {
return request.bodyToMono(CreateOrderRequest.class)
.flatMap(orderService::create)
.flatMap(order -> ServerResponse
.created(URI.create("/api/orders/" + order.getId()))
.bodyValue(OrderDto.from(order)));
}
public Mono<ServerResponse> delete(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return orderService.delete(id)
.then(ServerResponse.noContent().build());
}
}Kotlin coRouter DSL
@Configuration
class OrderRouter(private val handler: OrderHandler) {
@Bean
fun routes() = coRouter {
"/api/orders".nest {
accept(APPLICATION_JSON).nest {
GET("/{id}", handler::getById)
GET("", handler::list)
}
contentType(APPLICATION_JSON).nest {
POST("", handler::create)
}
DELETE("/{id}", handler::delete)
}
onError<OrderNotFoundException> { _, _ ->
ServerResponse.notFound().buildAndAwait()
}
}
}
@Component
class OrderHandler(private val orderService: OrderService) {
suspend fun getById(request: ServerRequest): ServerResponse {
val id = request.pathVariable("id").toLong()
return orderService.findById(id)
?.let { ServerResponse.ok().bodyValueAndAwait(OrderDto.from(it)) }
?: ServerResponse.notFound().buildAndAwait()
}
suspend fun list(request: ServerRequest): ServerResponse {
val status = request.queryParamOrNull("status")?.let { OrderStatus.valueOf(it) }
val orders = orderService.findByStatus(status).map { OrderDto.from(it) }
return ServerResponse.ok().bodyAndAwait(orders)
}
suspend fun create(request: ServerRequest): ServerResponse {
val body = request.awaitBody<CreateOrderRequest>()
val order = orderService.create(body)
return ServerResponse
.created(URI.create("/api/orders/${order.id}"))
.bodyValueAndAwait(OrderDto.from(order))
}
suspend fun delete(request: ServerRequest): ServerResponse {
val id = request.pathVariable("id").toLong()
orderService.delete(id)
return ServerResponse.noContent().buildAndAwait()
}
}Reactive Operators Patterns
// Chain operations
public Mono<OrderDto> processOrder(Long orderId) {
return orderRepository.findById(orderId)
.switchIfEmpty(Mono.error(new OrderNotFoundException(orderId)))
.flatMap(order -> {
order.process();
return orderRepository.save(order);
})
.doOnSuccess(order -> log.info("Processed order {}", order.getId()))
.map(OrderDto::from);
}
// Parallel execution
public Mono<OrderSummary> getOrderWithDetails(Long orderId) {
Mono<Order> orderMono = orderRepository.findById(orderId);
Mono<Customer> customerMono = orderMono
.flatMap(o -> customerService.findById(o.getCustomerId()));
Mono<List<Product>> productsMono = orderMono
.flatMapMany(o -> productService.findByIds(o.getProductIds()))
.collectList();
return Mono.zip(orderMono, customerMono, productsMono)
.map(tuple -> new OrderSummary(tuple.getT1(), tuple.getT2(), tuple.getT3()));
}
// Error handling
public Mono<OrderDto> createWithFallback(CreateOrderRequest request) {
return orderService.create(request)
.map(OrderDto::from)
.onErrorResume(ServiceUnavailableException.class, e -> {
log.warn("Service unavailable, using fallback");
return Mono.just(OrderDto.pending());
})
.timeout(Duration.ofSeconds(5))
.onErrorMap(TimeoutException.class, e ->
new ServiceUnavailableException("Order service timeout"));
}
// Retry with backoff
public Mono<Order> createWithRetry(CreateOrderRequest request) {
return orderService.create(request)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof TransientException)
.onRetryExhaustedThrow((spec, signal) -> signal.failure()));
}WebTestClient
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderApiTest {
@Autowired
private WebTestClient webClient;
@Test
void createOrder_ValidInput_ReturnsCreated() {
var request = new CreateOrderRequest(
CustomerId.generate(),
List.of(new OrderLineRequest(ProductId.generate(), 2, BigDecimal.TEN))
);
webClient.post()
.uri("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.exchange()
.expectStatus().isCreated()
.expectHeader().exists("Location")
.expectBody()
.jsonPath("$.id").isNotEmpty()
.jsonPath("$.status").isEqualTo("DRAFT");
}
@Test
void getOrder_NotFound_ReturnsProblemDetail() {
webClient.get()
.uri("/api/orders/99999")
.exchange()
.expectStatus().isNotFound()
.expectBody()
.jsonPath("$.type").value(containsString("not-found"))
.jsonPath("$.status").isEqualTo(404);
}
@Test
void streamOrders_ReturnsServerSentEvents() {
webClient.get()
.uri("/api/orders/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)
.returnResult(OrderDto.class)
.getResponseBody()
.take(3)
.collectList()
.block();
}
}Server-Sent Events (SSE)
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<OrderEvent>> streamEvents() {
return orderEventPublisher.getEvents()
.map(event -> ServerSentEvent.<OrderEvent>builder()
.id(event.getId().toString())
.event(event.getType().name())
.data(event)
.retry(Duration.ofSeconds(5))
.build());
}
// Client consumption
WebClient.create("http://localhost:8080")
.get()
.uri("/api/orders/events")
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<OrderEvent>>() {})
.subscribe(event -> {
log.info("Received event: {}", event.data());
});WebSocket
@Configuration
@EnableWebFlux
public class WebSocketConfig {
@Bean
public HandlerMapping handlerMapping(OrderWebSocketHandler handler) {
Map<String, WebSocketHandler> map = Map.of("/ws/orders", handler);
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setUrlMap(map);
mapping.setOrder(-1);
return mapping;
}
}
@Component
public class OrderWebSocketHandler implements WebSocketHandler {
private final OrderEventPublisher publisher;
@Override
public Mono<Void> handle(WebSocketSession session) {
Flux<WebSocketMessage> messages = publisher.getEvents()
.map(event -> session.textMessage(toJson(event)));
return session.send(messages);
}
}Critical WebFlux Rules
1. Never block — No .block(), Thread.sleep(), or blocking I/O 2. Use reactive all the way — One blocking call blocks the event loop 3. Subscribe carefully — Missing subscription = nothing happens 4. Handle errors — Use onErrorResume, onErrorMap, not try-catch 5. Backpressure — Use limitRate(), buffer() for fast producers 6. Context propagation — Use contextWrite() for MDC, security context
Spring Boot Web API Troubleshooting
Common issues and solutions for Spring Boot 4 REST APIs.
Common Issues
Issue: ProblemDetail Not Returning Correct Content-Type
Symptom: Error responses return text/plain instead of application/problem+json
Cause: Missing or incorrect Accept header handling
Solution:
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(ResourceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage()
);
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}Or ensure ProblemDetail is enabled globally:
spring.mvc.problemdetails.enabled=true---
Issue: Jackson 3 Serialization Changes Breaking API
Symptom: JSON output format changed after Boot 4 upgrade
Cause: Jackson 3 has different default behaviors
Solution:
1. Check package imports - Jackson 3 uses tools.jackson:
// Before (Jackson 2)
import com.fasterxml.jackson.annotation.JsonProperty;
// After (Jackson 3)
import tools.jackson.annotation.JsonProperty;2. Configure explicit serialization rules:
@Bean
public Jackson3ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.serializationInclusion(JsonInclude.Include.NON_NULL);
}---
Issue: API Versioning Not Matching Routes
Symptom: Requests return 404 despite valid path
Cause: Version header not sent or misconfigured
Solution:
1. Verify version is in request:
curl -H "API-Version: 2" http://localhost:8080/api/products/1232. Check configuration:
spring.mvc.apiversion.use.header=API-Version
spring.mvc.apiversion.default=1
spring.mvc.apiversion.supported=1,23. Ensure endpoint has version:
@GetMapping(path = "/{id}", version = "2.0")
public ProductV2 getV2(@PathVariable String id) { ... }---
Issue: CORS Preflight Failures
Symptom: Browser requests fail with CORS error, OPTIONS returns 403
Cause: Security configuration blocking preflight requests
Solution:
Configure CORS in security filter chain:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // For stateless API
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}---
Issue: @Valid Not Triggering Validation
Symptom: Invalid request bodies accepted without error
Cause: Missing @Valid annotation or validation dependency
Solution:
1. Add @Valid annotation:
@PostMapping
public OrderDto create(@Valid @RequestBody CreateOrderRequest request) {
// request is now validated
}2. Ensure validation starter is present:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>3. For nested objects, add @Valid in the DTO:
public record CreateOrderRequest(
@NotNull CustomerId customerId,
@NotEmpty List<@Valid OrderLineRequest> lines // @Valid for nested
) {}---
Issue: Pagination Parameters Not Binding
Symptom: Pageable always returns default values
Cause: Parameter names don't match Spring expectations
Solution:
Spring expects these parameter names by default:
page- page number (0-indexed)size- page sizesort- sort property and direction
# Correct
GET /api/orders?page=0&size=20&sort=createdAt,desc
# Wrong - using offset instead of page
GET /api/orders?offset=0&limit=20To customize parameter names:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
PageableHandlerMethodArgumentResolver resolver = new PageableHandlerMethodArgumentResolver();
resolver.setPageParameterName("offset");
resolver.setSizeParameterName("limit");
resolvers.add(resolver);
}
}---
Issue: ResponseEntity Location Header Missing Protocol
Symptom: Location header returns /api/orders/1 instead of full URL
Cause: Using relative URI instead of absolute
Solution:
Use ServletUriComponentsBuilder for full URL:
@PostMapping
public ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrderRequest request) {
OrderDto created = orderService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}---
Spring Boot 4 Migration Issues
ResponseEntityExceptionHandler Changes
// Boot 4 - override with ProblemDetail return type
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request
) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
status, "Validation failed"
);
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage
));
problem.setProperty("errors", errors);
return ResponseEntity.status(status).body(problem);
}MockMvc to MockMvcTester Migration
// Before (Boot 3.x)
mockMvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("SUBMITTED"));
// After (Boot 4.x) - optional, MockMvc still works
@Autowired
private MockMvcTester mvc;
assertThat(mvc.get().uri("/api/orders/1"))
.hasStatusOk()
.bodyJson()
.extractingPath("$.status").isEqualTo("SUBMITTED");Content Negotiation Defaults
Boot 4 changes default content negotiation:
# Explicit configuration for backward compatibility
spring.mvc.contentnegotiation.favor-parameter=false
spring.mvc.contentnegotiation.favor-path-extension=false
spring.mvc.contentnegotiation.parameter-name=formatSpring Boot Web API Workflow
Detailed step-by-step process for implementing REST APIs with Spring Boot 4.
---
Step 1: Create Controller
Set up a thin REST controller that delegates to services.
1a. Controller Structure
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
}1b. Technology Choice
| Choose | When |
|---|---|
Spring MVC (@RestController) | JPA/JDBC backend, simpler debugging, team knows imperative style |
| Spring WebFlux (functional router) | High concurrency (10k+ connections), streaming, reactive DB (R2DBC) |
With Java 21+ Virtual Threads (spring.threads.virtual.enabled=true), MVC handles high concurrency without WebFlux complexity.
1c. Controller Rules
- Thin controllers — No business logic, delegate to services
- DTOs for input/output — Never expose domain entities
- Consistent naming —
/api/v1/{resource}pattern
Output: Controller class with injected service dependencies.
---
Step 2: Define Endpoints
Map HTTP methods to controller methods.
2a. Standard CRUD Mapping
@GetMapping
public List<OrderDto> list() {
return orderService.findAll();
}
@GetMapping("/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderDto create(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
@PutMapping("/{id}")
public OrderDto update(@PathVariable Long id, @Valid @RequestBody UpdateOrderRequest request) {
return orderService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
orderService.delete(id);
}2b. Pagination
@GetMapping
public Page<OrderDto> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String[] sort
) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
return orderService.findAll(pageable);
}2c. @HttpExchange Declarative Client (Spring 7)
For consuming external APIs:
@HttpExchange(url = "/users", accept = "application/json")
public interface UserClient {
@GetExchange("/{id}")
User getUser(@PathVariable Long id);
@PostExchange
User createUser(@RequestBody CreateUserRequest request);
}Output: Endpoints mapped with proper HTTP methods and status codes.
---
Step 3: Add Validation
Validate request bodies at the API boundary.
3a. Bean Validation 3.1
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty List<@Valid OrderLineRequest> items,
@Size(max = 500) String notes
) {}
public record OrderLineRequest(
@NotNull Long productId,
@Positive int quantity,
@PositiveOrZero BigDecimal unitPrice
) {}3b. Custom Validators
@Constraint(validatedBy = UniqueEmailValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueEmail {
String message() default "Email already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}3c. Validation Groups
public interface OnCreate {}
public interface OnUpdate {}
public record UserRequest(
@Null(groups = OnCreate.class) Long id,
@NotBlank String name,
@NotBlank(groups = OnCreate.class) String password
) {}Output: Request validation with clear error messages.
---
Step 4: Handle Exceptions
Implement global error handling with ProblemDetail (RFC 9457).
4a. Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Order Not Found");
problem.setType(URI.create("https://api.example.com/errors/order-not-found"));
problem.setProperty("orderId", ex.getOrderId());
return problem;
}
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleValidation(ConstraintViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problem.setTitle("Validation Error");
problem.setProperty("violations", ex.getConstraintViolations().stream()
.map(v -> Map.of(
"field", v.getPropertyPath().toString(),
"message", v.getMessage()))
.toList());
return problem;
}
}4b. ProblemDetail Response Format
{
"type": "https://api.example.com/errors/order-not-found",
"title": "Order Not Found",
"status": 404,
"detail": "Order with ID 42 was not found",
"instance": "/api/v1/orders/42",
"orderId": 42
}4c. Enable ProblemDetail (Boot 4)
ProblemDetail is enabled by default in Spring Boot 4:
spring:
mvc:
problemdetails:
enabled: true # Default: true in Boot 44d. Exception Hierarchy
Define a structured exception hierarchy for clean error handling:
| Exception | HTTP Status | When |
|---|---|---|
ResourceNotFoundException | 404 | Entity not found by ID |
BusinessRuleViolationException | 422 | Domain invariant violated |
ConflictException | 409 | Concurrent modification or duplicate |
ConstraintViolationException | 400 | Bean validation failure |
Output: Structured error responses following RFC 9457.
---
Step 5: Configure Versioning
Set up API versioning for backward compatibility.
5a. Header-Based Versioning (Spring Boot 4)
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping(version = "1")
public OrderV1Dto getOrderV1(@PathVariable Long id) { ... }
@GetMapping(version = "2")
public OrderV2Dto getOrderV2(@PathVariable Long id) { ... }
}5b. Content Negotiation
spring:
mvc:
contentnegotiation:
favor-parameter: false
favor-path-extension: false5c. Jackson 3 Configuration (Spring Boot 4)
Jackson 3 uses the tools.jackson package:
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> builder
.featuresToEnable(SerializationFeature.INDENT_OUTPUT)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.modules(new JavaTimeModule());
}
}Important: Jackson 3 uses tools.jackson namespace, not com.fasterxml.jackson.
Output: Versioned API with proper content negotiation.
---
Verification Checklist
After implementing the web API:
- [ ] Controllers are thin — no business logic
- [ ] All
@RequestBodyparameters use@Valid - [ ]
@RestControllerAdvicehandles all exceptions with ProblemDetail - [ ] Response DTOs used (never entities)
- [ ] Proper HTTP status codes (
201 Created,204 No Content) - [ ] Jackson 3 configuration uses
tools.jacksonpackage - [ ] Tests with
@WebMvcTestcover endpoints — seespring-boot-testingskill