The mapper problem in enterprise Java applications
Converting entities to DTOs and back is one of the most repetitive tasks in backend development. Every API endpoint needs it, every database layer needs it, and if you're not careful, you end up with hundreds of lines of boilerplate getter and setter chains. MapStruct solves this with a simple idea: declare what you want to map, and let the compiler generate type-safe code for you. Mapping DTOs with MapStruct: what developers should know starts with understanding that you're not trading performance for convenience—you're getting both, plus compile-time safety that catches errors before they reach production.
We've used MapStruct across multiple production services handling e-commerce, CRM, and SaaS workloads. The library generates plain Java code at compile time, which means zero reflection overhead, zero runtime magic, and blazingly fast execution. The real win, though, is that misconfigurations fail your build immediately.
Declare a mapper and let the compiler generate the implementation
The core of MapStruct is declaring an interface or abstract class with method signatures that describe what you want to map. MapStruct scans these declarations at build time and generates the implementation as a new class.
@Mapper(componentModel = "spring")
public interface ProductMapper {
ProductDto toDto(Product product);
Product toEntity(CreateProductRequest request);
}That's it. You write two lines, MapStruct writes the rest. When you compile, the processor generates ProductMapperImpl with actual getter/setter logic. Because componentModel = "spring", the generated class is also annotated with @Component, so you can inject it directly into your services.
Here's a real scenario: an e-commerce service with orders. Your JPA entity has lazy-loaded collections, timestamps, internal IDs. Your API response DTO strips those down to what the client needs.
@Entity
public class Order {
private Long id;
private String reference;
private LocalDateTime createdAt;
private User createdBy; // lazy-loaded
private List<OrderLine> lines; // lazy-loaded
private BigDecimal total;
}
public record OrderResponse(
String reference,
BigDecimal total,
LocalDateTime createdAt
) {}
@Mapper(componentModel = "spring")
public interface OrderMapper {
OrderResponse toResponse(Order order);
}MapStruct matches fields by name. If the name and type align, it maps automatically. If they don't, you control the behavior. The generated code is straightforward—no magic, just assignment statements. You can inspect it in target/generated-sources and understand exactly what happens at runtime.
Rename fields with @Mapping to handle mismatches
Not every field name lines up. Your database schema uses snake_case, your API uses camelCase. Your entity has a field called creatorId but the DTO calls it createdBy. That's where @Mapping comes in.
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "createdBy", source = "creatorId")
@Mapping(target = "modifiedOn", source = "lastModified")
UserProfileDto toProfileDto(User user);
}The target is the DTO field you're filling. The source is the entity field you're reading from. MapStruct applies these mappings when generating code, and again, everything is type-safe and verifiable at compile time.
You can also map nested objects and collections:
@Mapper(componentModel = "spring", uses = AddressMapper.class)
public interface CustomerMapper {
@Mapping(target = "homeAddress", source = "primaryAddress")
@Mapping(target = "tags", source = "labels")
CustomerDto toDto(Customer customer);
}The uses parameter tells MapStruct to delegate nested mappings to AddressMapper. For collections like labels to tags, if both are lists of the same element type, MapStruct applies the element mapper automatically. If types differ, you can provide a custom method or another mapper.
Fail the build when target properties are unmapped
Imagine you add a new field to your API response DTO but forget to map it from the entity. Without strict checking, that field silently stays null in production. MapStruct can catch this at compile time.
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface ProductMapper {
ProductDto toDto(Product product);
}Set unmappedTargetPolicy = ReportingPolicy.ERROR and the build fails if any field in the target DTO is not mapped. This is enforced at the mapper level, and you can override it per method if needed. We've prevented dozens of bugs this way—a field gets added to the schema, the mapper method doesn't get updated, and your CI pipeline catches it before code review.
The alternatives are ReportingPolicy.WARN (logs a warning) and ReportingPolicy.IGNORE (silent). For production code, we recommend ERROR. If a field genuinely should stay null or default, you can mark it explicitly:
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface OrderMapper {
@Mapping(target = "internalNotes", ignore = true)
OrderResponse toResponse(Order order);
}The ignore = true tells MapStruct to skip that field deliberately, so the build won't fail.
When hand-written mapping is the right call
MapStruct is powerful, but it's not always the answer. If you have three DTOs and a service that's unlikely to change, writing a static utility method is simpler than setting up a mapper and adding a dependency.
public class UserMapper {
public static UserDto toDto(User user) {
return new UserDto(
user.getId(),
user.getEmail(),
user.getCreatedAt()
);
}
}This is clear, requires zero configuration, and works without any annotation processing. The downside is that it doesn't scale. As your codebase grows, DTOs multiply, mappings become complex, and maintaining hundreds of hand-written converters becomes a burden.
We use this rule of thumb: if the mapping logic is simple and the number of DTOs is small (fewer than 5–10), hand-written is fine. If you're building a complex domain model with many entities, many API representations, and frequent schema changes, MapStruct pays for itself immediately.
You can also mix both approaches. Use MapStruct for the majority of mappings and fall back to hand-written logic for the handful that need custom transformation:
@Mapper(componentModel = "spring")
public abstract class OrderMapper {
public abstract OrderDto toDto(Order order);
@Mapping(target = "discountedPrice", expression = "java(calculateDiscount(order.getPrice()))")
public abstract OrderSummaryDto toSummary(Order order);
protected BigDecimal calculateDiscount(BigDecimal price) {
// Custom logic here
return price.multiply(BigDecimal.valueOf(0.9));
}
}The expression attribute lets you write Java code inline for complex transformations. MapStruct generates the rest.
Common mistakes
Forgetting to add the annotation processor to your build
MapStruct needs the annotation processor on the compilation classpath. If you forget, your mapper interfaces compile but no implementation is generated, and you get a runtime error when trying to inject the mapper.
In Maven:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
<scope>provided</scope>
</dependency>In Gradle:
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'The processor is provided or annotationProcessor scoped, so it doesn't bloat your runtime classpath.
Mapping to null fields instead of custom logic
MapStruct matches by name and type. If a field name exists in both source and target but the type differs (e.g., createdAt is a Long timestamp in the entity but a LocalDateTime in the DTO), MapStruct won't automatically convert. You need to tell it how:
@Mapper(componentModel = "spring")
public interface EventMapper {
@Mapping(target = "occuredAt",
expression = "java(java.time.LocalDateTime.ofEpochSecond(event.getTimestamp() / 1000, 0, java.time.ZoneOffset.UTC))")
EventDto toDto(Event event);
}Or, better, use a method reference:
@Mapper(componentModel = "spring")
public abstract class EventMapper {
public abstract EventDto toDto(Event event);
@Mapping(target = "occuredAt", source = "timestamp")
protected abstract EventDto map(Event event);
protected LocalDateTime toLocalDateTime(Long timestamp) {
if (timestamp == null) return null;
return LocalDateTime.ofEpochSecond(timestamp / 1000, 0, ZoneOffset.UTC);
}
}MapStruct will call toLocalDateTime when it sees a Long to LocalDateTime conversion needed.
Creating too many mapper interfaces
A common pattern is one mapper per entity. That's often overkill. Group related mappings logically. If you have Product, ProductCategory, and ProductVariant, consider whether they belong in one ProductMapper or three separate ones. One mapper is cleaner unless the grouping feels unnatural.
Not testing the generated code
MapStruct generates code, but your mapper interfaces are still part of your codebase. Test them. A simple test catches mapping errors early:
@ExtendWith(SpringExtension.class)
@SpringBootTest
class ProductMapperTest {
@Autowired
private ProductMapper mapper;
@Test
void toDto_mapsAllFields() {
Product product = new Product(1L, "Widget", BigDecimal.TEN);
ProductDto dto = mapper.toDto(product);
assertThat(dto.name()).isEqualTo("Widget");
assertThat(dto.price()).isEqualByComparingTo(BigDecimal.TEN);
}
}Key takeaways
- MapStruct generates mapper implementations at compile time, eliminating reflection overhead and catching configuration errors at build time, not production runtime.
- Declare mapper interfaces with method signatures; the annotation processor fills in the implementation with plain Java code.
- Use
@Mapping(target, source)to rename or customize field mappings when names or types don't align. - Set
unmappedTargetPolicy = ReportingPolicy.ERRORto fail the build if any target property is not mapped, preventing silent null fields. - For small, stable codebases with few DTOs, hand-written mapping is simpler and requires no setup. MapStruct scales when your domain grows.
- Always include the annotation processor in your build configuration and ensure the generated code is in your IDE's source path for easy inspection.
- Test your mappers like any other production code; a simple unit test validates that fields are mapped correctly.






Comments
Loading comments…