Should you return a JPA entity from a REST controller?
No. A serialiser walks every field, so lazy associations load during serialisation and any column added to the table appears in the response — your API contract silently becomes your schema. Bidirectional mappings recurse until the stack runs out, and the annotations people reach for to stop that are patches on a boundary that should not hold an entity.
The Answer
Say this in the room. 45 seconds.
- No. Four separate problems, and they are not all performance problems.
- A serialiser touches every field, so lazy associations load during serialisation — outside the transaction, one query each, in a phase nothing measures.
- Your API contract becomes your schema. Add a column and it appears in the response; rename one and you have broken every client, from a migration nobody thought was public.
- That includes columns you did not mean to publish — a password hash, an internal note, a soft-delete flag.
- Bidirectional mappings recurse. Order → Item → Order until the stack runs out.
- The usual fixes —
@JsonIgnore,@JsonManagedReference, DTO-shaped annotations on the entity — are patches on a boundary that should not have held an entity. - Return a record. It says exactly what the API returns, and it cannot lazy-load, recurse, or leak.
Understand It
The serialiser below is a model: it reflects over an object's fields the way Jackson reflects over its getters. That one behaviour — it touches everything — is what all four problems come from. Jackson's annotations and Hibernate's proxies are not modelled.
Serialising is not a read
// An entity as JPA would have it: a couple of columns and a lazy collection.
int[] loads = {0};
class OrderEntity {
long id = 7;
String reference = "REF-7";
Lazy<List<String>> items = new Lazy<>(() -> List.of("sku-a", "sku-b"), loads);
}
var entity = new OrderEntity();
System.out.println(" lazy loads before serialising : " + loads[0]);
System.out.println(" json : " + serialise(entity));
System.out.println(" lazy loads after serialising : " + loads[0]); lazy loads before serialising : 0
json : {"id":7,"reference":"REF-7","items":["sku-a","sku-b"]}
lazy loads after serialising : 1The controller did not touch items. The serialiser did, because a serialiser's job is to emit everything, and it has no way to know that one of those fields costs a database query.
That single load is the whole mechanism behind two things people meet separately. With open-in-view on, it succeeds and issues a query per association per row — an N+1 that happens after the transaction commits, where no timer and no query-count assertion is watching. With open-in-view off, it throws LazyInitializationException mid-response, so the client receives a truncated body and a 200 status that has already been written.
Neither is a serialisation bug. Both are the consequence of handing a half-loaded object to something whose contract is to read all of it.
The API contract you did not write
// Sprint 1. The entity mirrors the table, and the controller returns it.
class UserV1 {
long id = 42;
String email = "ada@example.com";
String displayName = "Ada";
}
System.out.println(" v1 entity -> " + serialise(new UserV1()));
// Sprint 2. Someone adds two columns. Nobody touches the controller.
class UserV2 {
long id = 42;
String email = "ada@example.com";
String displayName = "Ada";
String passwordHash = "$2a$12$Nn0Xy8kQ...";
String internalRiskNotes = "flagged 2026-02 for review";
}
System.out.println(" v2 entity -> " + serialise(new UserV2()));
System.out.println();
System.out.println(" fields the API now exposes : " + exposedFields(new UserV2()));
// The DTO names what the API returns, so the same two columns change nothing.
record UserResponse(long id, String displayName) {}
var v2 = new UserV2();
System.out.println(" v2 via DTO -> " + serialise(new UserResponse(v2.id, v2.displayName))); v1 entity -> {"id":42,"email":"ada@example.com","displayName":"Ada"}
v2 entity -> {"id":42,"email":"ada@example.com","displayName":"Ada","passwordHash":"$2a$12$Nn0Xy8kQ...","internalRiskNotes":"flagged 2026-02 for review"}
fields the API now exposes : [id, email, displayName, passwordHash, internalRiskNotes]
v2 via DTO -> {"id":42,"displayName":"Ada"}A migration added two columns and published a password hash and an internal risk note. No controller changed. No API documentation changed. No review touched anything called an API.
This is the argument that matters, and it is a coupling argument rather than a performance one. Returning the entity makes every future schema change an API change, in both directions:
- Adding a column publishes it. Sometimes that is merely noise; sometimes, as here, it is a disclosure.
- Renaming a column breaks every client. The person doing the rename is looking at a database migration and has no reason to think they are editing a public interface.
- Changing a type reshapes the JSON.
inttoBigDecimalturns1999into1999.00.
A DTO makes the response an explicit, reviewable artefact. The last line is the same entity with the same two extra columns, and the response is unchanged — because someone had to name each field they meant to publish.
Bidirectional mappings do not survive a serialiser
// A perfectly ordinary bidirectional mapping: an author has books, a book
// knows its author. Nothing about it is wrong as a data model.
var author = new Author("Ursula");
new Book("The Dispossessed", author);
System.out.println(" " + serialise(author)); {"name":"Ursula","books":[{"title":"The Dispossessed","author":{"name":"Ursula","books":[{"title":"The Dispossessed","author":{"name":"...recursion stopped at depth 6","books":"...recursion stopped at depth 6"}}]}}]}This model stops at depth six so the page can show you the shape. Jackson has no such guard: it recurses until the stack runs out and throws StackOverflowError, or — worse on a large graph — produces a response of many megabytes before it does.
The mapping is not the bug. A @OneToMany with a mappedBy and a back-reference is exactly what JPA asks for, and navigating from either end is why you modelled it that way. It becomes a bug only when something walks the object graph without knowing where to stop, which is precisely what happens at the boundary.
The standard patches — @JsonIgnore on the back-reference, @JsonManagedReference and @JsonBackReference, @JsonIdentityInfo — all work, and all involve putting serialisation annotations on a persistence class so that one particular consumer can read it. The entity now serves two masters, and the next consumer that wants the back-reference has no way to get it.
Reference
The correct implementation, the configuration, and the migration path. Copy from here.
The shape to use
// A record says what the API returns. It cannot lazy-load, recurse or leak.
public record OrderResponse(
Long id,
String reference,
String customerName,
List<LineResponse> lines) {
public record LineResponse(String sku, int quantity) {}
}
@GetMapping("/orders/{id}")
public OrderResponse get(@PathVariable Long id) {
return orderService.findResponse(id); // mapping happens in the service
}
The mapping must happen inside the transaction, while the associations can still be loaded. A controller that maps an entity it received from a service has moved the loading back outside, which is the problem again with an extra class.
Building it without loading the entity at all
// A constructor expression: the database returns exactly these columns.
@Query("""
select new com.example.OrderSummary(o.id, o.reference, c.name, size(o.lines))
from Order o join o.customer c
where o.status = :status
""")
List<OrderSummary> summarise(@Param("status") Status status);
// A Spring Data interface projection: the same idea with less typing.
public interface OrderSummary {
Long getId();
String getReference();
String getCustomerName(); // resolves through the customer association
}
List<OrderSummary> findByStatus(Status status);
Better than mapping an entity, for a read endpoint. There is no entity in the persistence context, so no snapshot, no dirty checking, no lazy proxies and no chance of an accidental write.
Requests, which have the mirror problem
// Taking an entity as a request body lets a caller set anything in the table.
@PostMapping("/users")
public User create(@RequestBody User user) { ... } // don't
// The request DTO names what a caller may send, and validates it.
public record CreateUserRequest(
@NotBlank @Email String email,
@NotBlank @Size(max = 80) String displayName) {}
This direction is the more dangerous one and gets less attention. Binding a request body straight onto an entity is mass assignment: a caller who guesses a field name can set role, accountBalance, or id — pointing the write at a different row. The DTO is the allowlist.
If you are stuck with entities for now
@JsonIgnore // stops the recursion, hides it from everyone
private Order order;
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) // silences proxy noise
@Entity class Order { ... }
Both work and neither is the goal. Treat them as a way to stop the bleeding while DTOs are introduced endpoint by endpoint, and write down that that is what they are — otherwise the annotations accumulate and the entity becomes a serialisation format with a table attached.
Mapping, without writing it by hand
| Approach | Good for | Cost |
|---|---|---|
| Hand-written constructor / static factory | Small projects, unusual shapes | Verbose, and it drifts |
| MapStruct | Compile-time generation, no reflection | A build plugin, generated code to read |
| Constructor expression in JPQL | Read endpoints | The query names every column |
| Spring Data projection | Read endpoints, less typing | Less control over nesting |
| ModelMapper / reflection mappers | Prototypes | Silent at compile time; a renamed field is a runtime null |
For anything read-only, prefer the query-level options. They avoid the entity entirely, which removes the whole class of problem rather than mapping around it.
Scenarios
Real situations, with the decision and the argument.
1. A security review finds a password hash in an API response.
Almost certainly an entity being serialised, and a column added later than the endpoint. Nobody exposed it deliberately; the migration that added it was reviewed as a schema change because that is what it was.
The immediate fix is @JsonIgnore and a credential rotation, since the value has been served. The actual fix is a DTO on that endpoint, and the argument to make in the postmortem is that this will recur on the next column unless the boundary changes — the review process cannot catch it, because there is nothing about a migration that says "this edits a public API".
2. StackOverflowError while serialising, after adding a back-reference.
The relationship became bidirectional and the serialiser now has a cycle. The mapping is correct and the boundary is not.
@JsonIgnore on the back-reference stops it in about a minute, and is worth doing if the service is down. Then take the endpoint to a DTO, because the annotation has made the entity un-serialisable in the other direction for whoever needs that next — and they will add a second annotation to work around your first.
3. Someone proposes DTOs and someone else calls it boilerplate.
Both are right about the code and only one is right about the trade. A record with four components is genuinely more typing than returning the entity, and what it buys is that the response is an artefact somebody had to write down.
The concrete version of the argument beats the principle: ask what happens when the next migration adds a column. With an entity it ships to production in the response; with a DTO nothing changes. Then offer the cheaper middle ground for read endpoints — a projection interface or a constructor query, which is less code than the entity version because it also removes the mapping.
4. Turning off open-in-view breaks serialisation on twelve endpoints.
Each break is an endpoint that was lazy-loading during serialisation, and the failure is worse than it looks: the exception happens mid-response, after the status line has been written, so the client sees a 200 with a truncated body.
Fix them by fetching in the service and returning DTOs, in traffic order. Do not respond by turning the setting back on — that restores the queries you just discovered, and the truncated-body failure mode was only ever hidden.
5. An API needs the same entity in three shapes for three consumers.
This is the case where the entity-as-response approach fails on its own terms rather than on principle. A mobile client wants four fields, an internal dashboard wants twenty, a partner integration wants a different naming convention — and one class cannot be all three without annotations that contradict each other.
Three records, one per consumer, is the honest answer and reads worse than it works: each is small, each is independently versionable, and a change for one consumer cannot break the others. That last property is the one that pays for the duplication.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "Should you return an entity from a controller?" No. A serialiser walks every field, so lazy associations load during serialisation, every column becomes public API, and bidirectional mappings recurse. Return a record that names what the response contains.
2. "What actually goes wrong with the lazy fields?"
The serialiser touches them. With open-in-view on you get an N+1 that happens after the transaction commits, where nothing is measuring. With it off you get LazyInitializationException mid-response — a 200 status already sent and a truncated body.
3. "What's the coupling argument?" Your API contract becomes your schema. Adding a column publishes it, renaming one breaks every client, and changing a type reshapes the JSON — all from migrations that nobody reviews as API changes.
4. "Is that a real security problem or a theoretical one?" Real. A column added later — a password hash, an internal note, a soft-delete flag — appears in the response with no code change and nothing in review to catch it, because the change under review was a migration.
5. "Why do bidirectional mappings break serialisation?"
Each side references the other, so a serialiser walking the graph never terminates. StackOverflowError, or a very large response before it. The mapping is correct; walking it blindly is not.
6. "What about @JsonIgnore?"
It works, and it puts a serialisation concern on a persistence class so one consumer can read it. The next consumer that wants that field has no way to get it. Fine as a stopgap, not as the design.
7. "Isn't a DTO just boilerplate?" It is more code and it is the point — the response becomes something a person wrote down and a reviewer can see. For read endpoints a projection or constructor query is usually less code than the entity version, because it removes the mapping too.
8. "What about accepting an entity as a request body?"
Worse than returning one. It is mass assignment — a caller who guesses a field name can set role, a balance, or the id, which redirects the write to a different row. The request DTO is the allowlist.
9. "Where should the mapping happen?" Inside the transaction, in the service. A controller mapping an entity it received has moved the lazy loading back outside the session, which is the original problem with an extra class in the way.
Code traps
Trap A — predict before you run:
@RestController
class UserController {
@GetMapping("/users/{id}")
public User get(@PathVariable Long id) {
return repository.findById(id).orElseThrow();
}
}
Answer
Every column in the users table is now a public API field, including the ones added next year. If the entity has passwordHash, it is in the response.
Beyond the disclosure, two failure modes depend on configuration you did not set here. With open-in-view on, each lazy association serialises into a query — an N+1 outside the transaction. With it off, serialisation throws after the 200 has been written, so the client sees a truncated body rather than an error.
And a rename in a migration silently breaks every consumer, because nothing about editing a column name suggests you are editing an interface.
Trap B:
@PostMapping("/users")
public User create(@RequestBody User user) {
return repository.save(user);
}
Answer
Mass assignment. A caller sends any field the entity has — {"email":"...","role":"ADMIN"} — and it binds. If they send an id, save() becomes an update to that row, so a create endpoint edits an arbitrary user.
The response half has the same problem as Trap A: it returns the saved entity, so the password hash and everything else goes back out.
A CreateUserRequest record with validation annotations is the allowlist, and the service maps it onto a new entity setting only the fields a caller is allowed to control.
Trap C:
@Transactional(readOnly = true)
public Order find(Long id) {
return repository.findById(id).orElseThrow();
}
// in the controller
OrderResponse response = mapper.toResponse(orderService.find(id));
Answer
The DTO is right and it is built in the wrong place. find returns an entity whose associations were never fetched, and by the time the mapper touches them the transaction has ended — so this is LazyInitializationException, or an N+1 outside the session if open-in-view is on.
Introducing a DTO does not help if the mapping happens after the boundary. Move toResponse inside the service method, where the session is still open and the fetching can be planned.
The general shape: the transaction should end with a fully-formed value object, not with an entity that still needs the transaction.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "It's fine for internal APIs." | The schema coupling and the leaked columns do not care who is calling. |
"@JsonIgnore solves it." | It moves a serialisation concern onto a persistence class. |
| "DTOs are just boilerplate." | They make the response reviewable; for reads a projection is less code. |
| "Only a performance problem." | It is a disclosure and a coupling problem first. |
"Lombok's @Data makes it safe." | It generates getters, which is exactly what the serialiser walks. |
| "It only breaks with bidirectional mappings." | That is one of four problems, and the loudest one. |
| "Accepting an entity as a body is fine." | Mass assignment — a caller can set role or id. |
| "Map it in the controller." | The transaction has ended; the lazy loads have nowhere to go. |
Check Yourself
Q1. A migration adds a password_hash column. No application code changes. How does it end up in an API response?
Answer
Because the controller returns the entity, and a serialiser emits every field it finds — it has no way to know which ones were meant to be public. The API contract is therefore the table, so adding a column publishes it, renaming one breaks clients, and changing a type reshapes the JSON. Nothing in review catches it because the change under review was a database migration, which nobody reads as an edit to a public interface.
Q2. You introduce a DTO and still get LazyInitializationException. Why?
Answer
The mapping is happening after the transaction ended — typically the service returns the entity and the controller maps it. The DTO only helps if it is built while the session is open and the associations can still be fetched, so the mapping belongs inside the service method. The general rule is that a transaction should end with a fully-formed value object rather than an entity that still depends on it.
Q3. Why is accepting an entity as a @RequestBody worse than returning one?
Answer
Because it is mass assignment. Returning an entity leaks data; accepting one lets a caller write any field the entity has — role, a balance, or an id, which turns a create endpoint into an update against an arbitrary row. The response problem is bounded by what is in the table; the request problem is bounded by what an attacker can guess. A request DTO with validation is the allowlist, and the service maps only the fields a caller may control.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Leak a column you never exposed | 10 min |
| Challenge | Six endpoints, and what each publishes | 25 min |
| Production | The migration that changed the API | 45 min |
| Interview | Full round replay | 10 min |
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Leak a column you never exposed
One concept, guided. Near-impossible to fail.
- Challenge25 min
Six endpoints, and what each publishes
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The migration that changed the API
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — entities at the boundary
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- What is dirty checking, and why did my entity save without a save() call?
- spring jackson serialisation — not written yet
- api versioning — not written yet
Questions that lead here
What are the fetch types, and which is the default for each mapping?
LAZY loads on first access, EAGER loads with the parent — and the defaults are not the same on both sides: @ManyToOne and @OneToOne default to EAGER, collections default to LAZY. Touching a lazy association after the session closes is LazyInitializationException, and Spring Boot's open-in-view setting hides that by keeping the session alive through rendering.
Asked constantlyintermediate1–15 yrs11 min readJpa hibernate
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-31.