MapStruct and ModelMapper, both popular Java libraries for object-to-object mapping, tackle a common development task with surprisingly different philosophies, leading to significant divergences in performance and type safety.

Let’s see them in action. Imagine you have a Customer entity and a CustomerDTO you want to map between.

// Source Entity
public class Customer {
    private Long id;
    private String firstName;
    private String lastName;
    private Address address;

    // Getters and setters...
}

// Target DTO
public class CustomerDTO {
    private Long customerId;
    private String fullName;
    private String city;
    private String street;

    // Getters and setters...
}

// Address class
public class Address {
    private String street;
    private String city;

    // Getters and setters...
}

MapStruct Example:

MapStruct works by generating mapping code at compile time. You define an interface, and MapStruct generates an implementation.

@Mapper
public interface CustomerMapper {
    CustomerMapper INSTANCE = Mappers.getMapper(CustomerMapper.class);

    @Mapping(source = "id", target = "customerId")
    @Mapping(source = "address.street", target = "street")
    @Mapping(source = "address.city", target = "city")
    CustomerDTO customerToCustomerDTO(Customer customer);

    // Custom mapping for fullName
    default String mapFullName(Customer customer) {
        return customer.getFirstName() + " " + customer.getLastName();
    }
}

ModelMapper Example:

ModelMapper uses reflection at runtime to perform mappings. You typically create a ModelMapper instance and use its map() method.

ModelMapper modelMapper = new ModelMapper();

// Custom mapping configuration
modelMapper.createTypeMap(Customer.class, CustomerDTO.class)
           .addMappings(mapper -> mapper.map(src -> src.getAddress().getStreet(), CustomerDTO::setStreet))
           .addMappings(mapper -> mapper.map(src -> src.getAddress().getCity(), CustomerDTO::setCity))
           .addMappings(mapper -> mapper.map(src -> src.getFirstName() + " " + src.getLastName(), CustomerDTO::setFullName));

// Mapping
Customer customer = new Customer();
// ... set properties ...
CustomerDTO customerDTO = modelMapper.map(customer, CustomerDTO.class);

The core problem MapStruct solves is the boilerplate of manual mapping code. Instead of writing dto.setCustomerId(entity.getId());, you declare the mapping once. ModelMapper aims to solve this by making mappings convention-over-configuration, inferring them automatically where possible.

Internally, MapStruct generates plain Java code. This means the mapping logic is compiled directly into your application. When you call CustomerMapper.INSTANCE.customerToCustomerDTO(customer), you’re executing optimized, pre-compiled bytecode. There’s no runtime introspection involved in the actual mapping execution.

ModelMapper, on the other hand, relies heavily on Java Reflection. When you call modelMapper.map(customer, CustomerDTO.class), ModelMapper inspects both Customer and CustomerDTO classes at runtime, identifies matching properties (by name, typically), and uses reflection to get values from the source and set them on the target. For complex mappings or nested properties, it builds a mapping plan.

The most significant lever you control with MapStruct is the explicit declaration of mappings and the use of custom mapping methods. You can define how individual fields map, handle nested objects, and even implement complex transformations. With ModelMapper, you primarily configure mappings by specifying source and destination properties, or by providing custom converters for more intricate logic. The power here lies in its ability to infer mappings automatically, reducing explicit configuration for simple cases.

One thing MapStruct does that ModelMapper doesn’t, and which is crucial for understanding its performance advantage, is that it generates a dedicated mapping implementation for each pair of source and target types. This generated code is then compiled and optimized by the Java compiler just like any other Java source file. This means that when you use MapStruct, you’re essentially running hand-written (but auto-generated) mapping code, devoid of the overhead typically associated with runtime reflection, dynamic class loading, or interpretation. This compile-time generation is the key to its speed.

The next logical step after mastering object mapping is understanding how to integrate these mapping strategies into your persistence layer, specifically when dealing with detached entities and DTOs in frameworks like Spring Data JPA.

Want structured learning?

Take the full Java course →