Spring · 06

Validation & Error Handling

Every API receives bad input and every API throws exceptions. What separates a trustworthy service from a leaky one is where the checks live, how failures are named, and what the client finally sees. This page follows a request through Bean Validation (@Valid, groups, custom constraints), through the exception-resolver chain, and out the other side as an RFC 9457 ProblemDetail — then rebuilds a miniature validator from reflection so you can see there is no magic.

9
Deep chapters
20+
Exception types mapped
RFC 9457
Problem details
1
Runnable mini-validator
01
Chapter One · Why It Exists

From Scattered if-Chains to Declared Rules

POST bad JSON @Valid 400 problem "email": must be a well-formed email "age": must be greater than or equal to 18 controller body never runs with invalid data

Input validation answers one question: is this data acceptable before any business logic touches it? Error handling answers the next: when something is not acceptable (or breaks), what does the caller see? Done by hand, both spread through every controller; done with Spring they become declarations (constraints on a DTO) plus one central translator (an advice class).

Before: manual checks and try/catch everywhere
Hand-rolled validation — repeated in every endpoint
@PostMapping("/users") ResponseEntity<?> create(@RequestBody Map<String, Object> body) { List<String> errors = new ArrayList<>(); String name = (String) body.get("username"); if (name == null || name.isBlank()) errors.add("username is required"); else if (name.length() < 3 || name.length() > 20) errors.add("username must be 3-20 chars"); String email = (String) body.get("email"); if (email == null || !email.matches(".+@.+\\..+")) errors.add("email is invalid"); Object age = body.get("age"); if (!(age instanceof Integer a) || a < 18) errors.add("age must be >= 18"); if (!errors.isEmpty()) return ResponseEntity.badRequest().body(Map.of("errors", errors)); // shape differs per developer try { return ResponseEntity.status(201).body(service.create(name, email, (Integer) age)); } catch (DuplicateUserException e) { return ResponseEntity.status(409).body(Map.of("message", e.getMessage())); // yet another shape } catch (Exception e) { return ResponseEntity.status(500).body(Map.of("error", e.toString())); // leaks internals } }
After: declare, validate, translate once
Constraints on the DTO + one advice
record CreateUser( @NotBlank @Size(min = 3, max = 20) String username, @NotBlank @Email String email, @NotNull @Min(18) Integer age) { } @PostMapping("/users") ResponseEntity<User> create(@Valid @RequestBody CreateUser body) { // @Valid triggers validation BEFORE the method body User u = service.create(body); return ResponseEntity.created(URI.create("/users/" + u.id())).body(u); } @RestControllerAdvice class ApiExceptionHandler extends ResponseEntityExceptionHandler { // one place decides the error JSON for the whole app @ExceptionHandler(DuplicateUserException.class) ProblemDetail duplicate(DuplicateUserException e) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage()); pd.setTitle("User already exists"); return pd; } }

Three things changed. The rules moved to metadata next to the fields they protect, so they can be reused (REST, messaging, batch import). The controller body only runs with valid data. And every error — validation failure, duplicate, unreadable JSON, wrong HTTP verb — leaves the service in the same shape.

Analogy: Validation is airport security. The scanner (Hibernate Validator) is configured once with the rules; every passenger (request) goes through it before the gate (your method). When something is flagged, one desk (the advice) writes the incident report in a standard form — the passenger never talks to a different officer with a different form each time.
LayerWhat it checksTechnologyFailure becomes
Transport / syntaxis the body parseable, are types rightJackson, ConversionService400 HttpMessageNotReadableException
Input shaperequired fields, ranges, formatsBean Validation on DTOs400 MethodArgumentNotValidException
Business rulesstock available, order not already shippedservice code + domain exceptions409 / 422 via advice
Data integrityunique keys, foreign keys, not-nulldatabase constraintsDataIntegrityViolationException to 409
Rule of thumb
  • Validate at every trust boundary: controller DTOs, message-queue payloads, config properties (@ConfigurationProperties + @Validated).
  • Bean Validation is for shape. Rules needing data (unique username) need a query — and still a database unique constraint, because two requests can pass the check simultaneously.
02
Chapter Two · Mental Model

Two Machines: Validation, then Exception Resolution

There are two separate machines here, and confusing them causes most debugging pain. Machine 1 (validation) decides whether an object is valid. It produces data — a set of ConstraintViolations, wrapped by Spring into a BindingResult. Machine 2 (exception resolution) decides what HTTP response an exception becomes. They meet at one point: Spring throws an exception when machine 1 finds violations, and machine 2 catches it.

@Valid @RequestBody CreateUser reaches RequestResponseBodyMethodProcessor
▶
Jackson builds the record, then the processor creates a WebDataBinder and calls validateIfApplicable because the parameter carries @Valid / @Validated
WebDataBinder delegates to the app Validator (a LocalValidatorFactoryBean wrapping Hibernate Validator)
▶
every constraint on every property is evaluated; all violations are collected, not just the first
Violations become FieldErrors in a BindingResult
▶
field name, rejected value, message, and the constraint code (NotBlank, Size) are preserved for error mapping
Binder finds errors and the parameter has no BindingResult / Errors parameter right after it
▶
the processor throws MethodArgumentNotValidException (holding the BindingResult). Add an Errors parameter and Spring injects it instead — you handle errors inline
Exception propagates to DispatcherServlet.processHandlerException
▶
ExceptionHandlerExceptionResolver looks for an @ExceptionHandler in the controller, then in each @ControllerAdvice (ordered), choosing the closest exception type
Handler returns ProblemDetail / ResponseEntity
▶
the return handler writes it through a message converter as application/problem+json — same path as a normal response
Validation then error mapping — a failing POST /users
Jackson reads JSON to CreateUser 1 syntax / type error: HttpMessageNotReadableException this happens BEFORE validation runs @Valid: DataBinder.validate 2 SpringValidatorAdapter to Hibernate Validator each constraint: ConstraintValidator.isValid(value, ctx) BindingResult with FieldErrors 3 field=email code=Email rejected=not-an-email field=age code=Min rejected=15 throw MethodArgumentNotValidException 4 controller method body is never entered implements ErrorResponse: default status 400 ExceptionHandlerExceptionResolver 5 ControllerAdviceBean list sorted by @Order ExceptionHandlerMethodResolver picks closest type Your @ExceptionHandler or REEH default 6 build ProblemDetail: status 400, title, errors[] property Converter writes problem+json 7 HttpEntityMethodProcessor + Jackson ProblemDetail mixin flattens properties into top-level JSON
⚠ Production gotchas & how to diagnose them:
  • @Valid seems to be ignored: either the validation provider is missing, or the object was not created by the binder (a service method call, a new inside the controller). Validation only fires at the points Spring wires it: MVC arguments, @Validated beans (method validation), @ConfigurationProperties, and JPA lifecycle. Start with spring-boot-starter-validation; with only the API jar you get jakarta.validation.NoProviderFoundException: Unable to create a Configuration, because no Jakarta Bean Validation provider could be found.
  • Wrong namespace: javax.validation.* annotations (Boot 2) are silently ignored in Boot 3, which only recognises jakarta.validation.*. Search for import javax.validation after any upgrade.
  • Only the first error reported: constraint validation collects every violation. If your JSON shows one, your handler only read getFieldError() (singular) instead of getFieldErrors().
  • Annotation on the wrong target: constraints on the record component work (Hibernate Validator reads them via the component's field/accessor); constraints on a @RequestParam String parameter need method validation (see Chapter 4).
03
Chapter Three · Constraints

The Constraint Toolkit & Null Semantics

Constraints are annotations from jakarta.validation.constraints; Hibernate Validator is the reference implementation that evaluates them. The most important detail is how each treats null: almost every constraint considers null valid (so absence is checked separately by @NotNull). This is by design — constraints compose.

ConstraintApplies tonull passes?RejectsNotes
@NotNullanynonullempty string is fine
@NotEmptyCharSequence, Collection, Map, arraynonull, length/size 0" " passes
@NotBlankCharSequencenonull, "", whitespace-onlythe right default for required text
@Size(min,max)CharSequence, Collection, Map, arrayyessize out of rangedoes not imply required
@Min @Maxnumeric (not float/double)yesout of rangeinteger types, BigDecimal, BigInteger
@DecimalMin @DecimalMaxBigDecimal, CharSequence, integersyesout of rangevalue is a string: "0.01"
@Positive @PositiveOrZeronumericyes≤ 0 / < 0clearer than @Min(1)
@Digits(integer, fraction)numeric, CharSequenceyestoo many digitsgood for money: @Digits(integer=10, fraction=2)
@Pattern(regexp)CharSequenceyesregex not matching the whole stringbeware catastrophic backtracking
@EmailCharSequenceyesmalformed addresslenient; a@b passes by default
@Past @Future (+OrPresent)java.time, Dateyeswrong side of "now"uses the validator's Clock
@AssertTrue on a getterbooleanyesfalsepoor-man cross-field check
@Validnested object / elementsyes (skipped)cascades into childrennot a constraint itself
A realistic DTO: records, nested objects, collections, container elements
record PlaceOrder( @NotBlank @Size(max = 40) String customerRef, @NotEmpty @Size(max = 50) // at least one, at most 50 lines List<@Valid OrderLine> lines, // @Valid on the element cascades into each line @NotNull @Valid Address shipTo, // nested object: cascade @Future LocalDate requestedDelivery, // null allowed (optional) Map<@NotBlank String, @Size(max = 100) String> metadata // keys and values both constrained ) { } record OrderLine( @NotBlank String sku, @Positive @Max(1000) int quantity, // primitive int: cannot be null, so no @NotNull needed @NotNull @Digits(integer = 8, fraction = 2) BigDecimal unitPrice) { } record Address(@NotBlank String line1, @Size(max = 80) String line2, @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode) { }
Dependencies and settings
<!-- pom.xml: since Boot 2.3 validation is NOT part of spring-boot-starter-web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> <!-- brings hibernate-validator + jakarta.validation-api --> </dependency>
Request JSONViolations produced
{"customerRef":"","lines":[],"shipTo":null}customerRef: must not be blank; lines: must not be empty; shipTo: must not be null
{"customerRef":"c1","lines":[{"sku":"","quantity":0,"unitPrice":1.234}],"shipTo":{"line1":"x","countryCode":"in"}}lines[0].sku: must not be blank; lines[0].quantity: must be greater than 0; lines[0].unitPrice: numeric value out of bounds (<8 digits>.<2 digits> expected); shipTo.countryCode: must match "[A-Z]{2}"
{"customerRef":"c1","lines":[null]...}no violation at all: @Valid skips null elements and @NotEmpty only checks the list size. Declare List<@NotNull @Valid OrderLine> to reject it
⚠ Production gotchas & how to diagnose them:
  • @NotNull vs @NotEmpty vs @NotBlank mix-ups: @NotNull String name accepts "" and " ". For required text use @NotBlank; for collections @NotEmpty. Symptom: blank names saved to the database.
  • @Size alone does not make a field required: @Size(min=3) on a null string passes. Add @NotBlank.
  • @Pattern needs a full match and a regex that is safe: (a+)+$ style patterns allow ReDoS on hostile input. Keep regexes simple and bound the length with @Size(max=...) first.
  • @Min on double is unsupported due to rounding; Hibernate Validator throws UnexpectedTypeException: HV000030: No validator could be found for constraint. Use @DecimalMin / BigDecimal. The same HV000030 appears when a constraint is put on an unsupported type (for example @NotBlank on Integer).
  • Collections without @Valid: List<OrderLine> does not validate its elements. You need List<@Valid OrderLine> (or @Valid on the field). Symptom: nested invalid data reaches the database.
  • Using entities as request bodies with JPA constraints: a failing entity constraint at persist time throws jakarta.validation.ConstraintViolationException inside the transaction, wrapped in TransactionSystemException — a 500 instead of a clean 400. Validate DTOs at the edge.
04
Chapter Four · Triggering Validation

@Valid vs @Validated, Groups & Method Validation

Two annotations trigger validation, and they are not interchangeable. @Valid (jakarta.validation.Valid) is the standard marker: "validate this and cascade into it". @Validated (org.springframework.validation.annotation.Validated) is Spring's variant: it additionally accepts validation groups and, on a class, turns on method-level validation through a proxy.

{{@Valid}}{{@Validated}}
OriginJakarta Bean ValidationSpring
Groupsno (always Default)yes: @Validated(OnCreate.class)
On a field / element (cascade)yesno — cascade requires @Valid
On an MVC @RequestBody / @ModelAttribute parameteryesyes
On a classno effectenables method validation on that bean via AOP proxy (MethodValidationPostProcessor)
Failure exception for bodyMethodArgumentNotValidExceptionsame
Validation groups: one DTO, different rules
Create vs update with groups
interface OnCreate { } interface OnUpdate { } record ProductDto( @Null(groups = OnCreate.class) // server assigns the id on create @NotNull(groups = OnUpdate.class) Long id, // required on update @NotBlank(groups = { OnCreate.class, OnUpdate.class }) String name, @NotNull(groups = OnCreate.class) @Positive BigDecimal price) { } // no groups => Default group only @PostMapping Product create(@Validated(OnCreate.class) @RequestBody ProductDto dto) { ... } @PutMapping("/{id}") Product update(@PathVariable long id, @Validated(OnUpdate.class) @RequestBody ProductDto dto) { ... } // Careful: @Validated(OnCreate.class) validates ONLY OnCreate constraints. The un-grouped @Positive (Default group) is skipped! // Fix: make the group extend Default: interface OnCreate extends Default { }

Groups are easy to misuse — the trap in the last two comments bites nearly everyone once. Often two small DTOs (CreateProduct, UpdateProduct) are clearer than groups; reserve groups for rules that truly vary between flows of the same type. @GroupSequence defines an ordered check ("run cheap format checks first, only then expensive ones") and stops at the first failing group.

Method validation: path variables, request params and services

Constraints on simple parameters (@PathVariable, @RequestParam, @RequestHeader) are not bean properties, so the binder cannot validate them. They are checked by method validation, and how it works changed in Spring Framework 6.1 (Boot 3.2):

SetupMechanismException on violationDefault status
Controller methods, Spring 6.1+, no class-level @ValidatedMVC itself validates arguments and return values (HandlerMethodValidationException)org.springframework.web.method.annotation.HandlerMethodValidationException400 (it is an ErrorResponse)
Class has @Validated (AOP proxy)MethodValidationInterceptor around the beanjakarta.validation.ConstraintViolationException500 unless you map it
Spring 6.0 / Boot 3.0-3.1only the AOP proxy existsConstraintViolationException500 unless you map it
@Service with @ValidatedAOP proxy (works for any bean)ConstraintViolationExceptiondepends on caller
Method validation in practice
@RestController @RequestMapping("/products") class ProductController { // Spring 6.1+: NO @Validated here @GetMapping("/{id}") Product get(@PathVariable @Positive long id, // 6.1+: violation -> HandlerMethodValidationException -> 400 @RequestParam(defaultValue = "10") @Max(100) int limit) { ... } @PostMapping Product create(@Valid @RequestBody ProductDto dto) { ... } // only @Valid on the body: resolver validates, MethodArgumentNotValidException @PostMapping("/import") Product importFrom(@Valid @RequestBody ProductDto dto, @RequestParam @NotBlank String source) { ... } // a constraint on ANOTHER parameter: method validation takes over } @Service @Validated // services: proxy-based, throws ConstraintViolationException class PricingService { BigDecimal quote(@NotBlank String sku, @Min(1) int qty) { ... } }
⚠ Production gotchas & how to diagnose them:
  • Constraint on @RequestParam does nothing on Boot 3.0/3.1 or when the controller is not proxied: add @Validated to the class (older setups), or upgrade to Spring 6.1+ and remove the class-level annotation. Diagnose by putting a deliberately violating request through and watching which exception appears (or none).
  • ConstraintViolationException becomes 500 because Spring does not map it. Add an @ExceptionHandler(ConstraintViolationException.class) returning 400 — its violations expose getPropertyPath() like get.id (method and parameter names, not JSON field names).
  • Class-level @Validated plus Spring 6.1 built-in validation means validation could run twice or the exception type differs between environments. Pick one mechanism per controller.
  • Self-invocation: @Validated on a service works through a proxy; calling this.quote(...) inside the same class bypasses it (same rule as @Transactional — see AOP & Proxies).
  • Groups skipping Default: @Validated(OnCreate.class) silently ignores un-grouped constraints unless the group extends Default. Symptom: "price" rules never fire on create but fire on nothing else.
  • Two exception types for "bad input": a method that mixes a @Valid @RequestBody with constraints on other parameters is handled by method validation, so even body errors can arrive as HandlerMethodValidationException instead of MethodArgumentNotValidException. Handle both types in your advice (extending ResponseEntityExceptionHandler covers both) and test each endpoint shape.
05
Chapter Five · Custom Rules

Writing Your Own Constraints

When the standard constraints are not enough ("password has no spaces", "end date after start date", "country code exists in our table") write your own. A constraint is two parts: an annotation that declares it, and a ConstraintValidator that evaluates it. The annotation must declare the three mandatory attributes message, groups and payload; Hibernate Validator rejects it otherwise.

Field-level constraint
@Documented @Constraint(validatedBy = StrongPasswordValidator.class) @Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT }) @Retention(RetentionPolicy.RUNTIME) public @interface StrongPassword { String message() default "{password.weak}"; // message key, resolved through message interpolation Class<?>[] groups() default { }; // mandatory Class<? extends Payload>[] payload() default { }; // mandatory int minLength() default 12; } class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> { private int minLength; @Override public void initialize(StrongPassword a) { this.minLength = a.minLength(); } @Override public boolean isValid(String value, ConstraintValidatorContext ctx) { if (value == null) return true; // convention: null is NotNull's job boolean ok = value.length() >= minLength && value.chars().anyMatch(Character::isUpperCase) && value.chars().anyMatch(Character::isDigit); if (!ok) { ctx.disableDefaultConstraintViolation(); // replace the generic message with a precise one ctx.buildConstraintViolationWithTemplate("must be at least " + minLength + " chars with an upper-case letter and a digit") .addConstraintViolation(); } return ok; } }
Cross-field rules: class-level constraints
End date must be after start date
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = DateRangeValidator.class) @interface ValidDateRange { String message() default "endDate must be after startDate"; Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { }; } class DateRangeValidator implements ConstraintValidator<ValidDateRange, Booking> { @Override public boolean isValid(Booking b, ConstraintValidatorContext ctx) { if (b.startDate() == null || b.endDate() == null) return true; // let @NotNull report it if (b.endDate().isAfter(b.startDate())) return true; ctx.disableDefaultConstraintViolation(); ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate()) .addPropertyNode("endDate") // attach the error to the field clients should fix .addConstraintViolation(); return false; } } @ValidDateRange record Booking(@NotNull LocalDate startDate, @NotNull LocalDate endDate) { }
Validators with dependencies

Spring creates ConstraintValidators through SpringConstraintValidatorFactory, so a validator can have constructor-injected beans. This is useful for lookups, but treat it carefully.

A validator that queries a repository
@Constraint(validatedBy = KnownCountryValidator.class) @Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT }) @Retention(RetentionPolicy.RUNTIME) @interface KnownCountry { String message() default "unknown country code"; Class<?>[] groups() default { }; Class<? extends Payload>[] payload() default { }; } class KnownCountryValidator implements ConstraintValidator<KnownCountry, String> { private final CountryRepository countries; // injected by SpringConstraintValidatorFactory KnownCountryValidator(CountryRepository countries) { this.countries = countries; } @Override public boolean isValid(String code, ConstraintValidatorContext ctx) { return code == null || countries.existsByCode(code); // one query per validated object: cache reference data } }
Messages and i18n
MechanismWhereExample
Literalannotation attribute@NotBlank(message = "name is required")
Key lookupmessage = "{user.name.required}"resolved from ValidationMessages.properties (Hibernate) and, in Boot, your messages.properties via the application MessageSource
Parametersattribute values in the template"size must be between {min} and {max}"
EL expressions"${validatedValue}" in the templateneeds an Expression Language implementation on the classpath; Boot's starter provides it
LocaleAccept-Language through LocaleResolverone messages_fr.properties per language
⚠ Production gotchas & how to diagnose them:
  • Missing groups or payload on the annotation: HV000074: ... contains Constraint annotation, but does not contain a groups parameter (or payload / message) at first use. Copy the boilerplate exactly.
  • Validator returns false for null: then @MyConstraint makes the field required, surprising callers who also use @NotNull deliberately. Convention: null is valid.
  • Uniqueness checks in validators ("username not taken") are race-prone: two concurrent requests both pass. Keep the validator for a friendly early message but rely on a unique index and map DataIntegrityViolationException to 409.
  • Expensive validators hit the database per element of a large list — a hidden N+1. Batch the lookup in the service layer instead.
  • Message keys displayed raw ({password.weak} shown to clients) mean the key was not found in ValidationMessages.properties/messages.properties, or the file lives in the wrong module/encoding. Check the classpath root and use UTF-8.
  • Class-level errors have an empty field name: without addPropertyNode they appear as global errors (getGlobalErrors()), which naive handlers that only read field errors drop.
06
Chapter Six · Exceptions

The Exception Landscape & Binding Errors

Validation is only one source of exceptions. Spring MVC throws a family of well-known exceptions for protocol and binding problems, and DefaultHandlerExceptionResolver maps them to status codes without any code from you. Knowing the table lets you recognise the cause from the status alone and decide which ones deserve a custom body.

ExceptionTypical causeDefault statusNotes
MethodArgumentNotValidException@Valid @RequestBody / @ModelAttribute failed constraints400holds BindingResult
HandlerMethodValidationExceptionmethod validation on controller (Spring 6.1+)400per-parameter validation results
HttpMessageNotReadableExceptionmalformed JSON, wrong field type, empty body400cause is a Jackson exception
MethodArgumentTypeMismatchException/orders/abc for a long path variable400subtype of TypeMismatchException
MissingServletRequestParameterExceptionrequired @RequestParam absent400
MissingRequestHeaderExceptionrequired header absent400subtype of ServletRequestBindingException
BindException@ModelAttribute binding error (query/form)400validation errors on non-body objects
HttpRequestMethodNotSupportedExceptionwrong verb405Allow header set
HttpMediaTypeNotSupportedExceptionunsupported Content-Type415
HttpMediaTypeNotAcceptableExceptionunsatisfiable Accept406
NoResourceFoundExceptionno handler or static resource (Spring 6.1+)404replaced NoHandlerFoundException as default
MissingPathVariableExceptionmapping has {id} but method lacks the parameter500server-side programming error
HttpMessageNotWritableExceptionJackson failed to serialise the response500often lazy proxy or recursion
AsyncRequestTimeoutExceptionasync handler exceeded its timeout503
ResponseStatusExceptionthrown deliberatelyits own statusresolved by ResponseStatusExceptionResolver
ConstraintViolationExceptionproxy-based method validation, JPA entity validationnone (500)you must map it
DataIntegrityViolationExceptionDB unique / FK / not-null violationnone (500)map to 409 / 400
OptimisticLockingFailureExceptionstale @Versionnone (500)map to 409 / 412
AccessDeniedExceptionauthorisation failedhandled by Spring Security filterssee Security page
HandlerExceptionResolver chain — first resolver to return a ModelAndView wins
Exception thrown 1 ExceptionHandlerExceptionResolver 2 ResponseStatusExceptionResolver 3 DefaultHandlerExceptionResolver @ExceptionHandler: controller, then @ControllerAdvice beans @ResponseStatus type, ResponseStatusException Spring web exceptions: 4xx/5xx No resolver: exception rethrown Container maps it to /error: BasicErrorController (JSON) or Whitelabel page server.error.include-message, -binding-errors, -stacktrace default to never
Anatomy of a binding error

A validation failure arrives as a BindingResult. Each problem is an ObjectError: a FieldError when attached to a property, a plain (global) error when attached to the object. Knowing which accessor holds which fact is what makes custom bodies possible.

AccessorExample valueUse it for
FieldError.getField()"email", "lines[0].sku"tell clients which input to fix (JSON path style)
getRejectedValue()"not-an-email"logging; do not echo back for passwords or personal data
getDefaultMessage()"must be a well-formed email address"human message (already i18n-resolved)
getCode()"Email"stable machine code for the client to switch on
getCodes()["Email.createUser.email", "Email.email", "Email.java.lang.String", "Email"]message-source lookup order (most specific first)
getArguments()constraint attributes (min, max, regexp)build parameterised messages
BindingResult.getGlobalErrors()class-level constraint failurescross-field errors
Reading a MethodArgumentNotValidException
@Override protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { List<Map<String, String>> errors = new ArrayList<>(); for (FieldError fe : ex.getBindingResult().getFieldErrors()) { errors.add(Map.of("field", fe.getField(), "code", String.valueOf(fe.getCode()), "message", String.valueOf(fe.getDefaultMessage()))); } for (ObjectError ge : ex.getBindingResult().getGlobalErrors()) { // easy to forget: class-level constraints errors.add(Map.of("field", "", "code", String.valueOf(ge.getCode()), "message", String.valueOf(ge.getDefaultMessage()))); } ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "One or more fields are invalid"); pd.setTitle("Validation failed"); pd.setProperty("errors", errors); return handleExceptionInternal(ex, pd, headers, status, request); }
⚠ Production gotchas & how to diagnose them:
  • Unmapped domain exceptions become 500 and you may see nothing useful in the log, because Spring logs resolved web exceptions (Resolved [...]) only at DEBUG. Always add a catch-all @ExceptionHandler(Exception.class) that logs at ERROR with the correlation id and returns a generic 500 body.
  • Catch-all hides Spring's own 4xx: a broad @ExceptionHandler(Exception.class) in a class that does not extend ResponseEntityExceptionHandler turns 405, 415, and unreadable JSON into 500. Extend ResponseEntityExceptionHandler so the specific handlers win.
  • Exceptions in a filter never reach advice; they surface through /error (BasicErrorController) with Boot's default JSON (timestamp, status, error, path).
  • Handler order clash: two advices handling the same exception: the first by @Order wins. Without @Order the order is undefined — add it.
  • Stack traces to clients: server.error.include-stacktrace=always (or on_param) is a data leak. Keep the defaults (never) in every non-local profile.
07
Chapter Seven · Translating Exceptions

@RestControllerAdvice & ProblemDetail (RFC 9457)

@RestControllerAdvice (= @ControllerAdvice + @ResponseBody) registers a bean whose @ExceptionHandler methods apply to every controller (or a chosen subset via basePackages, annotations, assignableTypes). Spring MVC resolves the handler by exception type: a handler declared for Exception is the fallback, DuplicateUserException beats it, and a handler in the controller itself beats any advice. The recommended body format is ProblemDetail, the Spring type for RFC 9457 ("Problem Details for HTTP APIs", obsoleting RFC 7807).

A ProblemDetail response — standard members (blue) and extension members (gold)
"type": "https://api.acme.io/problems/out-of-stock", "title": "Out of stock", "status": 409, "detail": "SKU K-100: requested 5, available 2", "instance": "/orders/812", "sku": "K-100", "available": 2 { } URI naming the KIND of problem (default about:blank) short summary, same for every occurrence mirrors the HTTP status explanation of THIS occurrence URI of this occurrence (request path) extension members: any extra fields added with pd.setProperty(name, value) Content-Type: application/problem+json
A complete advice class for an order API
@RestControllerAdvice class ApiExceptionHandler extends ResponseEntityExceptionHandler { // keeps Spring's handling of 405/415/406/400 as ProblemDetail private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class); // 1) Bean Validation on @RequestBody -- override the inherited handler to add field errors @Override protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request validation failed"); pd.setType(URI.create("https://api.acme.io/problems/validation")); pd.setTitle("Validation failed"); pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream() .map(fe -> new FieldViolation(fe.getField(), fe.getCode(), fe.getDefaultMessage())) .toList()); return handleExceptionInternal(ex, pd, headers, status, request); } // 2) Method validation (path/query params): ConstraintViolationException from @Validated beans @ExceptionHandler(ConstraintViolationException.class) ProblemDetail constraintViolation(ConstraintViolationException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Invalid request parameters"); pd.setTitle("Validation failed"); pd.setProperty("errors", ex.getConstraintViolations().stream() .map(v -> new FieldViolation(v.getPropertyPath().toString(), null, v.getMessage())).toList()); return pd; } // 3) Domain errors: map each to a status; keep messages client-safe @ExceptionHandler(OrderNotFoundException.class) ProblemDetail notFound(OrderNotFoundException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); pd.setTitle("Order not found"); return pd; } @ExceptionHandler(DataIntegrityViolationException.class) ProblemDetail conflict(DataIntegrityViolationException ex) { log.warn("Integrity violation", ex); // the SQL detail goes to the log, not the client return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The request conflicts with existing data"); } @ExceptionHandler(OptimisticLockingFailureException.class) ProblemDetail stale(OptimisticLockingFailureException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The resource was modified by someone else; reload and retry"); } // 4) Catch-all: never leak internals, always log with the correlation id @ExceptionHandler(Exception.class) ProblemDetail unexpected(Exception ex, HttpServletRequest req) { log.error("Unhandled exception on {} {}", req.getMethod(), req.getRequestURI(), ex); ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error"); pd.setProperty("correlationId", MDC.get("correlationId")); return pd; } record FieldViolation(String field, String code, String message) { } }
Two ways to get ProblemDetail
ApproachHowResult
Boot switchspring.mvc.problemdetails.enabled=trueBoot registers a ResponseEntityExceptionHandler advice; all Spring MVC exceptions render as application/problem+json with default texts
Your own adviceextend ResponseEntityExceptionHandlersame defaults and you override individual handleXxx methods; a user-defined REEH advice replaces Boot's
Exception that carries its problemextend ErrorResponseException (an ErrorResponse)the exception is the response description; no handler method needed when a REEH advice is active
Quick one-offthrow ResponseStatusExceptionstatus + reason; rendered as ProblemDetail when problem details are enabled
A domain exception that is its own error response
class OutOfStockException extends ErrorResponseException { OutOfStockException(String sku, int requested, int available) { super(HttpStatus.CONFLICT, problem(sku, requested, available), null); } private static ProblemDetail problem(String sku, int requested, int available) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "SKU " + sku + ": requested " + requested + ", available " + available); pd.setType(URI.create("https://api.acme.io/problems/out-of-stock")); pd.setTitle("Out of stock"); pd.setProperty("sku", sku); pd.setProperty("available", available); return pd; } } // service: throw new OutOfStockException("K-100", 5, 2); -- the advice class needs no extra method
What the client receives
HTTP/1.1 400 Bad Request Content-Type: application/problem+json { "type": "https://api.acme.io/problems/validation", "title": "Validation failed", "status": 400, "detail": "Request validation failed", "instance": "/users", "errors": [ { "field": "email", "code": "Email", "message": "must be a well-formed email address" }, { "field": "age", "code": "Min", "message": "must be greater than or equal to 18" } ] }
⚠ Production gotchas & how to diagnose them:
  • Two advices fight: your class extends ResponseEntityExceptionHandler and another advice also does. Spring picks one per exception unpredictably, and Boot backs off its own. Keep exactly one REEH advice; give narrower advices @Order and specific exceptions.
  • Returning ResponseEntity<String> from a handler sets text/plain; use ProblemDetail or a record so the converter produces JSON. Check the Content-Type in the actual response.
  • Handler for Exception swallows the status of ResponseStatusException: it returns 500 for exceptions that already carry a status. Add @ExceptionHandler(ResponseStatusException.class) or rethrow ErrorResponse types, or extend REEH (which handles them first).
  • Missing @RestControllerAdvice scanning: the advice class must be in the component-scan path. Symptom: validation errors still show Boot's default error JSON ("timestamp","status","error","path").
  • Logging every 4xx at ERROR buries real faults and costs money in log volume. Log client errors at INFO/WARN without stack traces; log 5xx at ERROR with the exception.
  • @ExceptionHandler parameters: only certain argument types are allowed (exception, request, response, WebRequest, Locale, Principal...). Asking for a @RequestBody or Model in a REST handler fails at startup or runtime with IllegalStateException: Could not resolve parameter.
08
Chapter Eight · Contract Design

Designing the Error Response

An error response is part of your API contract just like a success response. Clients write code against it: they retry on some, show text from others, log the rest. A good design is consistent (one shape everywhere), machine-readable (stable codes), human-friendly (clear message), safe (no internals), and traceable (an id to find the log line).

🧱

Consistent shape

  • one structure for validation, domain, framework and 500 errors
  • standard members + a documented extension set
  • ProblemDetail gives the base for free
🔖

Stable machine codes

  • clients switch on type / code not on message text
  • messages may be translated or reworded
  • never reuse a code for a different meaning
🔍

Traceable

  • include correlationId / trace id
  • log the cause server-side with that id
  • support can search one id and see the whole story
🔒

Safe

  • no stack traces, SQL, class names or file paths
  • do not echo secrets from rejected values
  • prefer 404 to 403 when existence itself is sensitive
SituationStatusWhy this oneRetry?
malformed JSON, wrong types, failed constraints400the request itself is wrong; fix and resendno
syntactically fine but breaks a business rule (cannot ship cancelled order)409 or 422409 = conflicts with current state; 422 = semantically invalid input. Pick one convention and document itno (until state changes)
duplicate unique value409conflicts with existing resourceno
optimistic lock / If-Match mismatch409 or 412resource changed since the client read itafter re-reading
not authenticated401credentials missing or invalidafter login
authenticated, not allowed403caller known, action deniedno
resource missing404no
rate limit exceeded429include Retry-Afteryes, later
downstream failure / overload502 / 503 / 504not the client's faultyes, with backoff
unexpected bug500generic message + correlation idmaybe; idempotent only
What Boot returns if you do nothing
Default BasicErrorController JSON (Boot 3)
{ "timestamp": "2025-06-01T10:15:30.123+00:00", "status": 500, "error": "Internal Server Error", "path": "/orders/7" } # server.error.include-message=never (default) - message hidden # server.error.include-binding-errors=never (default) - validation errors hidden # server.error.include-stacktrace=never (default) # server.error.include-exception=false (default)

The defaults are deliberately stingy: a 400 from validation arrives with no field details unless you handle it. That is the usual explanation for "my validation works but clients only see Bad Request". Do not fix it by flipping include-binding-errors to always in production — build a proper handler so the shape and wording are yours.

Wiring correlation ids into every error
// Filter puts the id into MDC (see the REST page). Every ProblemDetail then carries it: @ExceptionHandler(Exception.class) ProblemDetail unexpected(Exception ex) { String id = Optional.ofNullable(MDC.get("correlationId")).orElse("n/a"); log.error("[{}] unhandled", id, ex); ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error, quote id " + id); pd.setProperty("correlationId", id); return pd; }
⚠ Production gotchas & how to diagnose them:
  • Leaking internals: returning ex.getMessage() of a DataAccessException sends SQL, table and constraint names to the client — free reconnaissance for an attacker. Map to a generic message.
  • Echoing rejected values: including getRejectedValue() for a password or token field writes secrets into responses, access logs and browser history. Drop them for sensitive fields.
  • Message text as contract: clients that parse "must not be blank" break when you reword or translate. Provide and document a code.
  • Inconsistent shapes: validation returns an array, domain errors a string, framework errors Boot's default JSON. Clients need three parsers. Standardise through one advice plus spring.mvc.problemdetails.enabled=true.
  • Swallowed exceptions: catch (Exception e) { return ResponseEntity.badRequest().build(); } loses the cause. Let exceptions propagate to the advice, which logs once.
  • Error handling that throws: an exception inside an @ExceptionHandler falls through to the container error page. Keep handlers trivial and test them.
09
Chapter Nine · Build It Yourself

A 90-Line Mini Bean Validator

Hibernate Validator is large, but the core loop is small: read the constraint annotations of an object's properties, evaluate each against the value, collect violations with a property path, honour the active groups, and cascade into nested objects. The program below does exactly that for Java records in about 90 lines of plain Java 17 (no Spring, no Jakarta), then renders the violations as a problem-style body — the job of the advice class.

MiniValidator pieceReal counterpart
@NotBlank, @Size, @Min with message() / groups()Jakarta constraint annotations (mandatory message, groups, payload)
validate(bean, prefix, groups...) walking RecordComponentsValidatorImpl.validate + bean metadata (BeanMetaData)
applies(declared, active)group matching; no declared group means Default
@Valid recursion with path + "."cascaded validation, ConstraintViolation.getPropertyPath()
Violation(path, message, rejected)ConstraintViolation / FieldError
problem(List<Violation>)@RestControllerAdvice building ProblemDetail with errors
MiniValidator.java — compile with javac, run with java
import java.lang.annotation.*; import java.lang.reflect.RecordComponent; import java.util.*; import java.util.regex.Pattern; public class MiniValidator { // ---- constraint annotations (each carries its own message + groups, like Jakarta) @Retention(RetentionPolicy.RUNTIME) @interface NotBlank { String message() default "must not be blank"; Class<?>[] groups() default {}; } @Retention(RetentionPolicy.RUNTIME) @interface Size { int min() default 0; int max() default Integer.MAX_VALUE; String message() default "size must be between {min} and {max}"; Class<?>[] groups() default {}; } @Retention(RetentionPolicy.RUNTIME) @interface Min { long value(); String message() default "must be greater than or equal to {value}"; Class<?>[] groups() default {}; } @Retention(RetentionPolicy.RUNTIME) @interface Pattern_ { String regexp(); String message() default "must match {regexp}"; Class<?>[] groups() default {}; } @Retention(RetentionPolicy.RUNTIME) @interface Valid { } // cascade marker interface Default { } // groups interface OnCreate { } interface OnUpdate { } record Violation(String path, String message, Object rejected) { } // ---- the engine: walk record components, find constraint annotations, apply the active groups static List<Violation> validate(Object bean, String prefix, Class<?>... groups) { List<Violation> out = new ArrayList<>(); Set<Class<?>> active = new HashSet<>(Arrays.asList(groups.length == 0 ? new Class<?>[]{Default.class} : groups)); for (RecordComponent rc : bean.getClass().getRecordComponents()) { Object v; try { v = rc.getAccessor().invoke(bean); } catch (Exception e) { throw new RuntimeException(e); } String path = prefix + rc.getName(); if (rc.isAnnotationPresent(Valid.class) && v != null) out.addAll(validate(v, path + ".", groups)); if (rc.isAnnotationPresent(NotBlank.class) && applies(rc.getAnnotation(NotBlank.class).groups(), active) && (v == null || v.toString().isBlank())) out.add(new Violation(path, rc.getAnnotation(NotBlank.class).message(), v)); if (rc.isAnnotationPresent(Size.class) && applies(rc.getAnnotation(Size.class).groups(), active) && v != null) { Size s = rc.getAnnotation(Size.class); int len = v.toString().length(); if (len < s.min() || len > s.max()) out.add(new Violation(path, s.message().replace("{min}", "" + s.min()).replace("{max}", "" + s.max()), v)); } if (rc.isAnnotationPresent(Min.class) && applies(rc.getAnnotation(Min.class).groups(), active) && v != null) { Min m = rc.getAnnotation(Min.class); if (((Number) v).longValue() < m.value()) out.add(new Violation(path, m.message().replace("{value}", "" + m.value()), v)); } if (rc.isAnnotationPresent(Pattern_.class) && applies(rc.getAnnotation(Pattern_.class).groups(), active) && v != null) { Pattern_ p = rc.getAnnotation(Pattern_.class); if (!Pattern.matches(p.regexp(), v.toString())) out.add(new Violation(path, p.message().replace("{regexp}", p.regexp()), v)); } } return out; } // a constraint with no groups belongs to Default static boolean applies(Class<?>[] declared, Set<Class<?>> active) { if (declared.length == 0) return active.contains(Default.class); for (Class<?> g : declared) if (active.contains(g)) return true; return false; } // ---- application model record Address(@NotBlank String city, @Size(min = 5, max = 6) String zip) { } record SignUp( @NotBlank(groups = OnUpdate.class) String id, @NotBlank @Size(min = 3, max = 20) String username, @Pattern_(regexp = ".+@.+\\..+", message = "must be a well-formed email address") String email, @Min(18) int age, @Valid Address address) { } // ---- error mapping (what @ControllerAdvice does): violations -> RFC 9457-shaped body static String problem(List<Violation> vs) { StringBuilder sb = new StringBuilder("{\"type\":\"about:blank\",\"title\":\"Validation failed\",\"status\":400,\"errors\":["); for (int i = 0; i < vs.size(); i++) { Violation v = vs.get(i); sb.append(i > 0 ? "," : "").append("{\"field\":\"").append(v.path()).append("\",\"message\":\"").append(v.message()).append("\"}"); } return sb.append("]}").toString(); } public static void main(String[] args) { SignUp bad = new SignUp(null, " ", "not-an-email", 15, new Address("", "12")); System.out.println("== Default group (create) =="); validate(bad, "").forEach(v -> System.out.println(v.path() + " -> " + v.message() + " (rejected: '" + v.rejected() + "')")); System.out.println("== OnUpdate group only =="); validate(bad, "", OnUpdate.class).forEach(v -> System.out.println(v.path() + " -> " + v.message())); System.out.println("== Default + OnUpdate =="); System.out.println(validate(bad, "", Default.class, OnUpdate.class).size() + " violations"); SignUp ok = new SignUp("u1", "vinay", "v@x.io", 30, new Address("Pune", "41100")); System.out.println("valid bean -> " + validate(ok, "").size() + " violations"); System.out.println(problem(validate(bad, ""))); } }
Real output of java MiniValidator
== Default group (create) == username -> must not be blank (rejected: ' ') username -> size must be between 3 and 20 (rejected: ' ') email -> must be a well-formed email address (rejected: 'not-an-email') age -> must be greater than or equal to 18 (rejected: '15') address.city -> must not be blank (rejected: '') address.zip -> size must be between 5 and 6 (rejected: '12') == OnUpdate group only == id -> must not be blank == Default + OnUpdate == 7 violations valid bean -> 0 violations {"type":"about:blank","title":"Validation failed","status":400,"errors":[{"field":"username","message":"must not be blank"},{"field":"username","message":"size must be between 3 and 20"},{"field":"email","message":"must be a well-formed email address"},{"field":"age","message":"must be greater than or equal to 18"},{"field":"address.city","message":"must not be blank"},{"field":"address.zip","message":"size must be between 5 and 6"}]}

Notice what the output teaches:

  • All violations are collected in one pass (username fails two constraints) — the same behaviour as Hibernate Validator, which is why you must not stop at the first error in a handler.
  • Groups select rules. With OnUpdate only the id rule runs; the unannotated constraints belong to Default and are skipped. Passing both groups yields all 7 violations. This is the @Validated(OnCreate.class) trap from Chapter 4 in executable form.
  • Cascading needs @Valid: address.city and address.zip are reported only because the address component is marked @Valid.
  • null handling is per constraint. @Min and @Pattern_ skip null; @NotBlank does not — copy that convention in your own validators.
Extend it
  • Add a @NotNull constraint and a @Past date constraint; make @Size also accept List sizes.
  • Add a class-level constraint hook: scan the type for an annotation whose validator receives the whole record (cross-field rules).
  • Return a code (annotation simple name) next to each message — that is the stable machine code Chapter 8 recommends.
10
Chapter Ten · Interview Angles

Questions You Will Be Asked

  • What is the difference between @NotNull, @NotEmpty and @NotBlank?
    @NotNull: not null. @NotEmpty: not null and size > 0 (strings, collections, maps, arrays). @NotBlank: strings only, not null and at least one non-whitespace character.
  • @Valid vs @Validated?
    @Valid is Jakarta: triggers and cascades, no groups. @Validated is Spring: supports groups and, on a class, enables proxy-based method validation. Use @Valid on nested objects, either on controller parameters.
  • Which exception does a failed @Valid @RequestBody throw, and what status results?
    MethodArgumentNotValidException (holds a BindingResult) — 400 by default, because it implements ErrorResponse.
  • How do you validate @PathVariable or @RequestParam?
    Put constraints directly on the parameter. Spring 6.1+ applies built-in method validation (throws HandlerMethodValidationException, 400); earlier versions need class-level @Validated and throw ConstraintViolationException, which you must map yourself.
  • How do you write a custom constraint?
    An annotation with @Constraint(validatedBy=...) and the mandatory message, groups, payload, plus a ConstraintValidator<A,T>. Treat null as valid so @NotNull stays separate; Spring can inject dependencies into the validator.
  • How do you centralise error handling?
    @RestControllerAdvice with @ExceptionHandler methods, ideally extending ResponseEntityExceptionHandler so Spring MVC's own exceptions get the same format; return ProblemDetail.
  • What is ProblemDetail?
    Spring's model for RFC 9457 error bodies: type, title, status, detail, instance plus extension properties; served as application/problem+json. Enable for framework exceptions with spring.mvc.problemdetails.enabled=true.
  • Why might a validation constraint not run?
    No validation starter/provider, javax instead of jakarta imports, missing @Valid, a collection without element @Valid, a group that excludes it, or the object never passed through the binder or a validated proxy.
  • Why isn't Bean Validation enough for "username must be unique"?
    It cannot see concurrent requests; two can pass the check at once. Keep a database unique constraint and map DataIntegrityViolationException to 409.
  • 400 vs 422 vs 409 for business-rule failures?
    All defensible; be consistent. 400 for malformed/invalid input, 409 when the request conflicts with current resource state, 422 for well-formed but semantically unacceptable content. Document the choice.
  • Follow-up trap: why do exceptions thrown in a servlet filter ignore my advice?
    Filters run outside DispatcherServlet; @ControllerAdvice only wraps handler invocation. Handle them in the filter or delegate to a HandlerExceptionResolver.
  • Follow-up trap: you get 500 for ConstraintViolationException from a service. Why?
    Spring MVC only maps its own web exceptions. ConstraintViolationException (from @Validated service methods or JPA) must be handled explicitly — usually 400 for request data, but treat violations that come from your own persistence layer as a server bug.
11
Chapter Eleven · Summary

Cheat Sheet & Next Steps

NeedUseRemember
Validate request body@Valid @RequestBody + constraints on a recordthrows MethodArgumentNotValidException, 400
Validate path / queryconstraints on parametersSpring 6.1+: HandlerMethodValidationException; else class @Validated
Required text / collection@NotBlank / @NotEmptyother constraints let null pass
Nested objects, elements@Valid on field / List<@Valid T>no cascade without it
Create vs update rules@Validated(Group.class)groups skip Default unless extended
Custom ruleannotation + ConstraintValidatornull is valid; mandatory attributes
Central error mapping@RestControllerAdvice extending REEHone advice, one shape
Error body formatProblemDetail (RFC 9457)stable type/code, correlation id
Safetydefaults include-*=nevernever leak stack traces, SQL or secrets
∑ Validation & errors — what to remember
  • Two machines: validation produces violations; exception resolution turns exceptions into HTTP. They meet at MethodArgumentNotValidException.
  • Validate at the boundary with DTO records; keep entities and domain objects out of the HTTP contract.
  • Know your null semantics and always cascade with @Valid through collections and nested types.
  • One advice, one shape. Extend ResponseEntityExceptionHandler, return ProblemDetail, map the exceptions Spring does not (constraint, integrity, optimistic lock).
  • Design errors for clients and attackers alike: stable codes, correlation ids, no internals.
  • Next: persistence — what happens after valid data is accepted.
←

Previous: REST & the MVC Request Path

  • where argument resolvers and exception resolvers sit
→

Next: Spring Data JPA

  • persistence context, mapping, N+1
Related pages
  • AOP & Proxies — the proxy behind @Validated method validation.
  • Transactions — rollback rules and exceptions crossing a transactional boundary.
  • Spring Security — 401 / 403 handling happens in filters, before advice.
  • Testing — @WebMvcTest to assert validation errors and problem bodies.
  • Interview Q&A — more questions across all topics.