ModelMapper
ModelMapper is a runtime, reflection-based object mapper. Instead of generating code at compile time, it inspects objects at runtime and copies matching properties using an intelligent matching strategy. The appeal is minimal boilerplate: one dependency, one @Bean, and a single map() call handles most conversions with zero configuration.
Adding the dependency
ModelMapper is a single library with no annotation processor.
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.2.2</version>
</dependency>
Configuring a ModelMapper bean
ModelMapper is thread-safe, so register one shared instance as a @Bean and inject it everywhere.
@Configuration
public class MappingConfig {
@Bean
public ModelMapper modelMapper() {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setMatchingStrategy(MatchingStrategies.STRICT)
.setFieldMatchingEnabled(true)
.setSkipNullEnabled(true);
return modelMapper;
}
}
STRICTmatching only maps properties whose names match precisely — far safer than the defaultSTANDARD, which can guess wrong on similarly named fields.skipNullis handy forPATCH: nulls in the source won’t overwrite existing values.
Warning: The default
STANDARDmatching strategy is loose and occasionally maps the wrong fields (e.g.billingCityintoshippingCity). Always setSTRICTfor predictable behaviour.
Basic map() usage
The core API is map(source, TargetType.class). ModelMapper instantiates the target and copies matching properties.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
private final ModelMapper modelMapper;
@Transactional(readOnly = true)
public ProductResponse findById(Long id) {
Product product = repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
return modelMapper.map(product, ProductResponse.class);
}
}
Map a collection with a stream:
List<ProductResponse> dtos = repository.findAll().stream()
.map(p -> modelMapper.map(p, ProductResponse.class))
.toList();
Note: ModelMapper maps best into mutable classes with a no-arg constructor and setters. Immutable records need extra configuration, which is one reason MapStruct pairs more naturally with records.
Custom type maps
When names don’t line up — for example flattening category.name into categoryName — define an explicit TypeMap once, typically right after creating the bean.
@Bean
public ModelMapper modelMapper() {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
modelMapper.typeMap(Product.class, ProductResponse.class)
.addMappings(mapper -> mapper.map(
src -> src.getCategory().getName(),
ProductResponse::setCategoryName));
return modelMapper;
}
For conversions ModelMapper can’t infer (string formatting, enum translation), use a Converter:
Converter<BigDecimal, String> priceFormatter =
ctx -> "$" + ctx.getSource().toPlainString();
modelMapper.typeMap(Product.class, ProductResponse.class)
.addMappings(m -> m.using(priceFormatter)
.map(Product::getPrice, ProductResponse::setFormattedPrice));
Runtime vs compile-time tradeoffs vs MapStruct
ModelMapper and MapStruct solve the same problem from opposite ends.
| Aspect | ModelMapper (runtime) | MapStruct (compile-time) |
|---|---|---|
| Mapping resolved | At runtime via reflection | At compile time via generated code |
| Setup | One dependency + a @Bean | Dependency + annotation processor |
| Boilerplate | Lowest — often zero config | Low — interface + @Mapping |
| Performance | Slower (reflection per call) | Native field-copy speed |
| Mismatch detected | At runtime, or silently skipped | At compile time (build fails) |
| Refactor safety | Rename can break mapping unnoticed | Compiler flags it immediately |
| Records / immutability | Awkward, needs config | First-class support |
| Debuggability | Through opaque internals | Step through generated code |
| Best for | Quick wins, prototypes, small apps | Larger apps, hot paths, strict contracts |
Tip: Use ModelMapper when speed of development matters more than raw throughput and your DTOs are simple mutable classes. Reach for MapStruct when you want compile-time safety, top performance, or you’re standardising on records.
The biggest practical difference is when you find out a mapping is wrong. MapStruct tells you at build time; ModelMapper tells you in production — or never, if it silently skips an unmatched field. On a critical path, that distinction usually decides the choice.