Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 031 / 100

DTOs vs JPA entities: a practical Java & Spring Boot guide

Never return entities from your API.

Expona Admin6 min read

The mistake that breaks every client

You've just deployed a new API endpoint. Within hours, mobile teams are calling—their app is crashing because a field they weren't expecting appeared in the response, and worse, it's null half the time. This scenario repeats across teams because returning JPA entities directly from REST controllers is one of the most common pitfalls in Spring Boot development. The question of DTOs vs JPA entities: a practical Java & Spring Boot guide isn't academic—it's about preventing production incidents and keeping your API stable as your schema evolves.

Why entities expose more than you intend

When you annotate a class with @Entity and return it directly from a controller, you're publishing your entire database schema to clients. This includes every field, relationship, and internal detail you never meant to expose.

Consider a typical user entity:

java
@Entity
public class User {
    @Id
    private Long id;
    
    private String email;
    private String passwordHash;  // Never expose this
    
    @Transient
    private String internalNotes;  // Implementation detail
    
    @ManyToOne(fetch = FetchType.LAZY)
    private Organization org;  // Lazy proxy, can break serialization
    
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

Return this directly and you've sent passwordHash over the wire. You've also forced clients to handle Hibernate lazy-loading proxies—those mysterious objects that throw exceptions when accessed outside a transaction. You've exposed every timestamp and internal note field, making it harder to refactor later.

We've all seen the JSON response that includes a field like org$$_jvst_123—that's a Hibernate proxy that didn't deserialize properly. Clients break. Your support team gets tickets. You learn the hard way that entities and APIs should never be the same thing.

DTOs make the API contract explicit

A Data Transfer Object is a simple, focused class that represents exactly what you want to send to the client. It's your API contract made visible.

java
public record UserResponse(
    Long id,
    String email,
    String organizationName,
    LocalDateTime createdAt
) {}

Now the contract is clear: clients get four fields, nothing more. No proxies, no internal details, no sensitive data. If someone on your team wants to add a field to the entity, they have to explicitly decide whether to expose it in the DTO. That decision point catches mistakes.

Here's the mapping in your service layer:

java
@Service
public class UserService {
    public UserResponse getUserById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("User not found"));
        
        return new UserResponse(
            user.getId(),
            user.getEmail(),
            user.getOrganization().getName(),
            user.getCreatedAt()
        );
    }
}

Notice we're calling .getName() on the organization inside the service, not in the JSON serialization layer. This gives us control over N+1 queries and lets us decide whether to fetch that relationship eagerly or handle it separately.

The controller stays simple:

java
@RestController
@RequestMapping("/api/users")
public class UserController {
    @GetMapping("/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        return userService.getUserById(id);
    }
}

Changing schema without breaking clients

Real systems evolve. You'll add columns, rename fields, split tables, add computed values. DTOs decouple your database from your API contract.

Let's say you need to track user verification status. You add it to the entity:

java
@Entity
public class User {
    // ... existing fields
    
    private boolean emailVerified;
    private LocalDateTime emailVerifiedAt;
}

But you don't want to expose emailVerifiedAt externally yet—it's still experimental. With a DTO, you control exactly what goes out:

java
public record UserResponse(
    Long id,
    String email,
    String organizationName,
    boolean emailVerified,  // New field, clients can adapt
    LocalDateTime createdAt
) {}

Old clients that don't know about emailVerified won't break—they just ignore it. New clients can use it. Later, when you want to add emailVerifiedAt`, you do that in a new API version or a separate endpoint without touching the entity at all.

This is versioning done right. Without DTOs, you're forced into awkward compromises: keep fields even when you don't need them, or break every client simultaneously.

We've worked on platforms where a single schema change rippled through mobile teams, backend services, and external partners. DTOs eliminate that coupling. Your database becomes an implementation detail, not an API detail.

Records make DTOs nearly free

Before Java 14, writing DTOs meant boilerplate: constructors, getters, equals, hashCode, toString. Java Records changed that.

A record is a compact way to define an immutable data carrier:

java
public record UserResponse(
    Long id,
    String email,
    String organizationName,
    LocalDateTime createdAt
) {}

That's it. The compiler generates everything: the constructor, getters, equals, hashCode, and toString. No annotations, no Lombok, no generated code comments cluttering your IDE.

Records work beautifully with Spring's JSON serialization. Jackson understands them out of the box:

java
@RestController
@RequestMapping("/api/users")
public class UserController {
    @GetMapping
    public List<UserResponse> listUsers() {
        return userService.getAllUsers();
    }
}

Jackson will serialize the record to JSON using its accessor methods. You can customize serialization with standard annotations if needed:

java
public record UserResponse(
    Long id,
    String email,
    String organizationName,
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
    LocalDateTime createdAt
) {}

For input DTOs, records work equally well. Spring will deserialize JSON into them automatically, and you get validation integration with standard Bean Validation annotations:

java
public record CreateUserRequest(
    @NotBlank String email,
    @NotBlank String name,
    @Email String verificationEmail
) {}

Then in your controller:

java
@PostMapping
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
    return userService.createUser(request);
}

Records eliminated the friction. DTO overhead went from "why bother" to "why wouldn't you." Now there's no reason to return raw entities.

Common mistakes

Mistake 1: Using @JsonIgnore on entities instead of creating DTOs. This is a band-aid. You're still exposing internal structure, just hiding parts of it. Clients can still see lazy proxies and sensitive fields. Instead, build proper DTOs with only what you intend to share.

Mistake 2: Putting business logic in DTOs. DTOs are dumb carriers. They shouldn't fetch data, validate complex rules, or call repositories. That logic lives in services. DTOs deserialize input and carry output—nothing more.

Mistake 3: Creating one DTO per entity. You often need multiple DTOs from a single entity. A list response might show fewer fields than a detail response. A creation DTO won't have an ID or timestamps. Design DTOs around use cases, not entities.

Mistake 4: Mapping DTOs manually in every controller. Repetitive mapping code is error-prone and clutters your controllers. Use MapStruct or write a small service utility to handle it consistently. Better yet, keep mapping in your service layer where it belongs.

Key takeaways

  • Never return JPA entities from REST endpoints—they expose internal fields, lazy proxies, and sensitive data that should stay private.
  • DTOs make your API contract explicit and stable. Clients know exactly what they're getting, and you can evolve your schema without breaking them.
  • Your database is an implementation detail. Your API is a contract. DTOs keep those concerns separate.
  • Java Records eliminate DTO boilerplate. They're immutable, work seamlessly with Spring and Jackson, and cost almost nothing to write.
  • Design DTOs around use cases, not entities. You'll often have multiple DTOs per entity depending on whether you're listing, creating, or updating.
  • Keep mapping logic in the service layer, not the controller. It's cleaner, easier to test, and easier to refactor.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles