Validation & Error Handling

Reject malformed input before it reaches your logic, and return errors clients can act on
Big idea: There are two kinds of "no". Field validation ("email is blank", "name too long") is declared on the request DTO with annotations and enforced by @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 @Valid on a @RequestBody triggers validation and what failure produces.
  • ResponseStatusException for business errors, and picking 400 / 401 / 403 / 404 / 409.
  • The default /error response and why /error is permitAll().
  • The @RestControllerAdvice + @ExceptionHandler pattern (project README's recommendation) and its trade-offs.

Chapter Structure

5.1 Two kinds of failure5.2 Bean Validation5.3 @Valid in the controller5.4 Business errors5.5 The /error endpoint5.6 Centralised handling5.7 Wrap-up

5.1 Two Kinds of Failure

Field validationBusiness rejection
Question"Is this payload well-formed?""Is this action allowed right now?"
Declared whereOn the request DTOIn the service method
Mechanism@Valid + constraint annotationsthrow new ResponseStatusException(status, msg)
Needs the database?NoUsually yes
Status400 (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;
ConstraintPasses whenNote
@NotNullvalue is not nullEmpty string still passes.
@NotBlanknon-null and has a non-whitespace charStrings only.
@NotEmptynon-null and size > 0Strings, collections, maps, arrays.
@Size(max = 80)null OR length ≤ 80Does not imply required — pair with @NotBlank.
@Emailnull OR looks like an emailSame: pair with @NotBlank if mandatory.
Gotcha: @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) { ... }
JSON bodyJackson builds PatientRequest@Valid runs constraintsviolations? throw MethodArgumentNotValidException → 400otherwise: method body runs

Without @Valid, the annotations on the DTO are inert. Every write endpoint in the project that takes a body uses @RequestBody @ValidcreatePatient, 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).

StatusUse it when…
400 Bad RequestThe request itself is wrong — malformed, or references something that does not exist.
401 UnauthorizedNo valid identity — missing/expired/invalid token, wrong password.
403 ForbiddenIdentity is fine, but this user may not do this (wrong role, disabled account).
404 Not FoundThe addressed resource does not exist (GET /api/patients/999).
409 ConflictValid request, but current state forbids it (duplicate, already paid).
500 Internal Server ErrorYou 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().

By default the message body is hidden for security. You can widen it with 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 ResponseStatusExceptionCentralised @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.
Takeaway: both are legitimate. Start inline; graduate to an advice class when you need a consistent custom error envelope or want the service layer free of HTTP concepts.

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 rejectionResponseStatusException(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

  1. Constraint annotations on the DTO but no @Valid in the controller — nothing is checked.
  2. Using @NotNull on a String that must be non-empty (use @NotBlank).
  3. Assuming @Size/@Email make a field required.
  4. Throwing 500 on purpose for a "not found".
  5. Returning 200 with an {"error": ...} body instead of a real status code.
  6. Leaking which credential was wrong via distinct 401 messages.
  7. Locking down /error, so failures on public routes get masked.
  8. Catching an exception in the service just to e.printStackTrace() and continue.

Revision Questions

  1. Contrast field validation and business rejection on four axes.
  2. Difference between @NotNull, @NotBlank, @NotEmpty.
  3. Why does firstName need both @NotBlank and @Size?
  4. What exception and status result from a body that fails @Valid?
  5. What does ResponseStatusException do besides set a status?
  6. Give the right status for: expired token; wrong role; unknown id in the URL; duplicate resource.
  7. Why is /error permitAll() in SecurityConfig?
  8. Name two things a @RestControllerAdvice gives you that inline throwing does not.

Practice

1. Tighten a DTO. Add @Pattern(regexp = "\\\\d{10}") to mobile in PatientRequest and observe the 400 for a bad number.
2. Introduce an advice. Add a @RestControllerAdvice that converts ResponseStatusException into a { code, message, path } body while keeping the status.
3. Field errors. Handle 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.

Next chapter: the filter that runs before any of this — authentication and authorization with Spring Security and JWT.

Teaching note: the @RestControllerAdvice example is illustrative — that package does not yet exist in hospital-erp; the README lists it as intended work.