When building REST APIs with Spring Boot, it’s common to see beginners return entities directly from their controllers. At first glance, this seems fine—the data flows, the response looks right, and everything “just works.”
But what happens when your entity evolves? When sensitive fields accidentally get exposed? Or when lazy-loaded relationships break your JSON?
This is where DTOs (Data Transfer Objects) become essential. In this post, you’ll learn what DTOs are, why returning entities is a bad practice, and how to design a clean service layer that uses DTOs as the only communication contract between your controller and your core domain.
No frameworks. No magic. Just clean code, solid design, and a scalable approach.
What Is a DTO?
A DTO (Data Transfer Object) is a simple data structure whose sole responsibility is to carry data across layers of your application.
In Spring Boot applications, DTOs are commonly used to:
- Receive input from the client (
POST
/PUT
requests) - Return structured output to the client (
GET
/DELETE
responses)
Unlike JPA entities, DTOs are not tied to the database. They represent the shape of the data you want to expose, and that shape is entirely up to you.
With Java 14+, DTOs can be defined concisely using records:
public record UserDTO(String name, String email) {}
Why You Shouldn’t Return Entities from Controllers
Returning JPA entities directly from your controllers creates a fragile and unsafe architecture. Here’s why:
1. Sensitive Data Exposure
Entities often contain fields that should never reach the client. Examples:
- Password hashes
- Internal flags
- Audit timestamps
- Foreign key relationships
If you return an entity, you risk leaking private or irrelevant data.
2. Tight Coupling to the Database Schema
Entities reflect how your data is stored—not how it should be presented. If your frontend depends on your entity structure, any change in the database can break your client contracts.
By returning your entities directly, your DB model becomes your API contract.
3. Serialization and Lazy Loading Issues
JPA entities with @OneToMany
or @ManyToOne
relationships are often lazily loaded. If Jackson tries to serialize a proxy object, you’ll hit exceptions like:
com.fasterxml.jackson.databind.JsonMappingException: failed to lazily initialize a collection
Or worse: infinite recursion due to circular references.
4. Performance Overhead
Entities tend to carry too much data. DTOs allow you to shape and minimize your payload for exactly what the client needs—nothing more.
The Clean Approach: DTO In, DTO Out (Controller <-> Service)
Let’s walk through a real-world example where:
- Controllers deal only with DTOs
- Services handle all the mapping
- Entities stay hidden from external layers
Example: Clean Architecture with DTOs
Step 1: Define the JPA Entity
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
private String name;
private String email;
private String password;
private LocalDateTime createdAt;
// Getters and setters
}
Note: This class includes fields (id
, password
, createdAt
) that we don’t want to expose via the API.
Step 2: Define DTOs as Records
public record CreateUserRequest(String name, String email, String password) {}
public record UserResponse(String name, String email) {}
CreateUserRequest
will be used in thePOST /users
endpoint.UserResponse
will be returned fromGET
andPOST
operations.
Step 3: Implement the Repository
public interface UserRepository extends JpaRepository<User, Long> {}
Spring will generate the implementation automatically.
Step 4: Implement the Service
@Service
public class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public UserResponse createUser(CreateUserRequest request) {
User user = new User();
user.setName(request.name());
user.setEmail(request.email());
user.setPassword(request.password()); // Assume hashed later
user.setCreatedAt(LocalDateTime.now());
User saved = repository.save(user);
return toResponse(saved);
}
public UserResponse getUserById(Long id) {
User user = repository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found"));
return toResponse(user);
}
private UserResponse toResponse(User user) {
return new UserResponse(user.getName(), user.getEmail());
}
}
✅ Mapping is explicit
✅ Entity is isolated
✅ DTOs are the only exposed format
Step 5: Implement the Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService service;
public UserController(UserService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody CreateUserRequest request) {
UserResponse response = service.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return ResponseEntity.ok(service.getUserById(id));
}
}
The controller doesn’t touch the entity. It only:
- Accepts DTOs
- Returns DTOs
- Delegates all business logic to the service
Advantages of This Pattern
✅ Encapsulation
The controller and the client don’t know or care how data is stored.
✅ Security
Sensitive fields are never exposed, even accidentally.
✅ Maintainability
You can evolve your entities or DTOs independently.
✅ Testability
Service methods are easily testable by verifying DTO inputs and outputs.
Final Thoughts
Returning entities from your Spring Boot controllers might seem like a shortcut—but it’s one that comes with long-term cost.
Using DTOs:
- Gives you full control over your API contract
- Keeps your domain model protected
- Helps you avoid common pitfalls like serialization errors and tight coupling
You don’t need external libraries or mapping frameworks to get this right. With a bit of manual work and clear boundaries between layers, your Spring Boot apps will be cleaner, safer, and easier to evolve.
Remember: Controllers speak in DTOs. Services do the translation. Entities stay behind the curtain.