DTOs and the API model
What returning an entity actually sends, why the schema must not be the contract, and the opt-in shape that makes a leak impossible rather than unlikely.
The fastest way to build a REST endpoint is to return the entity. Spring will serialise it, Jackson will find the getters, and the response looks right in a browser. It is also how private data leaves your service, and the failure is a 200.
What returning an entity actually sends
Here is an ordinary JPA entity — the one from the JPA course, with the fields a real customer record carries:
@Entity
public class Customer {
@Id @GeneratedValue public Long id;
public String name;
public String email;
public String passwordHash; // nobody outside should ever see this
public boolean internalFlagged;
@OneToMany(mappedBy = "customer") public List<Order> orders = new ArrayList<>();
}And the endpoint everybody writes first:
@GetMapping("/customers/{id}")
public Customer get(@PathVariable Long id) { return customers.findById(id).get(); }$ curl -i /customers/1
HTTP/1.1 200
Content-Type: application/json
{"id":1,"name":"Ana","email":"ana@example.com","passwordHash":"$2a$10$SOMETHINGSECRET","internalFlagged":true,"orders":[]}The password hash and an internal flag went out over HTTP, and the response was a 200. Nothing failed. No log line. Jackson did exactly what it was asked: serialise every readable property of the object it was handed.
That is the whole argument, and it does not depend on anyone being careless. Nobody wrote passwordHash into a response. Somebody added a field to an entity, months later, in a different pull request — and every endpoint that returns that entity started publishing it.
Four more ways the same coupling bites
A field added to the database changes your public API. The entity is shaped by your schema; the response is shaped by your entity; so a migration is now an API change. That is exactly backwards: the schema should be the thing you are free to change, and the contract the thing you are not.
Lazy relationships explode during serialisation. The previous course's LazyInitializationException arrives from a new direction: Jackson walks orders, the persistence context is closed, and you get a 500 from the JSON writer. The usual fix — open-in-view, or making the relationship eager — makes the symptom stop by making every request slower.
Cycles. Customer.orders → Order.customer → Customer.orders. Jackson follows references until it runs out of stack. The workaround is annotations (@JsonIgnore, @JsonManagedReference) sprinkled on the entity, which means your persistence model now carries serialisation concerns.
You cannot have two shapes. The list screen wants a name; the detail screen wants everything. With one entity you get one shape, and the list endpoint ships fields nobody renders.
A DTO is the contract, written down
A DTO — a data transfer object — is a class whose only job is to be the shape of a request or a response:
public class CustomerView {
public final Long id;
public final String name;
public final String email;
public CustomerView(Long id, String name, String email) {
this.id = id; this.name = name; this.email = email;
}
}$ curl -i -X POST /customers -d '{"name":"Bo","email":"bo@example.com","age":30}'
HTTP/1.1 201
Location: /customers/2
{"id":2,"name":"Bo","email":"bo@example.com"}Three fields, because three fields are the contract. Adding passwordHash to the entity now changes nothing about what this endpoint returns, and that is the property worth paying for: the leak becomes impossible rather than unlikely.
Note also what is not here. The request body had age, the response does not. A request DTO and a response DTO are different shapes for good reasons — a client sends a password and never receives one; the server sends an id and never accepts one.
"But that is boilerplate"
It is, and the objection deserves a straight answer rather than a dismissal.
The mapping code is genuinely repetitive. What you are buying with it is that the shape of your API is a thing somebody chose, written in a file, visible in a diff, and reviewable. Every alternative gives that up to some degree:
- MapStruct generates the mapper at compile time. Real boilerplate removed, the DTO kept. This is the usual answer on a Java team and it is a good one.
- Projections straight from the repository — the previous course's interface projections — skip the entity entirely for read paths. The narrowest
select, no persistence context, no mapping code. For a list endpoint this is often the best answer of all. - Records, when your baseline allows them, make a response DTO a single line.
@JsonIgnoreon the entity is the one to be wary of. It works, and it means your API's shape is now scattered across annotations on a class whose purpose is persistence — and it is opt-out, so the next field somebody adds is public by default.
That last distinction is the one to carry: a DTO is opt-in — a field appears in your API because somebody wrote it there. Annotating the entity is opt-out — a field appears because nobody remembered to hide it. The 200 at the top of this lesson is what opt-out looks like in production.