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.
From Scattered if-Chains to Declared Rules
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).
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.
| Layer | What it checks | Technology | Failure becomes |
|---|---|---|---|
| Transport / syntax | is the body parseable, are types right | Jackson, ConversionService | 400 HttpMessageNotReadableException |
| Input shape | required fields, ranges, formats | Bean Validation on DTOs | 400 MethodArgumentNotValidException |
| Business rules | stock available, order not already shipped | service code + domain exceptions | 409 / 422 via advice |
| Data integrity | unique keys, foreign keys, not-null | database constraints | DataIntegrityViolationException to 409 |
- 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.
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 RequestResponseBodyMethodProcessorWebDataBinder and calls validateIfApplicable because the parameter carries @Valid / @ValidatedWebDataBinder delegates to the app Validator (a LocalValidatorFactoryBean wrapping Hibernate Validator)FieldErrors in a BindingResultNotBlank, Size) are preserved for error mappingBindingResult / Errors parameter right after itMethodArgumentNotValidException (holding the BindingResult). Add an Errors parameter and Spring injects it instead — you handle errors inlineDispatcherServlet.processHandlerExceptionExceptionHandlerExceptionResolver looks for an @ExceptionHandler in the controller, then in each @ControllerAdvice (ordered), choosing the closest exception typeProblemDetail / ResponseEntityapplication/problem+json — same path as a normal response- @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
newinside the controller). Validation only fires at the points Spring wires it: MVC arguments,@Validatedbeans (method validation),@ConfigurationProperties, and JPA lifecycle. Start withspring-boot-starter-validation; with only the API jar you getjakarta.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 recognisesjakarta.validation.*. Search forimport javax.validationafter any upgrade. - Only the first error reported: constraint validation collects every violation. If your JSON shows one, your handler only read
getFieldError()(singular) instead ofgetFieldErrors(). - Annotation on the wrong target: constraints on the
recordcomponent work (Hibernate Validator reads them via the component's field/accessor); constraints on a@RequestParam Stringparameter need method validation (see Chapter 4).
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.
| Constraint | Applies to | null passes? | Rejects | Notes |
|---|---|---|---|---|
@NotNull | any | no | null | empty string is fine |
@NotEmpty | CharSequence, Collection, Map, array | no | null, length/size 0 | " " passes |
@NotBlank | CharSequence | no | null, "", whitespace-only | the right default for required text |
@Size(min,max) | CharSequence, Collection, Map, array | yes | size out of range | does not imply required |
@Min @Max | numeric (not float/double) | yes | out of range | integer types, BigDecimal, BigInteger |
@DecimalMin @DecimalMax | BigDecimal, CharSequence, integers | yes | out of range | value is a string: "0.01" |
@Positive @PositiveOrZero | numeric | yes | ≤ 0 / < 0 | clearer than @Min(1) |
@Digits(integer, fraction) | numeric, CharSequence | yes | too many digits | good for money: @Digits(integer=10, fraction=2) |
@Pattern(regexp) | CharSequence | yes | regex not matching the whole string | beware catastrophic backtracking |
@Email | CharSequence | yes | malformed address | lenient; a@b passes by default |
@Past @Future (+OrPresent) | java.time, Date | yes | wrong side of "now" | uses the validator's Clock |
@AssertTrue on a getter | boolean | yes | false | poor-man cross-field check |
@Valid | nested object / elements | yes (skipped) | cascades into children | not a constraint itself |
| Request JSON | Violations 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 |
@NotNullvs@NotEmptyvs@NotBlankmix-ups:@NotNull String nameaccepts""and" ". For required text use@NotBlank; for collections@NotEmpty. Symptom: blank names saved to the database.@Sizealone does not make a field required:@Size(min=3)on anullstring passes. Add@NotBlank.@Patternneeds 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.@Minondoubleis unsupported due to rounding; Hibernate Validator throwsUnexpectedTypeException: 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@NotBlankonInteger).- Collections without
@Valid:List<OrderLine>does not validate its elements. You needList<@Valid OrderLine>(or@Validon 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.ConstraintViolationExceptioninside the transaction, wrapped inTransactionSystemException— a 500 instead of a clean 400. Validate DTOs at the edge.
@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}} | |
|---|---|---|
| Origin | Jakarta Bean Validation | Spring |
| Groups | no (always Default) | yes: @Validated(OnCreate.class) |
| On a field / element (cascade) | yes | no — cascade requires @Valid |
On an MVC @RequestBody / @ModelAttribute parameter | yes | yes |
| On a class | no effect | enables method validation on that bean via AOP proxy (MethodValidationPostProcessor) |
| Failure exception for body | MethodArgumentNotValidException | same |
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.
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):
| Setup | Mechanism | Exception on violation | Default status |
|---|---|---|---|
Controller methods, Spring 6.1+, no class-level @Validated | MVC itself validates arguments and return values (HandlerMethodValidationException) | org.springframework.web.method.annotation.HandlerMethodValidationException | 400 (it is an ErrorResponse) |
Class has @Validated (AOP proxy) | MethodValidationInterceptor around the bean | jakarta.validation.ConstraintViolationException | 500 unless you map it |
| Spring 6.0 / Boot 3.0-3.1 | only the AOP proxy exists | ConstraintViolationException | 500 unless you map it |
@Service with @Validated | AOP proxy (works for any bean) | ConstraintViolationException | depends on caller |
- Constraint on
@RequestParamdoes nothing on Boot 3.0/3.1 or when the controller is not proxied: add@Validatedto 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). ConstraintViolationExceptionbecomes 500 because Spring does not map it. Add an@ExceptionHandler(ConstraintViolationException.class)returning 400 — its violations exposegetPropertyPath()likeget.id(method and parameter names, not JSON field names).- Class-level
@Validatedplus 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:
@Validatedon a service works through a proxy; callingthis.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 extendsDefault. Symptom: "price" rules never fire on create but fire on nothing else. - Two exception types for "bad input": a method that mixes a
@Valid @RequestBodywith constraints on other parameters is handled by method validation, so even body errors can arrive asHandlerMethodValidationExceptioninstead ofMethodArgumentNotValidException. Handle both types in your advice (extendingResponseEntityExceptionHandlercovers both) and test each endpoint shape.
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.
Spring creates ConstraintValidators through SpringConstraintValidatorFactory, so a validator can have constructor-injected beans. This is useful for lookups, but treat it carefully.
| Mechanism | Where | Example |
|---|---|---|
| Literal | annotation attribute | @NotBlank(message = "name is required") |
| Key lookup | message = "{user.name.required}" | resolved from ValidationMessages.properties (Hibernate) and, in Boot, your messages.properties via the application MessageSource |
| Parameters | attribute values in the template | "size must be between {min} and {max}" |
| EL expressions | "${validatedValue}" in the template | needs an Expression Language implementation on the classpath; Boot's starter provides it |
| Locale | Accept-Language through LocaleResolver | one messages_fr.properties per language |
- Missing
groupsorpayloadon the annotation:HV000074: ... contains Constraint annotation, but does not contain a groups parameter(orpayload/message) at first use. Copy the boilerplate exactly. - Validator returns false for
null: then@MyConstraintmakes the field required, surprising callers who also use@NotNulldeliberately. Convention:nullis 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
DataIntegrityViolationExceptionto 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 inValidationMessages.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
addPropertyNodethey appear as global errors (getGlobalErrors()), which naive handlers that only read field errors drop.
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.
| Exception | Typical cause | Default status | Notes |
|---|---|---|---|
MethodArgumentNotValidException | @Valid @RequestBody / @ModelAttribute failed constraints | 400 | holds BindingResult |
HandlerMethodValidationException | method validation on controller (Spring 6.1+) | 400 | per-parameter validation results |
HttpMessageNotReadableException | malformed JSON, wrong field type, empty body | 400 | cause is a Jackson exception |
MethodArgumentTypeMismatchException | /orders/abc for a long path variable | 400 | subtype of TypeMismatchException |
MissingServletRequestParameterException | required @RequestParam absent | 400 | |
MissingRequestHeaderException | required header absent | 400 | subtype of ServletRequestBindingException |
BindException | @ModelAttribute binding error (query/form) | 400 | validation errors on non-body objects |
HttpRequestMethodNotSupportedException | wrong verb | 405 | Allow header set |
HttpMediaTypeNotSupportedException | unsupported Content-Type | 415 | |
HttpMediaTypeNotAcceptableException | unsatisfiable Accept | 406 | |
NoResourceFoundException | no handler or static resource (Spring 6.1+) | 404 | replaced NoHandlerFoundException as default |
MissingPathVariableException | mapping has {id} but method lacks the parameter | 500 | server-side programming error |
HttpMessageNotWritableException | Jackson failed to serialise the response | 500 | often lazy proxy or recursion |
AsyncRequestTimeoutException | async handler exceeded its timeout | 503 | |
ResponseStatusException | thrown deliberately | its own status | resolved by ResponseStatusExceptionResolver |
ConstraintViolationException | proxy-based method validation, JPA entity validation | none (500) | you must map it |
DataIntegrityViolationException | DB unique / FK / not-null violation | none (500) | map to 409 / 400 |
OptimisticLockingFailureException | stale @Version | none (500) | map to 409 / 412 |
AccessDeniedException | authorisation failed | handled by Spring Security filters | see Security page |
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.
| Accessor | Example value | Use 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 failures | cross-field errors |
- 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 extendResponseEntityExceptionHandlerturns405,415, and unreadable JSON into 500. ExtendResponseEntityExceptionHandlerso 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
@Orderwins. Without@Orderthe order is undefined — add it. - Stack traces to clients:
server.error.include-stacktrace=always(oron_param) is a data leak. Keep the defaults (never) in every non-local profile.
@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).
| Approach | How | Result |
|---|---|---|
| Boot switch | spring.mvc.problemdetails.enabled=true | Boot registers a ResponseEntityExceptionHandler advice; all Spring MVC exceptions render as application/problem+json with default texts |
| Your own advice | extend ResponseEntityExceptionHandler | same defaults and you override individual handleXxx methods; a user-defined REEH advice replaces Boot's |
| Exception that carries its problem | extend ErrorResponseException (an ErrorResponse) | the exception is the response description; no handler method needed when a REEH advice is active |
| Quick one-off | throw ResponseStatusException | status + reason; rendered as ProblemDetail when problem details are enabled |
- Two advices fight: your class extends
ResponseEntityExceptionHandlerand another advice also does. Spring picks one per exception unpredictably, and Boot backs off its own. Keep exactly one REEH advice; give narrower advices@Orderand specific exceptions. - Returning
ResponseEntity<String>from a handler setstext/plain; useProblemDetailor a record so the converter produces JSON. Check theContent-Typein the actual response. - Handler for
Exceptionswallows the status ofResponseStatusException: it returns 500 for exceptions that already carry a status. Add@ExceptionHandler(ResponseStatusException.class)or rethrowErrorResponsetypes, or extend REEH (which handles them first). - Missing
@RestControllerAdvicescanning: 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.
@ExceptionHandlerparameters: only certain argument types are allowed (exception, request, response,WebRequest,Locale,Principal...). Asking for a@RequestBodyorModelin a REST handler fails at startup or runtime withIllegalStateException: Could not resolve parameter.
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
ProblemDetailgives the base for free
Stable machine codes
- clients switch on
type/codenot 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
| Situation | Status | Why this one | Retry? |
|---|---|---|---|
| malformed JSON, wrong types, failed constraints | 400 | the request itself is wrong; fix and resend | no |
| syntactically fine but breaks a business rule (cannot ship cancelled order) | 409 or 422 | 409 = conflicts with current state; 422 = semantically invalid input. Pick one convention and document it | no (until state changes) |
| duplicate unique value | 409 | conflicts with existing resource | no |
| optimistic lock / If-Match mismatch | 409 or 412 | resource changed since the client read it | after re-reading |
| not authenticated | 401 | credentials missing or invalid | after login |
| authenticated, not allowed | 403 | caller known, action denied | no |
| resource missing | 404 | no | |
| rate limit exceeded | 429 | include Retry-After | yes, later |
| downstream failure / overload | 502 / 503 / 504 | not the client's fault | yes, with backoff |
| unexpected bug | 500 | generic message + correlation id | maybe; idempotent only |
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.
- Leaking internals: returning
ex.getMessage()of aDataAccessExceptionsends 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 acode. - 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
@ExceptionHandlerfalls through to the container error page. Keep handlers trivial and test them.
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 piece | Real counterpart |
|---|---|
@NotBlank, @Size, @Min with message() / groups() | Jakarta constraint annotations (mandatory message, groups, payload) |
validate(bean, prefix, groups...) walking RecordComponents | ValidatorImpl.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 |
Notice what the output teaches:
- All violations are collected in one pass (
usernamefails 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
OnUpdateonly theidrule runs; the unannotated constraints belong toDefaultand 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.cityandaddress.zipare reported only because theaddresscomponent is marked@Valid. nullhandling is per constraint.@Minand@Pattern_skipnull;@NotBlankdoes not — copy that convention in your own validators.
- Add a
@NotNullconstraint and a@Pastdate constraint; make@Sizealso acceptListsizes. - 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.
Questions You Will Be Asked
- What is the difference between
@NotNull,@NotEmptyand@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. @Validvs@Validated?@Validis Jakarta: triggers and cascades, no groups.@Validatedis Spring: supports groups and, on a class, enables proxy-based method validation. Use@Validon nested objects, either on controller parameters.- Which exception does a failed
@Valid @RequestBodythrow, and what status results?MethodArgumentNotValidException(holds aBindingResult) — 400 by default, because it implementsErrorResponse. - How do you validate
@PathVariableor@RequestParam?
Put constraints directly on the parameter. Spring 6.1+ applies built-in method validation (throwsHandlerMethodValidationException, 400); earlier versions need class-level@Validatedand throwConstraintViolationException, which you must map yourself. - How do you write a custom constraint?
An annotation with@Constraint(validatedBy=...)and the mandatorymessage,groups,payload, plus aConstraintValidator<A,T>. Treatnullas valid so@NotNullstays separate; Spring can inject dependencies into the validator. - How do you centralise error handling?
@RestControllerAdvicewith@ExceptionHandlermethods, ideally extendingResponseEntityExceptionHandlerso Spring MVC's own exceptions get the same format; returnProblemDetail. - What is ProblemDetail?
Spring's model for RFC 9457 error bodies:type,title,status,detail,instanceplus extension properties; served asapplication/problem+json. Enable for framework exceptions withspring.mvc.problemdetails.enabled=true. - Why might a validation constraint not run?
No validation starter/provider,javaxinstead ofjakartaimports, 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 mapDataIntegrityViolationExceptionto 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 outsideDispatcherServlet;@ControllerAdviceonly wraps handler invocation. Handle them in the filter or delegate to aHandlerExceptionResolver. - Follow-up trap: you get 500 for
ConstraintViolationExceptionfrom a service. Why?
Spring MVC only maps its own web exceptions.ConstraintViolationException(from@Validatedservice 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.
Cheat Sheet & Next Steps
| Need | Use | Remember |
|---|---|---|
| Validate request body | @Valid @RequestBody + constraints on a record | throws MethodArgumentNotValidException, 400 |
| Validate path / query | constraints on parameters | Spring 6.1+: HandlerMethodValidationException; else class @Validated |
| Required text / collection | @NotBlank / @NotEmpty | other 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 rule | annotation + ConstraintValidator | null is valid; mandatory attributes |
| Central error mapping | @RestControllerAdvice extending REEH | one advice, one shape |
| Error body format | ProblemDetail (RFC 9457) | stable type/code, correlation id |
| Safety | defaults include-*=never | never leak stack traces, SQL or secrets |
- 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
@Validthrough collections and nested types. - One advice, one shape. Extend
ResponseEntityExceptionHandler, returnProblemDetail, 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
- AOP & Proxies — the proxy behind
@Validatedmethod validation. - Transactions — rollback rules and exceptions crossing a transactional boundary.
- Spring Security — 401 / 403 handling happens in filters, before advice.
- Testing —
@WebMvcTestto assert validation errors and problem bodies. - Interview Q&A — more questions across all topics.