Stop Returning Entities From Your Controllers

3 min read

  • Spring Boot
  • JPA
  • REST

Returning the entity straight from the controller is the fastest way to get an endpoint working, which is why almost every project has done it:

@GetMapping("/users/{id}")
public User get(@PathVariable long id) {
    return repository.findById(id).orElseThrow();
}

Jackson serialises the entity, the JSON looks right, and you move on. Here is what you have actually signed up for.

Your database schema is now your public API

Every field on the entity is in the response, including the ones you did not think about. Rename a column and you have broken every client. Add a field for an internal feature and you have published it. The mapping between table and payload is now one to one and permanent, enforced by nothing except everybody remembering.

The worst version of this is credentials. A User entity with passwordHash on it will serialise passwordHash, and the only thing standing between that and your API response is somebody having remembered @JsonIgnore.

Lazy loading meets serialisation

This is the failure people actually hit first. Jackson serialises by calling getters, and a lazy association's getter triggers a query. So one of two things happens.

If the session is still open, you get the N+1 problem in its purest form: serialising a list of 50 orders fires 50 extra queries for their customers, and then more for whatever those touch. The endpoint works, it is just quietly the slowest thing in the application.

If the session has closed, Jackson hits the proxy and you get LazyInitializationException rendered as a 500, from a stack trace that mentions serialisation rather than anything you wrote.

Neither of these is fixed by tuning the entity. They are caused by handing a database aware object to a component whose job is to touch every property on it.

Bidirectional relationships and infinite loops

@Entity class Order {
    @ManyToOne Customer customer;
}
@Entity class Customer {
    @OneToMany(mappedBy = "customer") List<Order> orders;
}

Serialise an order and Jackson walks to the customer, then to that customer's orders, then back to the customer, until the stack overflows. The usual answers, @JsonManagedReference and @JsonBackReference, work by putting API concerns into your persistence model, which is the problem rather than the fix.

Requests are worse than responses

On the way in it stops being a design argument and becomes a security one:

@PostMapping("/users")
public User create(@RequestBody User user) { ... }

The client controls every field it can name. That includes id, so a POST can overwrite an existing row. It includes role, so a user can make themselves an admin. It includes any flag you add later without thinking about this endpoint. This is mass assignment, and it is a real vulnerability class, not a theoretical one.

The DTO version

A record per direction, holding exactly the fields that endpoint deals in:

public record UserResponse(long id, String name, String email) {
    static UserResponse from(User user) {
        return new UserResponse(user.getId(), user.getName(), user.getEmail());
    }
}

public record CreateUserRequest(@NotBlank String name, @Email String email) {}
@PostMapping("/users")
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
    return UserResponse.from(service.create(request.name(), request.email()));
}

Records are ideal for this, for the reasons in Java Records Explained: immutable, no boilerplate, and the component list is the API contract written down in one place.

Note what the mapping does to the lazy loading question. UserResponse.from reads three fields, so those three are the only ones ever touched. If you need the customer name on an order response, you have to fetch it deliberately, which is exactly when you would write the join.

Doing it without a mapping library

You do not need MapStruct or ModelMapper to start, and reflective mappers reintroduce the original problem by copying whatever fields happen to match. A static factory on the DTO is explicit, greppable and free.

For read heavy endpoints you can skip the entity altogether with a projection, so the database returns only the columns the DTO needs:

@Query("select new com.example.UserResponse(u.id, u.name, u.email) from User u where u.active = true")
List<UserResponse> findActive();

The objection

Yes, it is more classes. That is the cost, and it is real on a small CRUD service where the DTO and the entity look identical on day one.

They stop looking identical the first time either side changes independently, and after that the DTO is the thing letting you change the schema without breaking clients, and change the API without a migration. The duplication is not an accident, it is the decoupling.