RizTech Academy logo
RizTech Academy
Building REST APIsLesson 3 of 630 min

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 LazyInitializationException or 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 Parcel lets a client set any field, including id, 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

  1. 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.
  2. Replace it with a ParcelResponse record and a from(entity) mapper; confirm the response contains only the DTO's fields.
  3. Accept @RequestBody CreateParcelRequest with only client-settable fields; POST a body that also includes "status":"DELIVERED" and confirm it is ignored (the field is not on the DTO).
  4. Rename a column on the entity and confirm the API response is unaffected because it maps through the DTO — contract independent of schema.
  5. 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.
  6. Reason about when a mapping library (MapStruct) would be worth adding versus hand-written from methods.

Official documentation

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