DTOs, and why you should not expose entities
A DTO (Data Transfer Object) is a simple object that carries data across a boundary — here, between your API and its clients. The single most common architectural mistake in Spring APIs is skipping DTOs and exposing JPA entities directly. This lesson is why that is a mistake, and how DTOs — cleanly, with Java records — fix it. Dakiya uses DTOs throughout, and the examples are verified against the running API.
The temptation: return the entity
You have a Parcel entity (a JPA @Entity, the next module's subject). The lazy path is to accept and
return it directly:
@GetMapping("/{id}")
public Parcel getOne(@PathVariable Long id) { // returning the ENTITY — don't
return repository.findById(id).orElseThrow();
}
It works, briefly. Then the problems arrive, and they are serious.
Why exposing entities is a mistake
Four concrete reasons, each one a bug or breach waiting to happen:
- You leak fields you did not mean to expose. An entity has every column — internal flags, audit
fields, a password hash on a
User, foreign keys. Serialising the entity ships all of them to the client. Adding a sensitive field to the entity later silently adds it to the API response — a data leak by accident. - Your API contract becomes your database schema. Clients now depend on your table structure. Rename a column or restructure a relationship and you break every client, because the entity's shape is the API response. The two should be able to change independently; exposing entities welds them together.
- Lazy-loading blows up serialisation. JPA relationships are often lazy (next module). When Jackson tries
to serialise an entity with a lazy association outside a transaction, you get a
LazyInitializationExceptionor accidental extra queries — a class of confusing error that vanishes once you map to a DTO inside the transaction. - Accepting an entity as input is dangerous.
@RequestBody Parcellets a client set any field, includingid,status, internal flags — "mass assignment". A malicious body could set a field you never intended clients to control.
The rule, which every Spring reviewer expects: never expose entities at the API boundary — map to DTOs in both directions.
DTOs with records: request and response
Java records are perfect for DTOs — immutable, concise, exactly a bag of named fields. Dakiya defines a request DTO (what a client may send) and a response DTO (what the API returns):
// what the client is ALLOWED to send when creating a parcel
public record CreateParcelRequest(
String recipientName,
String destinationCity
) {}
// what the API returns
public record ParcelResponse(
Long id, String trackingCode, String recipientName, String destinationCity, String status
) {
static ParcelResponse from(Parcel p) { // map entity -> DTO
return new ParcelResponse(p.getId(), p.getTrackingCode(), p.getRecipientName(),
p.getDestinationCity(), p.getStatus().name());
}
}
Two separate DTOs, on purpose. The request DTO lists only the fields a client may set — recipientName
and destinationCity, not id, trackingCode or status (the server assigns those). So a client cannot
set the status or id, no matter what they put in the body — the field simply is not on the DTO, so it is
ignored (verified: a POST body only populated recipient and city; the server generated the tracking code
and set status BOOKED). The response DTO lists exactly what the API exposes — and note it turns the
Status enum into a string and omits any internal entity fields. The controller maps between them:
@PostMapping
public ResponseEntity<ParcelResponse> create(@RequestBody CreateParcelRequest req, ...) {
Parcel p = new Parcel("PKG-...", req.recipientName(), req.destinationCity()); // DTO -> entity
p = repository.save(p);
return ResponseEntity.created(location).body(ParcelResponse.from(p)); // entity -> DTO
}
Verified against the running API: the response was exactly {"id":1,"trackingCode":"PKG-...","recipientName": "Asha","destinationCity":"Pune","status":"BOOKED"} — the DTO's fields, nothing more.
The mapping, and keeping it simple
DTOs require mapping between entity and DTO, and that is the honest cost — a bit of translation code. Keep it lightweight:
- A static factory (
ParcelResponse.from(entity)) or a small mapper method is fine and explicit — Dakiya uses this. Readable, no dependency, easy to see what maps to what. - Mapping libraries (MapStruct) generate the mapping code for large projects with many DTOs, removing boilerplate. Reach for one when the hand-written mapping becomes tedious, not before.
- Do the mapping in the service or controller boundary, not deep in the domain — the DTO is a concern of the API edge.
The mapping cost is real but small, and it buys you the four protections above: no leaked fields, an API contract independent of the schema, no lazy-loading serialisation surprises, and no mass-assignment. That trade — a little mapping code for a clean, safe, stable API boundary — is one every professional Spring codebase makes. When you see a controller returning entities in a code review, flagging it is exactly right.
Check your work
What a DTO is. A simple object carrying data across the API boundary; Java records are ideal (immutable, concise).
Why not expose entities. They leak every field (including sensitive ones, and any added later), weld the API contract to the database schema (schema changes break clients), cause lazy-loading serialisation errors, and — as input — allow mass assignment of fields clients should not control.
Separate request and response DTOs. The request DTO lists only client-settable fields (so id/status
cannot be set — verified), the response DTO lists only what the API exposes (enum → string, no internal
fields).
The mapping. Map DTO↔entity at the boundary — a static factory (from(entity)) for small projects,
MapStruct for large ones; the small mapping cost buys a clean, safe, stable API.
Practice
- Return a JPA entity directly from a GET; add an internal/sensitive field to the entity and watch it appear in the response — the leak.
- Replace it with a
ParcelResponserecord and afrom(entity)mapper; confirm the response contains only the DTO's fields. - Accept
@RequestBody CreateParcelRequestwith only client-settable fields; POST a body that also includes"status":"DELIVERED"and confirm it is ignored (the field is not on the DTO). - Rename a column on the entity and confirm the API response is unaffected because it maps through the DTO — contract independent of schema.
- Add a lazy relationship to an entity, return the entity from a controller, and reproduce a lazy-loading serialisation problem; then map to a DTO and see it disappear.
- Reason about when a mapping library (MapStruct) would be worth adding versus hand-written
frommethods.
Official documentation
- Spring — Data Transfer Objects (guidance) — Binding request bodies to your own types.
- Java — Records — Immutable data carriers, ideal for DTOs.
- MapStruct — Generated entity↔DTO mapping for larger projects.
Next: validation with Bean Validation.
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