RizTech Academy logo
RizTech Academy
Building REST APIsLesson 4 of 630 min

Validation with Bean Validation

Every request body is untrusted input, and validating it is not optional — it is where a great many bugs and security problems are stopped. Spring uses Bean Validation (the jakarta.validation standard) to validate DTOs declaratively: you annotate the fields with the rules, add @Valid, and Spring rejects bad input with a 400 before your code runs. This lesson wires it up on Dakiya's CreateParcelRequest, verified against the running API.

Declaring the rules on the DTO

You put validation constraints as annotations on the DTO's fields (record components). Dakiya's create request:

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateParcelRequest(
    @NotBlank String recipientName,                       // must be present and non-empty
    @NotBlank @Size(min = 2, max = 60) String destinationCity   // present, and 2..60 chars
) {}

The constraints declare what valid input looks like: recipientName must not be blank; destinationCity must not be blank and be between 2 and 60 characters. This needs spring-boot-starter-validation on the classpath. The common constraints:

Annotation Rule
@NotNull not null
@NotBlank not null, and not empty/whitespace (for strings)
@NotEmpty not null, and not empty (strings, collections)
@Size(min, max) length/size within range
@Min / @Max numeric bounds
@Email a valid email format
@Pattern(regexp) matches a regular expression
@Positive / @PositiveOrZero number sign

These live on the DTO, so the rules are declared once, next to the data, and apply everywhere that DTO is validated.

Triggering validation with @Valid

Declaring constraints does nothing until you tell Spring to check them, with @Valid on the controller parameter:

@PostMapping
public ResponseEntity<ParcelResponse> create(@Valid @RequestBody CreateParcelRequest req, ...) {
    // this body only runs if req passed validation
}

@Valid makes Spring validate the bound object before invoking your method. If it fails, Spring throws a MethodArgumentNotValidException and your method body never runs — so inside the method you can trust the data is valid. Verified against the running API: posting {"recipientName":"","destinationCity":"P"} (blank name, one-character city) returned HTTP 400 and did not create a parcel; posting a valid body created it (201). The @Valid is the switch — without it, the annotations are inert and bad data flows straight through.

What the client gets back: a useful error

By default the 400 is bare. A good API tells the client which fields failed and why, so you handle the validation exception and build a helpful response (the error-handling lesson does this globally). Dakiya's handler turns the exception into an errors map:

@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
    Map<String, String> errors = new HashMap<>();
    ex.getBindingResult().getFieldErrors()
      .forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage()));
    pd.setProperty("errors", errors);
    return pd;
}

Verified: the invalid POST above returned this body —

{"type":"about:blank","title":"Bad Request","status":400,"detail":"Validation failed",
 "instance":"/api/parcels",
 "errors":{"destinationCity":"size must be between 2 and 60","recipientName":"must not be blank"}}

Each failed field is named with its message — exactly what a client (or a frontend form) needs to show the user what to fix. You can customise the message per constraint: @NotBlank(message = "Recipient name is required"). The default messages ("must not be blank", "size must be between 2 and 60") are fine to start.

Validate at the edge, and know its limits

Two important points about what validation is for:

  • Validation belongs at the boundary — the DTO — not scattered in business logic. The controller edge is where untrusted input arrives; validating the DTO there means your service and domain can assume the data is structurally valid. This is the same "validate untrusted input at the boundary" discipline as every other course.
  • Bean Validation checks structure, not business rules. @NotBlank, @Size, @Email check the shape of the data. "This destination city is one we actually deliver to" or "this customer has not exceeded their parcel limit" are business rules — they need database lookups and belong in the service, not in an annotation. Do not try to force business logic into validation annotations; use Bean Validation for structural/format rules and service logic for the rest.

Nested validation is worth knowing: if a DTO contains another object, annotate the field @Valid too, and Spring validates the nested object's constraints as well. For most request DTOs, field-level constraints plus @Valid on the parameter is the whole story — declarative, verified, and stopping bad input before it reaches your logic.

Check your work

Declaring rules. Bean Validation annotations (@NotBlank, @Size, @Min/@Max, @Email, @Pattern, …) on the DTO's fields declare what valid input is; needs starter-validation.

Triggering it. @Valid on the @RequestBody parameter makes Spring validate before the method runs; failure throws MethodArgumentNotValidException and the body never executes. Verified: invalid POST → 400, not created; valid → 201.

Useful errors. Handle the exception to return which fields failed and why — Dakiya's handler builds an errors map inside a ProblemDetail. Verified body named both failing fields with their messages. Customise per-constraint with message = "...".

Validate at the edge. On the DTO at the controller boundary, so services can trust the data — not scattered through logic.

Structure vs business rules. Bean Validation checks shape/format only; business rules needing lookups ("do we deliver here?") belong in the service, not annotations. Nested objects need @Valid on the field too.

Practice

  1. Add @NotBlank and @Size(min=2,max=60) to CreateParcelRequest and @Valid to the controller parameter; POST an invalid body and confirm 400 with no record created.
  2. Remove the @Valid and confirm the annotations become inert (bad data flows through) — then add it back.
  3. Add the validation exception handler and confirm the 400 response names each failing field with its message (reproduce the verified body).
  4. Customise a message with @NotBlank(message="Recipient name is required") and confirm it appears in the response.
  5. Try to encode a business rule ("city must be a served city") as an annotation, hit its limits, and move it to the service instead.
  6. Add a nested object to a DTO, annotate the field @Valid, and confirm the nested constraints are checked.

Official documentation

Next: global error handling and consistent error responses.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship