Do you need to write a lot of mapping code in order to map between different object models? MapStruct simplifies this task by generating mapping code. In this blog, you will learn some basic features of MapStruct. Enjoy!

1. Introduction

In a multi-layered application, one has often to write boiler-plate code in order to map different object models. This can be a tedious and error-prone task. MapStruct simplifies this task by generating the mapping code for you. It generates code during compile time and it aims to generate the code like if it was written by you.

This blog will only give you a basic overview of how MapStruct can aid you, but it will be sufficient to give you a good impression of which problem it can solve for you.

If you are using IntelliJ as IDE, you can also install the MapStruct Support Plugin which will assist you in using MapStruct.

Sources used in this blog can be found at GitHub.

2. Prerequisites

Prerequisites for this blog are:

  • Basic Java knowledge, Java 21 is used in this blog;
  • Basic Spring Boot knowledge.

3. Basic Application

The application used in this blog, is a basic Spring Boot project. By means of a Rest API, a customer can be created and retrieved. In order to keep the API specification and source code in line with each other, you will use the openapi-generator-maven-plugin. First you write the OpenAPI specification and the plugin will generate the source code for you based on the specification. The OpenAPI specification consists out of two endpoints, one for creating a customer (POST) and one for retrieving the customer (GET). The customer consists out of its name and some address data.

Customer:
  type: object
  properties:
    firstName:
      type: string
      description: First name of the customer
      minLength: 1
      maxLength: 20
    lastName:
      type: string
      description: Last name of the customer
      minLength: 1
      maxLength: 20
    street:
      type: string
      description: Street of the customer
      minLength: 1
      maxLength: 20
    number:
      type: string
      description: House number of the customer
      minLength: 1
      maxLength: 5
    postalCode:
      type: string
      description: Postal code of the customer
      minLength: 1
      maxLength: 5
    city:
      type: string
      description: City of the customer
      minLength: 1
      maxLength: 20

The CustomerController implements the generated Controller interface. The OpenAPI maven plugin makes use of its own model. In order to transfer the data to the CustomerService, DTO’s are created. These are Java records. The CustomerDto is:

public record CustomerDto(Long id, String firstName, String lastName, AddressDto address) {
}

The AddressDto is:

public record AddressDto(String street, String houseNumber, String zipcode, String city) {
}

The domain itself is used within the Service and are basic Java POJO’s. The Customer domain is:

public class Customer {
    private Long customerId;
    private String firstName;
    private String lastName;

    private Address address;

    // Getters and setters left out for brevity
}

The Address domain is:

public class Address {

    private String street;
    private int houseNumber;
    private String zipcode;
    private String city;

    // Getters and setters left out for brevity
}

In order to connect everything together, you will need to write mapper code for:

  • mapping between the API model and the DTO;
  • mapping between the DTO and the domain.

4. Mapping Between DTO and Domain

4.1 Add Dependency

In order to make use of MapStruct, it suffices to add the MapStruct Maven dependency and to add some configuration to the Maven Compiler plugin.

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>${org.mapstruct.version}</version>
</dependency>
...
<build>
	<plugins>
        ...
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${org.mapstruct.version}</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
        ...
    </plugins>
</build>

4.2 Create Mapper

The CustomerDto, AddressDto and the Customer, Address domain do not differ very much from each other.

  • CustomerDto has an id while Customer has a customerId.
  • AddressDto has a houseNumber of type String while Address has a houseNumber of type integer.

In order to create a mapper for this using MapStruct, you create an interface CustomerMapper and annotate it with @Mapper and specify the component model with the value spring. Doing this, will ensure that the generated mapper is a singleton-scoped Spring bean which can be retrieved via @Autowired.

Because both models are quite similar to each other, MapStruct will be able to generate most of the code by itself. Because the customer id has a different name in both models, you need to help MapStruct a bit. Using the @Mapping annotation, you specify the source and target mapping. For the type conversion, you do not need to do anything, MapStruct can sort this out based upon the implicit type conversions.

The corresponding mapper code is the following:

@Mapper(componentModel = "spring")
public interface CustomerMapper {

    @Mapping(source = "customerId", target = "id")
    CustomerDto transformToCustomerDto(Customer customer);

    @Mapping(source = "id", target = "customerId")
    Customer transformToCustomer(CustomerDto customerDto);

}

Generate the code:

$ mvn clean compile

In the target/generated-sources/annotations directory, you can find the generated CustomerMapperImpl class.

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2024-04-21T13:38:51+0200",
    comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21 (Eclipse Adoptium)"
)
@Component
public class CustomerMapperImpl implements CustomerMapper {

    @Override
    public CustomerDto transformToCustomerDto(Customer customer) {
        if ( customer == null ) {
            return null;
        }

        Long id = null;
        String firstName = null;
        String lastName = null;
        AddressDto address = null;

        id = customer.getCustomerId();
        firstName = customer.getFirstName();
        lastName = customer.getLastName();
        address = addressToAddressDto( customer.getAddress() );

        CustomerDto customerDto = new CustomerDto( id, firstName, lastName, address );

        return customerDto;
    }

    @Override
    public Customer transformToCustomer(CustomerDto customerDto) {
        if ( customerDto == null ) {
            return null;
        }

        Customer customer = new Customer();

        customer.setCustomerId( customerDto.id() );
        customer.setFirstName( customerDto.firstName() );
        customer.setLastName( customerDto.lastName() );
        customer.setAddress( addressDtoToAddress( customerDto.address() ) );

        return customer;
    }

    protected AddressDto addressToAddressDto(Address address) {
        if ( address == null ) {
            return null;
        }

        String street = null;
        String houseNumber = null;
        String zipcode = null;
        String city = null;

        street = address.getStreet();
        houseNumber = String.valueOf( address.getHouseNumber() );
        zipcode = address.getZipcode();
        city = address.getCity();

        AddressDto addressDto = new AddressDto( street, houseNumber, zipcode, city );

        return addressDto;
    }

    protected Address addressDtoToAddress(AddressDto addressDto) {
        if ( addressDto == null ) {
            return null;
        }

        Address address = new Address();

        address.setStreet( addressDto.street() );
        if ( addressDto.houseNumber() != null ) {
            address.setHouseNumber( Integer.parseInt( addressDto.houseNumber() ) );
        }
        address.setZipcode( addressDto.zipcode() );
        address.setCity( addressDto.city() );

        return address;
    }
}

As you can see, the code is very readable and it has taken into account the mapping of Customer and Address.

4.3 Create Service

The Service will create a domain Customer taken the CustomerDto as an input. The customerMapper is injected in the Service and is used for converting between the two models. The other way around, when a customer is retrieved, the mapper converts the domain Customer to a CustomerDto. In the Service, the customers are persisted in a basic list in order to keep things simple.

@Service
public class CustomerService {

    private final CustomerMapper customerMapper;

    private final HashMap<Long, Customer> customers = new HashMap<>();
    private Long index = 0L;

    CustomerService(CustomerMapper customerMapper) {
        this.customerMapper = customerMapper;
    }

    public CustomerDto createCustomer(CustomerDto customerDto) {
        Customer customer = customerMapper.transformToCustomer(customerDto);
        customer.setCustomerId(index);
        customers.put(index, customer);
        index++;
        return customerMapper.transformToCustomerDto(customer);
    }

    public CustomerDto getCustomer(Long customerId) {
        if (customers.containsKey(customerId)) {
            return customerMapper.transformToCustomerDto(customers.get(customerId));
        } else {
            return null;
        }
    }
}

4.4 Test Mapper

The mapper can be easily tested by using the generated CustomerMapperImpl class and verify whether the mappings are executed successfully.

class CustomerMapperTest {

    @Test
    void givenCustomer_whenMaps_thenCustomerDto() {
        CustomerMapperImpl customerMapper = new CustomerMapperImpl();
        Customer customer = new Customer();
        customer.setCustomerId(2L);
        customer.setFirstName("John");
        customer.setLastName("Doe");
        Address address = new Address();
        address.setStreet("street");
        address.setHouseNumber(42);
        address.setZipcode("zipcode");
        address.setCity("city");
        customer.setAddress(address);

        CustomerDto customerDto = customerMapper.transformToCustomerDto(customer);
        assertThat( customerDto ).isNotNull();
        assertThat(customerDto.id()).isEqualTo(customer.getCustomerId());
        assertThat(customerDto.firstName()).isEqualTo(customer.getFirstName());
        assertThat(customerDto.lastName()).isEqualTo(customer.getLastName());

        AddressDto addressDto = customerDto.address();
        assertThat(addressDto.street()).isEqualTo(address.getStreet());
        assertThat(addressDto.houseNumber()).isEqualTo(String.valueOf(address.getHouseNumber()));
        assertThat(addressDto.zipcode()).isEqualTo(address.getZipcode());
        assertThat(addressDto.city()).isEqualTo(address.getCity());
    }

    @Test
    void givenCustomerDto_whenMaps_thenCustomer() {
        CustomerMapperImpl customerMapper = new CustomerMapperImpl();
        AddressDto addressDto = new AddressDto("street", "42", "zipcode", "city");
        CustomerDto customerDto = new CustomerDto(2L, "John", "Doe", addressDto);

        Customer customer = customerMapper.transformToCustomer(customerDto);
        assertThat( customer ).isNotNull();
        assertThat(customer.getCustomerId()).isEqualTo(customerDto.id());
        assertThat(customer.getFirstName()).isEqualTo(customerDto.firstName());
        assertThat(customer.getLastName()).isEqualTo(customerDto.lastName());

        Address address = customer.getAddress();
        assertThat(address.getStreet()).isEqualTo(addressDto.street());
        assertThat(address.getHouseNumber()).isEqualTo(Integer.valueOf(addressDto.houseNumber()));
        assertThat(address.getZipcode()).isEqualTo(addressDto.zipcode());
        assertThat(address.getCity()).isEqualTo(addressDto.city());

    }

}

5. Mapping between API and DTO

5.1 Create Mapper

The API model looks a bit different than the CustomerDto because it has no Address object and number and postalCode have different names in the CustomerDto.

public class Customer {

  private String firstName;

  private String lastName;

  private String street;

  private String number;

  private String postalCode;

  private String city;
  // Getters and setters left out for brevity
}

In order to create a mapper, you need to add a bit more @Mapping annotations, just like you did before for the customer id.

@Mapper(componentModel = "spring")
public interface CustomerPortMapper {

    @Mapping(source = "street", target = "address.street")
    @Mapping(source = "number", target = "address.houseNumber")
    @Mapping(source = "postalCode", target = "address.zipcode")
    @Mapping(source = "city", target = "address.city")
    CustomerDto transformToCustomerDto(Customer customerApi);

    @Mapping(source = "id", target = "customerId")
    @Mapping(source = "address.street", target = "street")
    @Mapping(source = "address.houseNumber", target = "number")
    @Mapping(source = "address.zipcode", target = "postalCode")
    @Mapping(source = "address.city", target = "city")
    CustomerFullData transformToCustomerApi(CustomerDto customerDto);

}

Again, the generated CustomerPortMapperImpl class can be found in the target/generated-sources/annotations directory after invoking the Maven compile target.

5.2 Create Controller

The mapper is injected in the Controller and the corresponding mappers can easily be used.

@RestController
class CustomerController implements CustomerApi {

    private final CustomerPortMapper customerPortMapper;
    private final CustomerService customerService;

    CustomerController(CustomerPortMapper customerPortMapper, CustomerService customerService) {
        this.customerPortMapper = customerPortMapper;
        this.customerService = customerService;
    }

    @Override
    public ResponseEntity<CustomerFullData> createCustomer(Customer customerApi) {

        CustomerDto customerDtoIn = customerPortMapper.transformToCustomerDto(customerApi);
        CustomerDto customerDtoOut = customerService.createCustomer(customerDtoIn);

        return ResponseEntity.ok(customerPortMapper.transformToCustomerApi(customerDtoOut));
    }

    @Override
    public ResponseEntity<CustomerFullData> getCustomer(Long customerId) {
        CustomerDto customerDtoOut = customerService.getCustomer(customerId);
        return ResponseEntity.ok(customerPortMapper.transformToCustomerApi(customerDtoOut));
    }

}

5.3 Test Mapper

A unit test is created in a similar way as the one for the Service and can be viewed here.

In order to test the complete application, an integration test is created for creating a customer.

@SpringBootTest
@AutoConfigureMockMvc
class CustomerControllerIT {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void whenCreateCustomer_thenReturnOk() throws Exception {
        String body = """
                {
                  "firstName": "John",
                  "lastName": "Doe",
                  "street": "street",
                  "number": "42",
                  "postalCode": "1234",
                  "city": "city"
                }
                """;


        mockMvc.perform(post("/customer")
                        .contentType("application/json")
                        .content(body))
                .andExpect(status().isOk())
                .andExpect(jsonPath("firstName", equalTo("John")))
                .andExpect(jsonPath("lastName", equalTo("Doe")))
                .andExpect(jsonPath("customerId", equalTo(0)))
                .andExpect(jsonPath("street", equalTo("street")))
                .andExpect(jsonPath("number", equalTo("42")))
                .andExpect(jsonPath("postalCode", equalTo("1234")))
                .andExpect(jsonPath("city", equalTo("city")));

    }

}

6. Conclusion

MapStruct is an easy to use library for mapping between models. If the basic mapping is not sufficient, you are even able to create your own custom mapping logic (which is not demonstrated in this blog). It is advised to read the official documentation to get a comprehensive list of all available features.