When implementing a REST API, you might be tempted to return Entities directly in your responses — and honestly, who hasn’t been there? But once you go down that path, you end up with a growing list of @JsonIgnore annotations, unintended fields leaking out, and eventually a stack overflow from circular references.
On the other hand, hand-writing toDto() / toEntity() methods is painful too. You add fields every time the model grows, fix mappings every time types change during refactoring, and tests keep getting pushed aside. Let’s use MapStruct to generate all this boilerplate automatically.
Why You Shouldn’t Return Entities Directly in Responses
Entities are packed with database concerns: password hashes, relations to other tables, audit columns — a lot of things API clients simply don’t need. By introducing DTOs, you can define the response shape independently as part of your API design.
As hand-written conversion methods multiply, you’ll run into missed updates when fields are added, inconsistent implementations across the codebase, and insufficient test coverage. MapStruct generates implementation classes from interfaces at compile time, so there’s no runtime overhead, and the generated code is straightforward Java — easy to debug.
What Is MapStruct?
MapStruct is a mapping library based on annotation processors. ModelMapper is often mentioned as an alternative, but it performs runtime mapping via reflection. Many projects choose MapStruct for its performance and debuggability (a detailed comparison is left for another article).
Adding Dependencies and Configuring the Annotation Processor
Maven
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<!-- If using Lombok, declare it before MapStruct -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
When using both Lombok and MapStruct, it’s important to declare Lombok first. If the order is wrong, MapStruct won’t be able to recognize the getters/setters generated by Lombok.
Gradle
dependencies {
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
// If using Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
Basic Usage of @Mapper
First, define your Entity and DTO.
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String userName;
private String email;
// getters/setters omitted
}
public class UserDto {
private Long id;
private String name; // different field name
private String email;
// getters/setters omitted
}
Next, create the Mapper interface.
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "userName", target = "name")
UserDto toDto(User user);
@Mapping(source = "name", target = "userName")
User toEntity(UserDto dto);
}
Adding componentModel = "spring" causes MapStruct to generate an implementation class annotated with @Component, making it available for dependency injection as a Spring Bean. Fields with matching names like id and email are mapped automatically without needing @Mapping.
Specifying Mappings for Fields with Different Names
Use @Mapping(source = "userName", target = "name") to explicitly define the correspondence. For multiple fields, simply stack the annotations. Fields that don’t need mapping can be excluded with ignore = true.
@Mapping(source = "userName", target = "name")
@Mapping(target = "password", ignore = true)
UserDto toDto(User user);
Mapping Nested Objects
Consider a case where User contains an Address. The cleanest approach is to define a separate Mapper for the child object and compose them with uses.
@Mapper(componentModel = "spring")
public interface AddressMapper {
AddressDto toDto(Address address);
}
@Mapper(componentModel = "spring", uses = AddressMapper.class)
public interface UserMapper {
UserDto toDto(User user);
}
If you want to flatten the structure (e.g., map address.city directly to the city field of the DTO), you can use dot notation.
@Mapping(source = "address.city", target = "city")
UserDto toDto(User user);
Adding Custom Conversion Logic
For cases that simple field copying can’t handle, use default methods with @Named.
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(source = "birthDate", target = "birthDateStr", qualifiedByName = "dateToString")
UserDto toDto(User user);
@Named("dateToString")
default String dateToString(LocalDate date) {
return date == null ? null : date.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
}
Since you can write default methods directly in the interface, there’s no need to create a separate implementation class.
Mapping a List Takes Just One Line in the Mapper
List<UserDto> toDtoList(List<User> users);
That’s it. MapStruct generates an implementation that iterates over the list internally. Looking at the generated code, it’s simply a for-each loop calling the single-item toDto() — clean and straightforward.
Injecting into a Service
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserDto getUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
return userMapper.toDto(user);
}
}
With componentModel = "spring" configured, it can be injected just like any other Spring Bean. Combined with REST API CRUD implementation, the Controller → Service → Mapper layering falls naturally into place.
Inspecting the Generated Code
After building, UserMapperImpl.java is generated under target/generated-sources/annotations/. It’s plain, readable Java — no different from what you’d write by hand — so checking here is the fastest way to verify mapping behavior.
In IntelliJ IDEA, right-click this directory and select Mark Directory as → Generated Sources Root so the IDE includes the generated classes in its completion index.
Writing Unit Tests Against MapperImpl
You can test without spinning up the Spring context.
class UserMapperTest {
private final UserMapper mapper = new UserMapperImpl();
@Test
void toDto_fieldsAreMappedCorrectly() {
User user = new User();
user.setId(1L);
user.setUserName("tanaka");
user.setEmail("[email protected]");
UserDto dto = mapper.toDto(user);
assertThat(dto.getId()).isEqualTo(1L);
assertThat(dto.getName()).isEqualTo("tanaka");
assertThat(dto.getEmail()).isEqualTo("[email protected]");
}
}
Just instantiate it directly with new UserMapperImpl(). If there are nested child Mappers, pass them as constructor arguments (e.g., new UserMapperImpl(new AddressMapperImpl())).
Troubleshooting When the Build Fails
If MapperImpl is not being generated, check that mapstruct-processor is listed under annotationProcessorPaths in the maven-compiler-plugin configuration. Adding it to <dependencies> alone will not work.
A “No property named ‘xxx’” error is caused by a typo in source or target. The same error occurs when Lombok hasn’t generated the expected getter, so also double-check the declaration order of your dependencies.
Summary
With MapStruct, hand-written conversion code is replaced by simple interface definitions. Because generation happens at compile time, it’s type-safe, and the generated code is plain Java — debugging is never a problem. Combined with JPA entity design and pagination implementation, your entire API layer becomes cleanly organized. Start by adding the dependency and try it out with the simplest Mapper you have.