How MapStruct's annotation processor generates type-safe, compile-time DTO mapping code — removing the fragile handwritten boilerplate that breaks silently when your model changes.
Every sufficiently large Java codebase has a mapping layer. Somewhere between your persistence model and your API contract, you’re converting one object graph to another — copying fields, transforming values, flattening or enriching data. The question is whether that code is generated or handwritten, type-checked or not, tested or just assumed to be correct.
I spent years writing mapping code by hand. Field-by-field assignment, BeanUtils.copyProperties calls, the occasional ModelMapper dependency that worked fine until it didn’t. The problem with all of these is the same: they fail silently. Add a field to your domain model, forget to update the mapper, and the field is quietly null in your API response. Compilation doesn’t help you. The test you didn’t write doesn’t help you either.
MapStruct solves this properly. It’s an annotation processor that generates the mapping code at compile time — readable Java source you can inspect — and fails the build if something doesn’t add up.
<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>
If you’re using Lombok alongside MapStruct (and you probably are), the processor order matters. Lombok must run before MapStruct so that generated getters and setters are visible:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<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>
Take a Betfair domain model. The internal RunnerPrice entity holds everything we need to persist — selection IDs, prices, timestamps. The API response needs a slimmer RunnerPriceDto that omits internal fields and formats the price differently.
@Entity
public class RunnerPrice {
private Long id;
private Long selectionId;
private String runnerName;
private BigDecimal bestBackPrice;
private BigDecimal bestLayPrice;
private BigDecimal tradedVolume;
private Instant capturedAt;
private String internalMarketRef; // never expose this
}
public record RunnerPriceDto(
Long selectionId,
String runnerName,
BigDecimal bestBackPrice,
BigDecimal bestLayPrice,
BigDecimal tradedVolume
) {}
The mapper interface is minimal:
@Mapper(componentModel = "spring")
public interface RunnerPriceMapper {
@Mapping(target = "internalMarketRef", ignore = true)
RunnerPriceDto toDto(RunnerPrice entity);
List<RunnerPriceDto> toDtoList(List<RunnerPrice> entities);
}
That componentModel = "spring" annotation registers the generated implementation as a Spring bean, so you can @Autowired it anywhere. The generated class lives in target/generated-sources and looks exactly like the handwritten version you’d have written anyway — except it’s always correct.
When field names differ between source and target, @Mapping is your tool:
@Mapper(componentModel = "spring")
public interface MarketMapper {
@Mapping(source = "marketCatalogueId", target = "marketId")
@Mapping(source = "event.eventName", target = "eventName")
@Mapping(source = "marketStartTime", target = "scheduledOff")
@Mapping(target = "internalRef", ignore = true)
MarketSummaryDto toDto(MarketCatalogue catalogue);
}
The source = "event.eventName" form traverses nested objects — MapStruct handles the null-safety for nested property paths automatically, returning null for the target field if any step in the chain is null.
MapStruct can delegate to other mappers automatically. If your source has a Settlement sub-object that maps to a SettlementDto, just declare both mappers and MapStruct picks up the delegation:
@Mapper(componentModel = "spring", uses = {SettlementMapper.class})
public interface BetMapper {
BetDto toDto(Bet bet);
}
The uses attribute tells MapStruct which other mappers are available for type conversion. The generated code calls settlementMapper.toDto(bet.getSettlement()) internally. You don’t write that — it’s inferred.
@AfterMappingSometimes the generated mapping isn’t enough. A common case: the source has a raw double price from the Betfair API, and the target wants it rounded to two decimal places and expressed as a formatted string for display.
@Mapper(componentModel = "spring")
public abstract class PriceMapper {
public abstract PriceDisplayDto toDisplayDto(RunnerPrice price);
@AfterMapping
protected void formatPrice(RunnerPrice source,
@MappingTarget PriceDisplayDto target) {
if (source.getBestBackPrice() != null) {
target.setFormattedBack(
String.format("%.2f", source.getBestBackPrice())
);
}
}
}
@AfterMapping runs after the generated mapping completes. @MappingTarget injects the partially-populated target object. Use an abstract class rather than an interface when you need concrete method bodies.
For reusable conversion logic — say, converting Instant to a formatted String across multiple mappers — use qualifier annotations:
public class TimeFormatters {
@Named("toIso")
public String instantToIso(Instant instant) {
return instant == null ? null
: DateTimeFormatter.ISO_INSTANT.format(instant);
}
}
@Mapper(componentModel = "spring", uses = TimeFormatters.class)
public interface EventMapper {
@Mapping(source = "startTime", target = "startTimeIso",
qualifiedByName = "toIso")
EventDto toDto(Event event);
}
Because the implementation is generated, testing is straightforward — just test the behaviour you care about. The generated class is a plain Java object; you don’t need Spring context for unit tests:
class RunnerPriceMapperTest {
private final RunnerPriceMapper mapper =
Mappers.getMapper(RunnerPriceMapper.class);
@Test
void shouldMapSelectionIdAndName() {
RunnerPrice entity = new RunnerPrice();
entity.setSelectionId(12345678L);
entity.setRunnerName("Frankel");
entity.setBestBackPrice(new BigDecimal("3.50"));
entity.setInternalMarketRef("internal-secret");
RunnerPriceDto dto = mapper.toDto(entity);
assertThat(dto.selectionId()).isEqualTo(12345678L);
assertThat(dto.runnerName()).isEqualTo("Frankel");
assertThat(dto.bestBackPrice()).isEqualByComparingTo("3.50");
}
@Test
void shouldNotExposeInternalRef() {
// RunnerPriceDto doesn't even have the field — this confirms the
// @Mapping(ignore=true) is preventing accidental exposure if the
// DTO ever gains that field in the future.
assertThat(RunnerPriceDto.class.getDeclaredFields())
.extracting(Field::getName)
.doesNotContain("internalMarketRef");
}
}
Mappers.getMapper() instantiates the generated implementation without the Spring container. Keep the test focused on the mappings that could realistically go wrong — name mismatches, ignored fields, custom conversions.
MapStruct is not magic. It’s a code generator that produces ordinary Java. That’s exactly the point — when you open target/generated-sources/annotations/RunnerPriceMapperImpl.java, you see exactly what it does. There’s nothing hidden.
The real benefit shows up six months into a project, when a team member adds a field to RunnerPrice and the build flags that RunnerPriceDto doesn’t have a corresponding mapping. Without MapStruct, that field is silently absent from the API response. With it, you have a compile error and a clear fix.
If you’re building microservices with complex domain-to-DTO mappings and want to get the boilerplate out of the way for good, get in touch.