Endpoints, Validation and Errors
Expose the bookings endpoints with record DTOs, validate input with Bean Validation, and return every error as a typed Problem Details response.
@PostMapping
ResponseEntity<BookingView> create(@AuthenticationPrincipal Jwt jwt,
@Valid @RequestBody CreateBookingRequest body,
UriComponentsBuilder uri)
class BookingConflictException extends ErrorResponseException { ... }The controller is the thinnest layer in the project. It reads the request, validates it, takes the caller's identity from the token, calls the service and shapes the response. It never touches a repository and never sees an entity.
Requests and responses are records
CreateBookingRequest carries only what a client may set: room, start and end. The owner comes from the token and the status from the server, so a client cannot book on someone else's behalf by adding a field. BookingView is the response, with the room name flattened in so clients do not need a second call.
Validation in two places
Bean Validation on the record catches what can be judged from the request alone: missing fields, a start in the past, and an end before the start (an @AssertTrue method on the record). Rules that need configuration or the database, such as the minimum notice, the maximum length and the overlap, live in the service. Both kinds end up as Problem Details, so clients see one error format.
Status codes
- 201 with a
Locationheader built from the injectedUriComponentsBuilder, which starts from the request's scheme, host, port and context path, so it stays correct behind a proxy once forwarded headers are configured. - 400 for invalid input, with an
errorslist naming each field. - 404 for an unknown booking and for a booking that belongs to someone else. A 403 would confirm the ID exists.
- 409 for an overlap, a booking that has already started, and a lost optimistic-locking race.
- 422 for a request that is well formed but breaks a business rule, such as too little notice or a room ID that does not exist.
One error handler
Domain exceptions extend ErrorResponseException, which carries a status and a ProblemDetail. A @RestControllerAdvice extending ResponseEntityExceptionHandler renders those, and Spring MVC's own exceptions, as application/problem+json. It overrides handleMethodArgumentNotValid to add the field list, and maps ObjectOptimisticLockingFailureException to 409. Anything else falls through to Spring Boot's default error handling, which never includes the exception message or stack trace unless you configure it to.
Each type is a stable URI under your domain. Clients switch on type, not on title or detail, which are for people and may be reworded.
Pagination
The list endpoint takes a Pageable and returns PagedModel, which serialises to a stable content plus page shape. Returning a raw Page exposes Spring Data's internal structure, which has changed between versions. Spring Data already caps the page size at 2000; lower it with spring.data.web.pageable.max-page-size so a client asking for ?size=100000 gets a page you are happy to serve.
Check ?sort= against a short list of allowed properties. An unknown name otherwise reaches Spring Data as a PropertyReferenceException and the client gets a 500; the controller turns it into a 400 that lists the allowed names.
Example
import java.net.URI;
import java.time.Instant;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.web.PagedModel;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.util.UriComponentsBuilder;
record CreateBookingRequest(
Long roomId,
Instant startsAt,
Instant endsAt) {
// Reported as the field "endAfterStart" in the 400 response.
(message = "must be after startsAt")
boolean isEndAfterStart() {
return startsAt == null || endsAt == null || endsAt.isAfter(startsAt);
}
}
record BookingView(long id, long roomId, String roomName, Instant startsAt, Instant endsAt, String status) { }
// Signatures of the service from the persistence lesson, so this file compiles on its own.
class BookingService {
BookingView book(String ownerId, CreateBookingRequest request) { throw new UnsupportedOperationException(); }
BookingView get(String ownerId, long id) { throw new UnsupportedOperationException(); }
PagedModel<BookingView> list(String ownerId, Instant from, Pageable pageable) { throw new UnsupportedOperationException(); }
BookingView cancel(String ownerId, long id) { throw new UnsupportedOperationException(); }
}
("/api/bookings")
public class BookingController {
// Anything else in ?sort= is a 400, not a PropertyReferenceException and a 500.
private static final List<String> SORTABLE = List.of("startsAt", "endsAt", "id");
private final BookingService bookings;
BookingController(BookingService bookings) {
this.bookings = bookings;
}
ResponseEntity<BookingView> create( Jwt jwt,
CreateBookingRequest body,
UriComponentsBuilder uri) {
BookingView created = bookings.book(jwt.getSubject(), body);
URI location = uri.path("/api/bookings/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
PagedModel<BookingView> mine( Jwt jwt,
(required = false) Instant from,
(size = 20, sort = "startsAt", direction = Sort.Direction.ASC)
Pageable pageable) {
pageable.getSort().forEach(order -> {
if (!SORTABLE.contains(order.getProperty())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Cannot sort by '" + order.getProperty() + "'. Allowed: " + SORTABLE);
}
});
return bookings.list(jwt.getSubject(), from != null ? from : Instant.EPOCH, pageable);
}
("/{id}")
BookingView get( Jwt jwt, long id) {
return bookings.get(jwt.getSubject(), id);
}
("/{id}/cancel")
BookingView cancel( Jwt jwt, long id) {
return bookings.cancel(jwt.getSubject(), id);
}
}
When to use it
- Let a mobile app show "Room already booked" with a suggestion of free slots, by switching on the booking-conflict problem type and reading roomId.
- Highlight the exact form fields a user got wrong, using the errors list from a 400 response.
- Keep booking IDs from being enumerated by returning 404 for other users' bookings.
More examples
Domain exceptions that carry their own Problem Details
Because each exception builds its own <code>ProblemDetail</code>, the service just throws and the HTTP shape is decided once, next to the error it describes. Extra properties such as <code>roomId</code> appear as top-level JSON fields, which clients can use to highlight the right room. The detail text names only what the caller already sent, so nothing internal leaks.
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
public class BookingConflictException extends ErrorResponseException {
static final URI TYPE = URI.create("https://bookings.example.com/problems/booking-conflict");
public BookingConflictException(long roomId) {
super(HttpStatus.CONFLICT, problem(roomId), null);
}
private static ProblemDetail problem(long roomId) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Room " + roomId + " is already booked for part of that time.");
problem.setType(TYPE);
problem.setTitle("Room already booked");
problem.setProperty("roomId", roomId);
return problem;
}
}
class BookingNotFoundException extends ErrorResponseException {
BookingNotFoundException(long id) {
super(HttpStatus.NOT_FOUND, problem(id), null);
}
private static ProblemDetail problem(long id) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
"Booking " + id + " was not found.");
problem.setType(URI.create("https://bookings.example.com/problems/booking-not-found"));
problem.setTitle("Booking not found");
return problem;
}
}
class BookingRuleException extends ErrorResponseException {
BookingRuleException(String detail) {
super(HttpStatus.UNPROCESSABLE_CONTENT, problem(detail), null);
}
private static ProblemDetail problem(String detail) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, detail);
problem.setType(URI.create("https://bookings.example.com/problems/booking-rule"));
problem.setTitle("Booking breaks a rule");
return problem;
}
}
The error handler
<code>ResponseEntityExceptionHandler</code> already renders every <code>ErrorResponseException</code>, including the domain exceptions above, and Spring MVC's own errors such as 405 and 415, as Problem Details. Overriding one method keeps that behaviour and only enriches validation errors. The optimistic locking handler logs at info: it is an expected race, not a fault, and logging it as an error would page someone for nothing.
import java.net.URI;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
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;
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
// 400 with one entry per broken field or record-level rule.
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> Map.of("field", e.getField(),
"message", String.valueOf(e.getDefaultMessage())))
.toList();
ProblemDetail problem = ex.getBody();
problem.setType(URI.create("https://bookings.example.com/problems/invalid-request"));
problem.setTitle("Invalid request");
problem.setDetail("One or more fields are invalid.");
problem.setProperty("errors", errors);
return handleExceptionInternal(ex, problem, headers, status, request);
}
// Two people changed the same booking at once; the later one must reload and retry.
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
ProblemDetail concurrentUpdate(ObjectOptimisticLockingFailureException ex) {
log.info("Optimistic lock conflict on {}", ex.getPersistentClassName());
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The booking was changed by another request. Reload it and try again.");
problem.setType(URI.create("https://bookings.example.com/problems/concurrent-update"));
problem.setTitle("Concurrent update");
return problem;
}
}
What a validation failure looks like
The <code>@AssertTrue</code> method on the record is reported under the property name derived from it, <code>endAfterStart</code>. A front end can map known field names to inputs and show the rest as a general message. All problems are reported in one response, so users fix the form once instead of discovering errors one at a time.
{
"type": "https://bookings.example.com/problems/invalid-request",
"title": "Invalid request",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/api/bookings",
"errors": [
{ "field": "startsAt", "message": "must be a future date" },
{ "field": "endAfterStart", "message": "must be after startsAt" }
]
}
Discussion