Validation & Error Handling
@Valid. Business rejection ("no such patient", "already billed") is decided in the service and raised as an exception carrying an HTTP status. Both should reach the client as a predictable response, not a stack trace.What You Will Learn
- Jakarta Bean Validation constraints:
@NotNull,@NotBlank,@Email,@Size. - How
@Validon a@RequestBodytriggers validation and what failure produces. ResponseStatusExceptionfor business errors, and picking 400 / 401 / 403 / 404 / 409.- The default
/errorresponse and why/errorispermitAll(). - The
@RestControllerAdvice+@ExceptionHandlerpattern (project README's recommendation) and its trade-offs.
Chapter Structure
5.1 Two Kinds of Failure
| Field validation | Business rejection | |
|---|---|---|
| Question | "Is this payload well-formed?" | "Is this action allowed right now?" |
| Declared where | On the request DTO | In the service method |
| Mechanism | @Valid + constraint annotations | throw new ResponseStatusException(status, msg) |
| Needs the database? | No | Usually yes |
| Status | 400 (Spring: MethodArgumentNotValidException) | Whatever fits: 400/401/403/404/409 |
5.2 Bean Validation on the DTO
LoginRequest:
@Data
public class LoginRequest {
@NotBlank
@Email
private String email;
@NotBlank
private String password;
@NotNull
private Long roleId;
}PatientRequest adds length limits that mirror the DB:
@NotBlank @Size(max = 80) private String firstName;
@NotBlank @Size(max = 80) private String lastName;
@Email @Size(max = 180) private String email; // optional, but if present must be an email
@Size(max = 20) private String mobile;| Constraint | Passes when | Note |
|---|---|---|
@NotNull | value is not null | Empty string still passes. |
@NotBlank | non-null and has a non-whitespace char | Strings only. |
@NotEmpty | non-null and size > 0 | Strings, collections, maps, arrays. |
@Size(max = 80) | null OR length ≤ 80 | Does not imply required — pair with @NotBlank. |
@Email | null OR looks like an email | Same: pair with @NotBlank if mandatory. |
@Size and @Email treat null as valid. In PatientRequest that is intentional — email and mobile are optional, but if supplied they are checked. firstName is mandatory, so it carries both @NotBlank and @Size.5.2.1 Where the constraints come from
spring-boot-starter-validation puts Hibernate Validator on the classpath; auto-configuration registers it. The annotations are jakarta.validation.constraints.*.
5.3 @Valid in the Controller
@PostMapping
public ResponseEntity<PatientResponse> createPatient(
@RequestBody @Valid PatientRequest request) { ... }Without @Valid, the annotations on the DTO are inert. Every write endpoint in the project that takes a body uses @RequestBody @Valid — createPatient, updatePatient, login, createDoctor, and so on.
Spring Boot's default renders the failure as a JSON error body with HTTP 400. (To validate a single @RequestParam or @PathVariable instead of a bean, put @Validated on the controller class and the constraint on the parameter; failure is then ConstraintViolationException.)
5.4 Business Errors: ResponseStatusException
Rather than defining a hierarchy of custom exceptions, the project throws Spring's built-in org.springframework.web.server.ResponseStatusException with an explicit status:
// not found → 404
patientRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Patient not found"));
// bad reference in a create payload → 400
doctorRepository.findById(request.getDoctorId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid doctor"));
// state conflict → 409
if (visitRepository.existsByAppointmentId(id))
throw new ResponseStatusException(HttpStatus.CONFLICT, "A visit already exists for this appointment");
// authn / authz
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Account is inactive");Spring turns the exception into a response with that status and the reason phrase. Because it is a RuntimeException, it also rolls back any active @Transactional (Chapter 4).
| Status | Use it when… |
|---|---|
| 400 Bad Request | The request itself is wrong — malformed, or references something that does not exist. |
| 401 Unauthorized | No valid identity — missing/expired/invalid token, wrong password. |
| 403 Forbidden | Identity is fine, but this user may not do this (wrong role, disabled account). |
| 404 Not Found | The addressed resource does not exist (GET /api/patients/999). |
| 409 Conflict | Valid request, but current state forbids it (duplicate, already paid). |
| 500 Internal Server Error | You did not throw this — it means an unhandled bug. Never throw it deliberately. |
5.5 The /error Endpoint
In SecurityConfig:
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/auth/**", "/error").permitAll()
.anyRequest().authenticated())Spring Boot has a built-in BasicErrorController mapped to /error. When any request fails, the servlet container re-dispatches it there to render the JSON error body (timestamp, status, error, path). If /error required authentication, an error on an unauthenticated request would itself be blocked — so it is explicitly permitAll().
server.error.include-message=always during development.5.6 Centralised Handling with @RestControllerAdvice
The project's README explicitly recommends an exception/ package with @ControllerAdvice handlers. The current code throws ResponseStatusException inline instead. Here is what the recommended version looks like and why you might move to it:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fields = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage, (a, b) -> a));
return ResponseEntity.badRequest().body(new ApiError("VALIDATION_FAILED", "Invalid request", fields));
}
}Inline ResponseStatusException | Centralised @RestControllerAdvice |
|---|---|
| Zero extra classes; status is right where the rule is. | One place defines the error shape for the whole API. |
| Response body shape is Spring's default, not yours. | Custom body: error code, field map, correlation id. |
Services import org.springframework.http.*. | Services throw plain domain exceptions; no web imports. |
| Fine for a small, single-team app. | Pays off as endpoints and clients multiply. |
Quick Concept Map
Field validation → annotations on the DTO + @Valid in the controller → 400
@NotBlank (required text) vs @Size/@Email (null-tolerant) → combine for "required and bounded"
Business rejection → ResponseStatusException(status, message) in the service
Status choice → 400 bad request · 401 no identity · 403 not allowed · 404 missing · 409 conflict
/error → built-in JSON error controller, must be permitAll()
@RestControllerAdvice → one place to shape every error response
Common Mistakes
- Constraint annotations on the DTO but no
@Validin the controller — nothing is checked. - Using
@NotNullon aStringthat must be non-empty (use@NotBlank). - Assuming
@Size/@Emailmake a field required. - Throwing
500on purpose for a "not found". - Returning 200 with an
{"error": ...}body instead of a real status code. - Leaking which credential was wrong via distinct 401 messages.
- Locking down
/error, so failures on public routes get masked. - Catching an exception in the service just to
e.printStackTrace()and continue.
Revision Questions
- Contrast field validation and business rejection on four axes.
- Difference between
@NotNull,@NotBlank,@NotEmpty. - Why does
firstNameneed both@NotBlankand@Size? - What exception and status result from a body that fails
@Valid? - What does
ResponseStatusExceptiondo besides set a status? - Give the right status for: expired token; wrong role; unknown id in the URL; duplicate resource.
- Why is
/errorpermitAll()inSecurityConfig? - Name two things a
@RestControllerAdvicegives you that inline throwing does not.
Practice
@Pattern(regexp = "\\\\d{10}") to mobile in PatientRequest and observe the 400 for a bad number.@RestControllerAdvice that converts ResponseStatusException into a { code, message, path } body while keeping the status.MethodArgumentNotValidException so the response lists each invalid field and its message.Chapter Wrap-Up
Field validation lives on the request DTO and is enforced by @Valid; a violation is an automatic 400. Business rules live in the service and are raised as ResponseStatusException with a status chosen to tell the client what went wrong. The built-in /error endpoint renders the fallback body and must stay public. When the API grows, a @RestControllerAdvice centralises the error shape and keeps the service layer free of HTTP types.
Teaching note: the @RestControllerAdvice example is illustrative — that package does not yet exist in hospital-erp; the README lists it as intended work.