Someone adds a field to a DTO. The entity already has it. MapStruct compiles fine, the tests pass, the PR merges. Two weeks later the field is null in production and nobody knows why.
The compiler knew. It just wasn’t told to care.
MapStruct is the default mapping library on most Java teams I’ve worked on, and almost nobody touches the config past @Mapper. That default config is exactly what lets the bug above happen. Here are the three things I add to every mapper to make it safe, testable, and predictable.
1. Make the compiler fail on missing mappings
By default, when a target field has no matching source, MapStruct’s unmappedTargetPolicy is WARN. A warning in an annotation processor is noise. It scrolls past in the build log and nobody reads it. So the field stays null and you find out from a bug report.
Flip it to ERROR:
@Mapper(
componentModel = MappingConstants.ComponentModel.SPRING,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface UserMapper {
UserDto map(User source);
}
Now the build fails the moment a target field has no source. Add phoneNumber to UserDto without a matching source field, and the compile breaks with the exact field name. You fix it before the code ever runs.
This is the whole point. Without ERROR, completeness is a thing humans have to remember. With it, the compiler enforces it for free. The cost of catching a mistake drops from “production incident plus a debugging session” to “a red build you fix in thirty seconds”.
2. One component model, two ways to get the mapper
Declare the component model with the constant, not a string literal:
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface UserMapper {
UserDto map(User source);
}
That makes the generated mapper a Spring bean, so you inject it like anything else:
@Service
public class UserService {
private final UserMapper userMapper;
public UserService(UserMapper userMapper) {
this.userMapper = userMapper;
}
}
Good for production. Annoying for unit tests. You don’t want to boot a Spring context just to test that one mapper turns a User into a UserDto. So don’t. MapStruct generates a plain implementation you can grab directly:
class UserMapperTest {
private final UserMapper mapper = Mappers.getMapper(UserMapper.class);
@Test
void mapsAllFields() {
User source = new User(1L, "Kyryl", "kyryl@example.com");
UserDto dto = mapper.map(source);
assertThat(dto.id()).isEqualTo(1L);
assertThat(dto.name()).isEqualTo("Kyryl");
}
}
Mappers.getMapper() returns a real instance with zero context startup. The test runs in milliseconds. Same mapper, two access paths: Spring injection in the app, the factory in tests.
3. Let MapStruct resolve types for you
This is where most hand-written mapping code is wasted. People write loops and null checks and delegation calls that MapStruct will generate if you let it. Three patterns cover almost everything.
Collections
Declare the single-element mapping and the list overload. You don’t implement the loop, MapStruct derives it from the element mapping it already has.
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface UserMapper {
UserDto map(User source);
List<UserDto> map(List<User> source);
}
The generated map(List iterates and calls map(User) per element. No for loop in your code, no .stream().map(...).toList() boilerplate.
Enums
Map an enum once with @ValueMappings. The catch nobody handles until it bites them: a new constant added to the source enum later. ANY_REMAINING is the default branch that keeps that from throwing at runtime.
@ValueMappings({
@ValueMapping(target = "ENABLED", source = "ACTIVE"),
@ValueMapping(target = "UNKNOWN", source = MappingConstants.ANY_REMAINING)
})
StatusDto map(Status source);
Someone adds Status.SUSPENDED next quarter and forgets your mapper exists. Without ANY_REMAINING, MapStruct throws an IllegalArgumentException the first time that value flows through. With it, the value maps to UNKNOWN and you handle it gracefully instead of paging someone.
Composition
Compose mappers with uses. MapStruct picks the right sub-mapper by type, so you never write delegation by hand.
@Mapper(uses = {StatusMapper.class, AddressMapper.class})
public interface OrderMapper {
OrderDto map(Order source);
}
When Order has a Status field and an Address field, MapStruct sees the types, finds the matching mapper in uses, and calls it. Add a third nested type later, register its mapper in uses, done. No manual wiring.
The honest trade-off
unmappedTargetPolicy = ERROR is not free. Every field you intentionally leave unmapped now needs an explicit @Mapping(target = "auditTimestamp", ignore = true). On a DTO with thirty fields where you only care about ten, that’s a wall of ignore = true lines, and you have to maintain them.
That’s real overhead and I won’t pretend it isn’t. My rule of thumb: turn it on the moment a mapper has more than a handful of fields, or sits on a service where DTOs and entities change often. That’s exactly where the silent-null bug lives. For a tiny three-field mapper that never changes, the ceremony isn’t worth it.
The other cost is that ANY_REMAINING can hide a mapping you genuinely meant to add. It trades a loud runtime crash for a quiet fallback. That’s the right call for resilience, but pair it with a test that asserts the constants you care about actually map where you expect.
What you actually gain
Add these up and the mapper stops being a place bugs hide. The compiler enforces completeness. The tests run without a container. The boilerplate that used to be hand-written loops and switch statements is generated from a couple of method signatures.
Define the shape once, let the generator handle the mechanical parts. That’s the whole pitch.
What does your @Mapper config look like? Do you enforce ERROR, or has the WARN default ever shipped a null to production on you? Curious whether teams turn this on by default or only after it bites.






