Building REST APIs
DTOs and MapStruct — Separating API Contracts from Domain Models
Designing data transfer structures and automating conversion mapping compilation.
Introduction
In well-architected applications, database entities should never be exposed directly to client consumers. Instead, we use Data Transfer Objects (DTOs) to encapsulate the exact contract required by the API. To automate the conversion between domain models and DTOs without manual boilerplate, Spring developers rely on MapStruct, a high-performance, compile-time mapping framework.
Why It Matters
Exposing database entities creates severe issues: it leaks internal structures (like password hashes or audit fields), causes runtime lazy loading exceptions when serialization triggers database queries, and couples DB schema updates directly to the client contract. MapStruct solves this efficiently by generating plain Java mapping code at compile-time, avoiding the high CPU and reflection costs of runtime mappers like ModelMapper.
Real-World Analogy
Think of an internal payroll spreadsheet. The accountant's master file (Domain Entity) contains columns for name, address, tax status, internal review score, and bank details.
When sending the employee a pay stub (DTO), we do not hand over the entire spreadsheet. An automated compiler machine (MapStruct) reads the sheet and copies only the allowed fields (e.g. name and net pay) to a separate certificate. This maintains security boundaries.
How It Works
MapStruct integrates into the build phase (Maven/Gradle annotation processors). When compilation starts:
- The compiler detects interfaces annotated with
@Mapper. - It analyzes properties in source and target classes, matching fields with identical names and compatible types automatically.
- It generates a concrete implementation class (e.g.,
UserMapperImpl.class) under target generated sources. At runtime, mapping is simple, standard getter/setter execution, running at native Java speed with zero reflection.
Practical Example
Let's map a User database entity to a clean UserResponseDto and see how to use the mapper:
// 1. The Domain Entity class User { private Long id; private String username; private String email; private String passwordHash; // SHOULD NEVER BE EXPOSED// Getters and setters omitted for brevity public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPasswordHash() { return passwordHash; } public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } }
// 2. The API DTO class UserResponseDto { private Long id; private String username; private String email;
// Getters and setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } }
Now, define the MapStruct interface. Using componentModel = "spring" registers the generated implementation as a Spring Bean:
import org.mapstruct.Mapper; import org.mapstruct.Mapping;@Mapper(componentModel = "spring") public interface UserMapper {
// Simple matching mapping UserResponseDto toDto(User user);
// Reverse mapping if needed User toEntity(UserResponseDto dto); }
Inject the mapper into your controller using standard constructor injection:
import org.springframework.web.bind.annotation.*;@RestController @RequestMapping("/api/users") public class UserController {
private final UserMapper userMapper;
public UserController(UserMapper userMapper) { this.userMapper = userMapper; }
@GetMapping("/{id}") public UserResponseDto getUser() { User user = new User(); // Simulated database lookup user.setId(1L); user.setUsername("john_doe"); user.setEmail("john@example.com"); user.setPasswordHash("secret_hash_value");
return userMapper.toDto(user); // MapStruct transforms User to UserResponseDto } }
Quick Quiz
Q1: Why is MapStruct faster than ModelMapper at runtime?
A) It compiles to native assembly code
B) It runs mappings inside database procedures
C) It generates standard Java classes with direct getters/setters during compilation, avoiding runtime Java Reflection
D) It runs mapping asynchronous processes in background threads
Answer: C — MapStruct works during the compilation phase, generating plain Java bytecode, which results in near-zero CPU mapping overhead.
Q2: How do you register a MapStruct mapper as an injectable Spring bean?
A) Annotate the interface with @Component
B) Configure the mapper annotation as @Mapper(componentModel = "spring")
C) Build a manual mapping config factory bean class
D) MapStruct is registered as a Spring bean automatically by default
Answer: B — Specifying componentModel = "spring" instructs MapStruct to append Spring's @Component annotation to the generated implementation class.
Scenario-Based Challenge
Production Scenario:
Your database entity stores a user's name in two properties: firstName and lastName. However, your target DTO requires a single property named fullName. How do you configure MapStruct to handle this mapping?
View Solution
You can specify custom mapping behavior using a default method in the mapper interface or using MapStruct annotations:
@Mapper(componentModel = "spring") public interface UserMapper {
@Mapping(target = "fullName", expression = "java(user.getFirstName() + " " + user.getLastName())") UserResponseDto toDto(User user); }
Alternatively, you can write a helper method inside the interface or use
@AfterMapping to build the full name string manually.
Interview Questions
1. How can you handle unmapped properties warnings during compilation in MapStruct?
By default, MapStruct alerts unmapped properties as warnings. In production, configure unmapped target policies inside the mapper annotation: @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR). This turns unmapped properties into compilation failures, prompting developers to address them immediately.
2. What is the benefit of using @InheritInverseConfiguration?
It allows MapStruct to reuse the configuration defined on a forward mapping (e.g. source to target) for the reverse mapping (e.g. target to source) without redeclaring every @Mapping rule in reverse.
Production Considerations
Keep mappers stateless. Do not invoke external database queries inside MapStruct mappings, as this will lead to unexpected N+1 query patterns or performance bottlenecks. Perform mapping operations outside database transaction bounds when possible, freeing up connection pools faster.