DTOs & Validation
In 2012 a researcher named Egor Homakov used a crafted POST request to give himself commit access to Rails itself — the framework’s own repo on GitHub — because the endpoint bound incoming JSON straight onto the model, internal fields included. Your FriendController from module 04 has exactly that shape right now. This module closes the hole and puts a bouncer on the door.
The Goal
By the end of this module you can:
- Explain why returning and binding entities at the API edge is a vulnerability, not a style choice — with the attack
- Design request and response records with different shapes for different directions
- Map entity ↔ DTO by hand and say when a mapping library earns its place
- Trace exactly where Bean Validation runs, what triggers it, and what the client sees on failure
- Draw the line between shape validation (here) and business rules (module 06)
- Catch the silent killer: constraints that never run because nobody wrote
@Valid
The Lesson
Exposing entities is an attack surface, not bad taste
Module 04’s controller does @RequestBody Friend in and List<Friend> out. Three concrete ways that bites:
1. Mass assignment — the Homakov attack. Jackson binds incoming JSON onto whatever fields the class has. Your entity has id. So:
curl -X POST http://localhost:8080/api/v1/friends \
-H "Content-Type: application/json" -d '{"id": 1, "name": "Mallory"}'
The client just chose its own primary key. Today that’s a weird overwrite-or-error; the day Friend grows a boolean admin or Expense grows a boolean settled, a crafted POST sets it, because the binding never asked which fields were meant to be writable. That’s mass assignment — a standard pentest checklist item with a long trophy wall (the GitHub/Rails incident being the famous one).
2. Serialization leaks. Returning the entity means every field, current and future, goes to every client. The day someone adds upiHandle or an internal riskScore column to Friend, it’s in every API response that afternoon — nobody decided to publish it, the entity-out pattern decided for them.
3. Schema-client coupling. Rename the entity field name to displayName for a database reason, and every consumer of your API breaks the same minute, because your table layout was your API contract. Those are two different contracts that change for two different reasons; gluing them together means you can never change either freely.
The fix is one idea: a dedicated class per direction at the API edge — a DTO (data transfer object). Input gets a class containing only client-writable fields; output gets a class containing only deliberately published fields. The entity stays inside.
Records: the perfect DTO
You met records in lambdas & streams — one line buys you final fields, constructor, accessors, equals/hashCode/toString. Immutable and compact is exactly the DTO job description:
public record CreateFriendRequest(
@NotBlank @Size(max = 50) String name
) {}
public record FriendResponse(Long id, String name) {
public static FriendResponse from(Friend f) {
return new FriendResponse(f.getId(), f.getName());
}
}
Note what’s absent from CreateFriendRequest: no id. A client sending "id": 999 hits nothing — Jackson ignores JSON keys with no matching component. Mass assignment dies not by clever filtering but because the writable surface is exactly the fields you declared.
Request and response are different shapes
Don’t reach for one FriendDto used both ways — the directions genuinely differ. Look at expenses:
| Direction | Record | Shape logic |
|---|---|---|
| Client → server | CreateExpenseRequest(description, amountInPaise, payerId, sharerIds) | Clients know ids; they can’t send you a Friend object |
| Server → client | ExpenseResponse(id, description, amountInPaise, payerName, sharerNames) | Clients want names to display, plus the id the server assigned |
Ids in, names out, server-generated fields only on the way out. The moment you force one class to serve both directions you’re back to fields that shouldn’t be writable or shouldn’t be visible.
Mapping: by hand, until it hurts
The translation between entity and DTO is honest, boring code:
public static ExpenseResponse from(Expense e) {
return new ExpenseResponse(
e.getId(),
e.getDescription(),
e.getAmountInPaise(),
e.getPayer().getName(),
e.getSharers().stream().map(Friend::getName).sorted().toList());
}
Five lines, zero dependencies, debugger-friendly. Write it by hand at this scale. Know that MapStruct exists — a compile-time code generator teams use when entities have forty fields and there are sixty of them — so you recognize it in codebases and interviews. Reaching for it on a two-entity project is cargo cult.
Bean Validation de-magicked
Those @NotBlank annotations are pure metadata — they do nothing by themselves. Stick them on any class and construct garbage all day; nothing fires. Validation needs three parts:
- Constraints —
@NotBlank,@Positive, declared on the record components. - An engine — Hibernate Validator, pulled in by the
validationstarter you added in module 02. (Naming trap: Hibernate Validator and Hibernate ORM are unrelated projects from the same shop. One validates objects, the other does module 04.) - A trigger —
@Validon the controller parameter. This is the part everyone forgets.
@PostMapping
public FriendResponse create(@Valid @RequestBody CreateFriendRequest request) {
flowchart LR
A[JSON body arrives] --> B[Jackson binds it to the record]
B --> C{"@Valid present on the parameter?"}
C -->|yes| D[Hibernate Validator checks every constraint]
C -->|no| E[Constraints silently skipped - garbage walks in]
D -->|all pass| F[Your method body runs]
D -->|any fail| G[MethodArgumentNotValidException - 400 - method never runs]
The timing matters: validation runs during argument resolution, before your method body executes. On failure Spring throws MethodArgumentNotValidException and its default handler answers 400 Bad Request — your code never saw the request. The constraints worth knowing cold:
| Constraint | Passes | Fails | Note |
|---|---|---|---|
@NotNull | "", " " | null | Null check only |
@NotBlank | "Asha" | null, "", " " | Strings: the one you usually want |
@NotEmpty | [1], "a" | null, [], "" | Collections and strings |
@Positive | 1 | 0, -5 | Use on the paise amount |
@Size(max = 50) | length ≤ 50 | length 51 | Null passes — pair with @NotBlank |
One honest wrinkle about the failure response. Out of the box, Spring Boot 3 returns this for a validation failure:
{"timestamp":"2026-06-12T14:03:11.482+00:00","status":400,
"error":"Bad Request","path":"/api/v1/friends"}
A 400 with no clue which field failed — Boot strips messages and binding errors from error responses by default (a deliberate don’t-leak-internals stance). For learning, flip them on:
server.error.include-message=always
server.error.include-binding-errors=always
Now the body includes an errors array with field, rejected value, and message. Real APIs don’t ship either default — module 06’s @RestControllerAdvice global handler builds a deliberate, consistent error JSON instead. Today you just need to see the machinery.
Where bean validation ends
Bean validation checks shape: not blank, positive, within size. It cannot check facts: that payerId refers to a friend who exists, that the payer is part of the group, that this expense doesn’t breach a monthly limit. Those rules need the database and the domain — they belong in the service layer, which is module 06’s whole subject. Resist the urge to smuggle repository lookups into validators; keep the bouncer checking dress code, not running background checks.
One sanctioned middle step — a custom constraint when a shape rule recurs. SplitEase money is paise as long, positive, and (sanity cap) at most ₹10 lakh per expense:
@Target(ElementType.TYPE_USE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PaiseValidator.class)
public @interface ValidPaise {
String message() default "amount must be positive paise up to 10 lakh rupees";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PaiseValidator implements ConstraintValidator<ValidPaise, Long> {
@Override
public boolean isValid(Long value, ConstraintValidatorContext ctx) {
return value != null && value > 0 && value <= 100_000_000L;
}
}
Then @ValidPaise Long amountInPaise reads like domain language. Still a shape check — no database, no dependencies — just one with a name.
Check The Concept
How This Shows Up At Work
- The bug-bounty report. “Mass assignment on POST /users allows setting
role” is a recurring real-world finding with payouts to match. Companies whose endpoints bind to entities collect these reports; companies with request DTOs don’t. Reviewers at fintechs check for this specifically. - The mobile-app outage. Backend renames an entity field during a DB cleanup; the Android app, parsing API responses, starts crashing for every user on the old contract. A response DTO would have pinned the API shape while the schema moved underneath. This exact incident is why “entities out of controllers” gets flagged in code review.
- The silent garbage. Production data has expenses with negative amounts and blank descriptions. Constraints were on the record all along — someone removed
@Validduring a refactor and no test caught it, because the failure mode is nothing happening. You will build this exact bug on purpose below. - The triage signal. 400 means the client sent garbage; 500 means your server broke. Teams that keep this line crisp route bugs to the right owner in minutes. Validation done right is what keeps client mistakes from surfacing as your stack traces.
- The interview question. “How do you validate input in Spring?” — weak answer lists annotations. Strong answer: constraints are metadata,
@Validtriggers Hibernate Validator during binding, failure isMethodArgumentNotValidException→ 400, and business rules live in the service, not in constraints.
Build This
Where you are: after module 04, friends and expenses live in a real database, but FriendController binds and returns the Friend entity, and there’s no expense endpoint at all — just seeded rows.
What gets added: request/response records for both resources, validation that actually fires, an ExpenseController, and entities stop leaking past the controller line. (Run everything with ./mvnw spring-boot:run as usual.)
-
Create the friend DTOs —
CreateFriendRequestandFriendResponseexactly as in the Lesson (type them; note which fields each one doesn’t have). -
Rewire
FriendControllerto speak DTO at both edges:
@RestController
@RequestMapping("/api/v1/friends")
public class FriendController {
private final FriendRepository friendRepository;
public FriendController(FriendRepository friendRepository) {
this.friendRepository = friendRepository;
}
@PostMapping
public FriendResponse create(@Valid @RequestBody CreateFriendRequest request) {
Friend saved = friendRepository.save(new Friend(request.name()));
return FriendResponse.from(saved);
}
@GetMapping
public List<FriendResponse> all() {
return friendRepository.findAll().stream().map(FriendResponse::from).toList();
}
}
- Create the expense DTOs, with the
@ValidPaiseconstraint from the Lesson (both the annotation andPaiseValidator):
public record CreateExpenseRequest(
@NotBlank @Size(max = 100) String description,
@NotNull @ValidPaise Long amountInPaise,
@NotNull Long payerId,
@NotEmpty List<Long> sharerIds
) {}
public record ExpenseResponse(Long id, String description, long amountInPaise,
String payerName, List<String> sharerNames) {
public static ExpenseResponse from(Expense e) {
return new ExpenseResponse(e.getId(), e.getDescription(), e.getAmountInPaise(),
e.getPayer().getName(),
e.getSharers().stream().map(Friend::getName).sorted().toList());
}
}
- Create
ExpenseController— ids in, names out, fetch-join on the way back (module 04’sfindAllWithEveryonefinally meets a real endpoint):
@RestController
@RequestMapping("/api/v1/expenses")
public class ExpenseController {
private final ExpenseRepository expenseRepository;
private final FriendRepository friendRepository;
public ExpenseController(ExpenseRepository expenseRepository,
FriendRepository friendRepository) {
this.expenseRepository = expenseRepository;
this.friendRepository = friendRepository;
}
@PostMapping
public ExpenseResponse create(@Valid @RequestBody CreateExpenseRequest request) {
Friend payer = friendRepository.findById(request.payerId()).orElseThrow();
Set<Friend> sharers = new HashSet<>(friendRepository.findAllById(request.sharerIds()));
Expense saved = expenseRepository.save(
new Expense(request.description(), request.amountInPaise(), payer, sharers));
return ExpenseResponse.from(saved);
}
@GetMapping
public List<ExpenseResponse> all() {
return expenseRepository.findAllWithEveryone().stream()
.map(ExpenseResponse::from).toList();
}
}
Honesty note: that bare orElseThrow() for an unknown payerId produces an ugly 500 today. That’s deliberate — turning it into a clean 404 with a custom exception and a global handler is module 06’s opening act.
- Add the learning flags to
application.propertiesso 400s tell you something:
server.error.include-message=always
server.error.include-binding-errors=always
- Run it and curl a valid request (PowerShell:
curl.exewith-d "{\"description\":...}"):
curl -X POST http://localhost:8080/api/v1/expenses -H "Content-Type: application/json" \
-d '{"description":"breakfast","amountInPaise":45000,"payerId":1,"sharerIds":[1,2]}'
Expected:
{"id":4,"description":"breakfast","amountInPaise":45000,
"payerName":"Asha","sharerNames":["Asha","Rohit"]}
No payer_id internals, no entity graph — exactly the fields ExpenseResponse publishes. Then curl an invalid one and read the 400:
curl -X POST http://localhost:8080/api/v1/expenses -H "Content-Type: application/json" \
-d '{"description":" ","amountInPaise":-500,"payerId":1,"sharerIds":[1,2]}'
With the flags on, the errors array names both failing fields, the rejected values, and the messages — including your @ValidPaise default message. Your method body never ran; check the log, no insert happened.
-
Prove mass assignment is dead: POST a friend with
-d '{"id": 999, "name": "Mallory"}'. The response id is database-assigned, not 999 — theidkey matched nothing onCreateFriendRequestand fell on the floor. In module 04’s entity-binding version this same request was honored. -
Break it on purpose #1 — the silent killer. Delete
@ValidfromExpenseController.create(just the one word). Restart. Re-send the invalid curl from step 6: HTTP 200, and the garbage is now a row in your database — blank description, negative paise, no error anywhere. This is the nastiest bug in this module precisely because nothing visibly fails; you find it weeks later when balances go negative. Look at the row (GET it back), feel the wrongness, restore@Valid, and delete the bad row (H2 console orpsql:DELETE FROM expense WHERE amount_in_paise < 0;— mind the join table if it had sharers). Lesson burned in: constraints without a trigger are comments. -
Break it on purpose #2 — read a constraint message. With
@Validrestored, POST an expense with"amountInPaise": -1and only that wrong. Read the single-field error and your own@ValidPaisemessage coming back. Then change the record to plain@Positiveinstead, restart, resend, and compare the stock message (must be greater than 0). You now know exactly where every word of a 400 body comes from.
Where to Practice
| Resource | What to do there | How long |
|---|---|---|
| spring.io/guides — “Validating Form Input” | Run the official guide; spot where it puts @Valid and what handles the failure | 25 min |
| Baeldung — search “Java Bean Validation Basics” (free article) | Read the constraint catalogue; pick two you didn’t know and try them on a throwaway record | 25 min |
| Baeldung — search “Spring MVC Custom Validation” (free article) | Compare its custom validator with your @ValidPaise | 15 min |
| dev.java — Records tutorial | Refresh record syntax: compact constructors are useful for DTO normalization | 15 min |
Check Yourself
- Name the three concrete harms of binding/returning entities at the API edge, with one sentence each.
- Why does a request DTO kill mass assignment structurally, rather than by filtering?
- Why do request and response need different record shapes? Use the expense as the example.
- Constraints, engine, trigger — name the three parts of Bean Validation and where each lives in your project.
- A POST with a blank name returns 400. Did your controller method run? What exception was thrown, and by what point in the request lifecycle?
@NotNullvs@NotBlankvs@NotEmptyon aString— what does each reject?- Why does “payer must exist in the database” not belong in a bean validation constraint?
- What is the failure mode when
@Validis missing, and why is it worse than an exception?
Answers
- Mass assignment (crafted JSON sets fields you never meant to be writable, like id or internal flags); serialization leaks (every current and future entity field ships to every client); schema-client coupling (a database rename becomes an API breaking change because the table layout is the contract).
- Because the writable surface is exactly the record’s declared components. A malicious
idkey has nothing to bind to — Jackson drops unknown keys. There is no field to protect because the field doesn’t exist on the input type. - Directions carry different knowledge: the client knows ids (
payerId,sharerIds) and can’t construct server objects; the response carries server-assignedidand display-ready names. One shared class would need fields that are either unsafely writable or pointlessly visible. - Constraints: annotations on the record components (metadata only). Engine: Hibernate Validator, from the validation starter. Trigger:
@Validon the@RequestBodyparameter, which makes Spring invoke the engine during argument binding. - No — validation runs during argument resolution, before the method body. Spring throws
MethodArgumentNotValidException, and default handling converts it to a 400 response. @NotNullrejects onlynull(empty and whitespace pass).@NotEmptyrejectsnulland""(whitespace passes).@NotBlankrejectsnull,"", and whitespace-only — the usual right answer for user input.- It’s a fact about the domain requiring a database lookup, not the shape of a field. Validators should stay dependency-free shape checks; existence and relationship rules belong in the service layer (module 06), where they can also return proper domain errors.
- Silence. Invalid data sails through, gets persisted, and surfaces much later as corrupt state (negative balances, blank descriptions) with no stack trace pointing anywhere. An exception fails loudly at the boundary; missing
@Validfails invisibly in your data.
Explain it out loud: Explain to an empty chair why a fintech reviewer would block a PR where a controller takes @RequestBody Account — walk the mass-assignment attack end to end with a concrete curl, then describe the request record that fixes it and where @Valid goes. If you can’t say when validation runs relative to the method body, reread the de-magic section.
Still Unclear?
Walk me through the exact lifecycle of a Spring MVC request with @Valid
@RequestBody: where Jackson runs, where Hibernate Validator runs, what
MethodArgumentNotValidException is and who converts it to a 400. Then quiz
me: give me five scenarios and ask whether the controller method body ran.
Explain mass assignment with a real-world example like the 2012 GitHub Rails
incident. Then look at this record and entity of mine [paste them] and tell
me which fields would be exposed or writable if I bound the entity directly.
Make me find one of them myself before you list the rest.
I put @NotBlank on a field but invalid data is still getting saved. Give me
a debugging checklist ordered by likelihood — do not just say add @Valid,
explain each possible cause and how I would confirm it from logs alone.
Why AI Can’t Do This For You
AI will happily generate request records, response records, and every constraint annotation in the catalogue — and it will just as happily generate the controller where @Valid quietly went missing, because nothing about that code looks wrong. The bug has no error message, no stack trace, no failing test unless someone thought to write one. Only a person who knows the trigger model — metadata, engine, trigger — looks at a 200 response for garbage input and immediately knows which word is absent.
And when the pentest report or the corrupted production data lands, the question is never “can you write a DTO” — it’s “which of our forty endpoints binds an entity, what fields does that expose, and what’s the blast radius.” That’s an audit of your codebase against your domain, done with judgment AI doesn’t have about which fields were never meant to leave the building.
Module done? Add it to today’s tracker