DTO's and Clean API Design
This post links to: Building a Spring Boot CRUD App With Postgres from Scratch: The Complete Guide.
This guide follows on from Building Your First REST API. In our last post, we built basic CRUD endpoints using Spring Boot. But if you looked closely, we were passing entity objects like User directly into our controllers. This might work in small demos, but it’s not API best practice.
In this post, we’ll explore how and why to use Data Transfer Objects (DTOs) to decouple our internal data model from our API layer. This will also lay the groundwork for validation in the next post.
What Are DTOs?
As mentioned previously, DTO stands for Data Transfer Object. These objects are used to transfer data between your client and API. This means they are not tied to any database model or internal structure.
Here's an example of a DTO:
// UserCreateRequest.java
public class UserCreateRequest {
private String name;
private String email;
}
You’re in control of what comes in and what goes out without exposing your DB structure.
A prime example is on object creation not providing an ID and letting the business logic generate that for you.
Why You Shouldn't Expose Entities?
Entities often contain irrelevant or sensitive information such as:
- Internal DB-specific annotations (
@Entity,@Id) - Fields like password, roles,
@CreatedDatethat shouldn’t be public
Moreover, entities evolve over time and your API shouldn’t break every time your DB model changes. Implementing DTO's provides security, maintainability, and flexibility
Think of your DTOs as your API’s contract of what it consumes and produces.
Creating Your DTOs
Let's add the following DTOs into our application!
Request DTO for User Creation
public class UserCreateRequest {
private String name;
private String email;
// constructor, getters...
}
Request DTO for Updating Users
public class UserUpdateRequest {
private String name;
private String email;
// constructor, getters...
}
Request DTO for Users Responses
public class UserResponse {
private Long id;
private String name;
private String email;
// constructor, getters...
}
We can also create a simple mapper to map between User entities and UserResponse DTOs:
@Component
public class UserMapper {
public User toEntity(final UserCreateRequest request) {
final var user = new User();
user.setName(request.getName());
user.setEmail(request.getEmail());
return user;
}
public UserResponse toResponse(final User user) {
return new UserResponse(user.getId(), user.getName(), user.getEmail());
}
}
Update the Controller and Service With DTOs
We can now update our UserController and UserService to use our newly created DTOs:
Updated User Service
@Service
public class UserService {
private final UserMapper userMapper;
private final UserRepository userRepository;
public UserService(final UserMapper userMapper, final UserRepository userRepository) {
this.userMapper = userMapper;
this.userRepository = userRepository;
}
public List<User> getAllUsers() {
return userRepository.findAll();
}
public User getUserById(final Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found with ID: " + id));
}
public User createUser(final UserCreateRequest user) {
final var newUser = userMapper.toEntity(user);
return userRepository.save(newUser);
}
public User updateUser(final Long id, final UserUpdateRequest updatedUser) {
final var user = getUserById(id);
user.setName(updatedUser.getName());
user.setEmail(updatedUser.getEmail());
return userRepository.save(user);
}
public void deleteUser(final Long id) {
userRepository.deleteById(id);
}
}
Updated User Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
private final UserMapper userMapper;
public UserController(final UserService userService, final UserMapper userMapper) {
this.userService = userService;
this.userMapper = userMapper;
}
@GetMapping
public List<UserResponse> getAllUsers() {
return userService.getAllUsers().stream()
.map(userMapper::toResponse)
.toList();
}
@GetMapping("/{id}")
public UserResponse getUserById(@PathVariable("id") final Long id) {
final var userById = userService.getUserById(id);
return userMapper.toResponse(userById);
}
@PostMapping
public UserResponse createUser(@RequestBody final UserCreateRequest user) {
final var newUser = userService.createUser(user);
return userMapper.toResponse(newUser);
}
@PutMapping("/{id}")
public UserResponse updateUser(@PathVariable("id") final Long id,
@RequestBody final UserUpdateRequest updatedUser) {
final var updateUser = userService.updateUser(id, updatedUser);
return userMapper.toResponse(updateUser);
}
@DeleteMapping("/{id}")
public void deleteUser(@PathVariable("id") final Long id) {
userService.deleteUser(id);
}
}
Setting up these DTOs in our project provides us with:
- Better encapsulation of data
- Safer API boundaries
- Easier evolution of internal models
- Easier validation
- Cleaner API documentation (e.g. with OpenAPI/Swagger)
What's Next?
So to recap we:
- Learnt what DTOs are
- Why it's dangerous to expose certain entity fields
- How to structure
Create,Update, andResponseDTOs - How to map between DTOs and entities
- How to set ourselves up for request validation