Skip to main content

Spring Boot DTOs and Validation: A Complete Guide

There is one line of code that quietly ruins more Spring Boot APIs than any other:

return userRepository.findById(id).orElseThrow();

It compiles. It returns 200 OK. It ships. And it hands the entire internet your BCrypt password hash, your account-lock state, and your failed-login counter — because Jackson serialises every field it can reach, and your @Entity was never designed to be read by strangers.

In Episode 4 of Building Instagram's Authentication Backend, we build the wall that stops this: DTOs. Ten of them, in one file, guarding nine endpoints. This article is the written companion — every annotation, every design decision, and the exact reason request DTOs and response DTOs are not the same kind of object.

What you'll build

One file — dto/AuthDtos.java — containing 7 request DTOs (validated input) and 3 response DTOs (controlled output), wired to the GlobalExceptionHandler we built in Episode 3.

What Is a DTO in Spring Boot?

A DTO (Data Transfer Object) is a plain object whose only job is to carry data across a boundary. It has no business logic, no database mapping, and no lifecycle. It exists to define, precisely and permanently, what shape of data is allowed to cross the edge of your application.

Your entity answers a different question. User answers "what does a user look like in PostgreSQL?" — which is why it holds things no API caller should ever see:

@Entity
public class User implements UserDetails {

    private UUID id;
    private String username;
    private String email;
    private String password;            // BCrypt hash
    private String fullName;
    private Role role;
    private boolean emailVerified;
    private boolean accountLocked;
    private int failedLoginAttempts;    // security signal
    private LocalDateTime lockExpiresAt;
    private LocalDateTime lastLoginAt;
    // ...
}

Look at that field list from an attacker's point of view. password is a hash you just handed them for offline cracking. failedLoginAttempts and lockExpiresAt tell them exactly how close they are to a lockout and when it lifts — that's a free oracle for a credential-stuffing script. role tells them who is worth targeting. None of that is a bug in the entity. The entity is correct. The bug is letting it become JSON.

Why Your @Entity Must Never Be Your API Response

The most common objection is reasonable: "I'll just put @JsonIgnore on the password field."

That works — once. The problem is structural. @JsonIgnore is a deny-list, and deny-lists fail in one specific, predictable way: the next field. Six months from now someone adds twoFactorSecret to the entity to ship 2FA. They don't know about your JSON contract. They don't add @JsonIgnore. Nothing breaks, no test fails — and your API starts leaking TOTP secrets. The failure mode of a deny-list is silence.

A response DTO is an allow-list. New entity fields are invisible by default. To leak something, a developer has to actively type it into UserResponse — a deliberate act, in a file whose entire purpose is public contract, visible in code review.

The rule worth memorising

A deny-list leaks when you forget something. An allow-list leaks only when you choose to. In authentication code, always ship the allow-list.

Inside AuthDtos: 7 Request DTOs and 3 Response DTOs

Here is the complete inventory backing all nine auth endpoints:

  • Requests (validated input): RegisterRequest, LoginRequest, RefreshTokenRequest, VerifyEmailRequest, ResendOtpRequest, ForgotPasswordRequest, ResetPasswordRequest
  • Responses (controlled output): AuthResponse, UserResponse, MessageResponse

Notice the asymmetry immediately: every request DTO is covered in validation annotations. Not one response DTO has a single one. That is not an oversight — it is the whole point, and we'll come back to it.

Bean Validation Annotations: @NotBlank, @Size, @Email, @Pattern

RegisterRequest is the densest DTO in the file, so it teaches the most:

@Data
public static class RegisterRequest {

    @NotBlank(message = "Username is required")
    @Size(min = 3, max = 30, message = "Username must be 3-30 characters")
    @Pattern(regexp = "^[a-zA-Z0-9._]+$",
             message = "Username can only contain letters, numbers, dots, and underscores")
    private String username;

    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
            message = "Password must contain uppercase, lowercase, digit, and special character")
    private String password;

    @NotBlank(message = "Full name is required")
    @Size(max = 100)
    private String fullName;

}

@NotBlank vs @NotNull vs @NotEmpty

This trips up more developers than it should:

  • @NotNull — rejects null. Accepts "" and " ".
  • @NotEmpty — rejects null and "". Accepts " ".
  • @NotBlank — rejects null, "", and whitespace-only. Trims before checking.

For any user-supplied String, @NotBlank is almost always what you actually meant. That is why it is on every string field in this file.

Layered validation on the password field

The password field carries three annotations, and the order they read in matters for the user experience:

  • @NotBlank — is there anything at all?
  • @Size(min = 8) — is it long enough?
  • @Pattern(...) — the lookahead regex demanding lowercase, uppercase, a digit, and a special character.

Each lookahead group in (?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]) asserts a character class exists somewhere in the string without consuming it. Notice the @Size annotation still carries the length rule — the regex deliberately doesn't. Keeping length in @Size means a too-short password produces "Password must be at least 8 characters" instead of the useless "Password must contain uppercase, lowercase, digit, and special character".

@Pattern on username

The username regex ^[a-zA-Z0-9._]+$ is an allow-list too — letters, digits, dots, underscores. Everything else is rejected: no spaces, no Unicode homoglyphs, no control characters that let one user impersonate another. This is the same character set Instagram enforces, and the reason is identity safety, not tidiness.

The smaller request DTOs

@Data
public static class LoginRequest {

    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;

    @NotBlank(message = "Password is required")
    private String password;

}

Look closely at LoginRequest.password: it has @NotBlank and nothing else. No @Size. No @Pattern. This is deliberate and it is important.

A real security decision

Never enforce password composition rules on login. If you did, a password that predates your current policy would be rejected at the door with a validation error — telling an attacker your rules and locking out legitimate users. At login you check one thing: did they send something? The real answer comes from BCrypt.

The OTP DTOs use @Size(min = 6, max = 6) — an exact-length check, since our OTP is always six digits:

@Data
public static class VerifyEmailRequest {

    @NotBlank(message = "Email is required")
    @Email
    private String email;

    @NotBlank(message = "OTP is required")
    @Size(min = 6, max = 6, message = "OTP must be 6 digits")
    private String otp;

}

Response DTOs: Controlled Output

@Data @Builder
public static class AuthResponse {
    private String accessToken;
    private String refreshToken;
    private String tokenType;
    private long expiresIn;
    private UserResponse user;
}

@Data @Builder
public static class UserResponse {
    private UUID id;
    private String username;
    private String email;
    private String fullName;
    private String profilePicture;
    private String role;
    private boolean emailVerified;
    private LocalDateTime createdAt;
}

@Data @Builder
public static class MessageResponse {
    private boolean success;
    private String message;
}

Three things to notice:

  • Zero validation annotations. Validation guards data coming in. Data going out was built by our own code — if it's malformed, that's a bug to fix, not a user to reject.
  • @Builder on responses, @Data alone on requests. Jackson deserialises requests via a no-args constructor and setters. Responses are constructed by us, and a builder makes an eight-field object readable at the call site.
  • UserResponse is the allow-list in action. Compare it to the entity: password, accountLocked, failedLoginAttempts, lockExpiresAt, and authProvider simply do not exist here. They cannot leak, because there is nowhere for them to go.

Lombok gotcha worth knowing

Adding @Builder alongside @Data suppresses the no-args constructor that @Data would otherwise generate. That's harmless for a response DTO you only ever serialise — but the moment you try to deserialise one (in a test, or a Feign client), Jackson will fail. The fix is @NoArgsConstructor @AllArgsConstructor alongside the builder. This is exactly why request DTOs in this file stay on plain @Data.

How @Valid Triggers Validation in Spring Boot

Annotations on a DTO do nothing on their own. They are inert metadata until one keyword activates them at the controller boundary:

@PostMapping("/register")
public ResponseEntity<ApiResponse<MessageResponse>> register(
        @Valid @RequestBody RegisterRequest request) {
    // If we reach this line, every annotation on RegisterRequest passed.
    // No null checks. No manual guards. Nothing.
    return ResponseEntity.ok(authService.register(request));
}

Remove @Valid and every annotation in the file silently stops working. The code still compiles, the tests still pass, and the guard is gone. It's the single most common validation bug in Spring Boot — and it fails silently, which is the worst way to fail.

When validation does fail, Spring throws MethodArgumentNotValidException before your controller method body ever executes. And we already built the thing that catches it, back in Episode 3:

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) {
    String message = ex.getBindingResult().getFieldErrors().stream()
            .map(FieldError::getDefaultMessage)
            .collect(Collectors.joining(", "));
    return ApiResponse.error(message, "VALIDATION_ERROR");
}

Every failed field message is collected and joined, then wrapped in the ApiResponse envelope with the VALIDATION_ERROR code. The client receives:

{
  "success": false,
  "message": "Username must be 3-30 characters, Invalid email format",
  "errorCode": "VALIDATION_ERROR"
}

Validation Messages Are API Contract Surface

This is the idea I most want to land. That message = "Username must be 3-30 characters" string is not a log line. It is not a developer note. It is production UI copy. It travels from your Java annotation, through the exception handler, across HTTP, into a mobile app, and onto a real person's screen — usually rendered verbatim under a text field.

Which means the default messages are unshippable. Leave @Size bare and your user reads "size must be between 3 and 30" — lowercase, passive, no subject, no idea which field. Write the message yourself and they read "Username must be 3-30 characters."

Spot the gap

Two fields in this file are still naked: @Size(max = 100) on fullName, and the bare @Email on the OTP DTOs. Trip those in Postman and you get Jakarta’s raw default text. There’s a third, subtler gap too: @Size(min = 6, max = 6) checks the OTP’s length, not that it contains digits — "abcdef" sails through. All three are good first pull requests if you want to contribute to the repo.

Why All 10 DTOs Live in One Container Class

Ten public static nested classes in a single AuthDtos.java, rather than ten separate files:

public class AuthDtos {

    @Data public static class RegisterRequest { /* ... */ }
    @Data public static class LoginRequest { /* ... */ }
    @Data public static class RefreshTokenRequest { /* ... */ }
    @Data public static class VerifyEmailRequest { /* ... */ }
    @Data public static class ResendOtpRequest { /* ... */ }
    @Data public static class ForgotPasswordRequest { /* ... */ }
    @Data public static class ResetPasswordRequest { /* ... */ }

    @Data @Builder public static class AuthResponse { /* ... */ }
    @Data @Builder public static class UserResponse { /* ... */ }
    @Data @Builder public static class MessageResponse { /* ... */ }

}

The case for it: these ten objects are one cohesive unit with one lifecycle. They are the auth API's contract. They change together, get reviewed together, and are read together — and reading the entire surface of your API in one scroll is genuinely valuable. It also kills ten near-identical import blocks.

Now the honest part, because most tutorials won't tell you this: this pattern does not scale. It works here because the boundary is small, closed, and cohesive. On a service with forty DTOs across six domains, one container class becomes a 900-line merge-conflict magnet that no one can navigate. The heuristic I use in production: one container per bounded context, and the moment it passes roughly 300 lines or you're scrolling to find things, split it. Java 21 records are also a strong alternative for request DTOs specifically — immutable, no Lombok, and validation annotations work on record components exactly the same way.

What's Next: Episode 5

The dto/ package is now complete — ApiResponse from Episode 3, AuthDtos from today. We have a fully defined boundary with nothing behind it yet. Next episode we go one layer deeper into the entities and the Flyway migrations that back them, and start filling in the space between the contract and the database.

Frequently Asked Questions

Can't I just use @JsonIgnore instead of a response DTO?

You can, and it works today. But it's a deny-list — you must remember to annotate every sensitive field you ever add, forever. A response DTO is an allow-list: new entity fields are invisible unless someone deliberately adds them. In auth code, choose the allow-list.

Why do request DTOs have validation annotations but response DTOs don't?

Validation defends against untrusted input. Requests come from the internet and are untrusted by definition. Responses are built by your own service — if one is malformed, that's a bug to fix in code, not a condition to report to the caller.

Should I use Java records for DTOs instead of Lombok classes?

For request DTOs, records are an excellent fit — immutable, concise, and Bean Validation annotations work on record components identically. This series uses Lombok classes for consistency with the rest of the stack, but a record-based refactor is a legitimate improvement.

Where is ApiResponse covered?

Episode 3, in full — the generic <T>, the ok() factory methods, @Builder.Default, and @JsonInclude. It lives in the same dto/ package but is not part of Episode 4.

What happens if I forget @Valid on the controller method?

Every annotation on that DTO is silently ignored. No error, no warning, no failing test — the request sails straight into your service layer unvalidated. It's the most common validation bug in Spring Boot, precisely because nothing tells you.

Is this compatible with Spring Boot 3?

Almost entirely. The DTO code and the jakarta.validation annotations are identical between Spring Boot 3 and 4 — the javax to jakarta migration happened back in Spring Boot 3.0.


Watch Episode 4

Get the code

The complete AuthDtos.java and everything else in this series lives on GitHub: sprashantofficial/insta-auth-service — a star genuinely helps the series reach more developers.

Go deeper:

One question for the comments, because I genuinely go back and forth on it: one DTO container file, or one file per DTO? I've made my case above — including where it breaks. Tell me where you draw the line.

Comments