ModelMapper

Introduction – problem solving

In modern software development, it is common practice to separate data representation for persistence (entities) from data representation for transfer (DTOs). This separation often necessitates conversion operations between these two forms. In most cases, there is a typical pattern for how these mappings should be performed. Mapping libraries like ModelMapper are designed to accommodate these standard patterns, simplifying and automating the mapping process, which reduces boilerplate code and potential errors.

Default example

In this example, we are mapping between an object of class Service and a DTO object ServiceDto.

Let’s check for some pattern which works with default configuration.

Service:

  • Contains a Client and an Address.
  • The Client has a Name object (first name, last name).
  • The Address has a city and a street.

ServiceDto:

  • A simpler representation of the Service object, containing only strings for the client’s first name, last name, delivery street, and delivery city.
UML diagram
a customized setter

Let’s arrange some init data and try to test conversion of Service to ServiceDto.

From this default example, we can make the following observations:

  • The mapping for the field firstName failed, as ModelMapper does not have enough information to build the correct method chain from the source to the target.
  • The mapping for clientLastName was successful due to the additional token client, which helps bind the source field to the target through a chain of methods.
  • The deliveryStreet field has enough information for mapping, but there is no setter for it. By default, ModelMapper will not set the value directly on the field, even if it is public. To change this, you need to enable field matching in the configuration.
  • The deliveryCity field was successfully mapped and converted to uppercase in the custom setter.

Flattening Objects by Default: ModelMapper can automatically flatten nested objects. In this example, the Client -> Name and Address classes, which are nested inside Service, are flattened, and their properties are mapped directly to ServiceDto.

ModelMapper performs mapping through methods by default, splitting method names into tokens and matching them according to a predefined strategy. This approach is often convenient and effective for standard use cases. However, this is just the basic behavior. ModelMapper provides a highly customizable and flexible workflow that allows for more complex mapping configurations when needed.

How it works

ModelMapper’s workflow can be divided into two main processes:

  1. Matching Process
  2. Mapping Process

Matching process

The matching process uses conventions configured in ModelMapper or a TypeMap to determine how properties correspond. This process consists of three key steps:

1. Identifying Eligible Properties

ModelMapper checks:

  • access level (e.g., public, private)
  • naming convention (e.g., camelCase, snake_case)

to determine if a field or method is eligible for mapping. This step decides whether a field or method can be mapped based on these rules.

2. Splitting Method Names into Tokens

  • Method names are broken into tokens for matching.
  • The NamingConvention transforms the property name (e.g., getFirstName becomes firstName).
  • NameTokenizer is used to split the transformed name into tokens (e.g., first and name), which are then compared to the target property.

3. Token Matching Strategy

  • ModelMapper uses a matching strategy to compare tokens between the source (getter methods) and the destination (setter methods).

Additional notes:

  • Methods priority: Methods take precedence over fields when their names match.
  • Fallback to Reflection: If no methods exist and the fields are accessible (based on access level), ModelMapper can use reflection to read from or write to fields directly.
  • Explicit Mappings: If custom mappings were configured, they are choosed by default
  • Ambiguity can occur if multiple source properties match a single target property. The matching engine attempts to resolve this by finding the closest match. If it fails, a ConfigurationException is thrown unless otherwise configured.

Mapping Process

Once the matching process is complete and ModelMapper knows which fields correspond to each other, the mapping process handles the actual transfer of values from the source object to the target object.

1. Create the Target Object

  • ModelMapper creates an instance of the target object, either using a default constructor or a custom provider if no default constructor is available.

2. Skip Fields

  • If certain fields should not be mapped, they are skipped early in the process.

3. Setting Values

  • Source values are mapped to target fields based on the matching results. This process can be customized through specific field mappings, custom converters, nested object handling, or by using TypeMaps and PropertyMaps.

The separation of workflows allows ModelMapper to be highly customizable, enabling developers to tailor the object mapping process to meet specific needs.

Main Components of ModelMapper API

ModelMapper

  • The core object that contains the primary logic and configuration for performing object mapping.
  • The map() method is used to execute the mapping between source and target objects based on the configured rules, TypeMaps, PropertyMaps, and other settings.
  • ModelMapper can be customized with options like naming conventions, access levels, and strategies to handle ambiguities in the mapping process.

TypeMap:

  • Represents the configuration for mapping between two specific types (source and target).
  • A TypeMap can contain one or more PropertyMap objects that define custom mappings for specific properties.
  • TypeMap objects are created and managed by ModelMapper and are used to execute the mapping according to the configuration.
  • TypeMaps are cached in the ModelMapper instance. Once you create and configure a TypeMap, ModelMapper will automatically reuse it for subsequent mappings between the same types, improving performance and ensuring consistency.

The example uses a TypeMap and configures it using ExpressionMap.

ExpressionMap allows for concise and convenient manual mapping by letting you define how source fields map to target fields through expressions (such as lambdas) that specify the logic of mapping. This gives you fine-grained control over the mapping process.

Unlike the default example, the firstName was manually bound and mapped successfully.

However, there is a limitation: the skip() method only works with setters. If a setter does not exist, you need to find an alternative approach, such as using a PostConverter.

A PostConverter is a mechanism that allows you to modify the target object after the main mapping process has occurred. It takes a Supplier as a parameter, making it convenient for adding custom logic or calling special methods on the target object. This is particularly useful for handling fields that do not have setters or for performing additional processing that cannot be handled during the regular mapping configuration.

PropertyMap

  • Defines custom mapping rules for converting properties between source and target objects.
  • PropertyMap allows you to explicitly override default mappings, specifying how certain fields in the source map to fields in the target.
  • PropertyMap are specific to the mapping between two types and must be registered within a TypeMap to take effect.
configure() only accept getter/setter, using(converter), when(condition), with(provider).

Note: Attempting to include more complex expressions directly within configure(), such as calling methods on fields (e.g., getCity().toUpperCase()), will result in a ConfigurationException. This is because PropertyMap is designed to work with property mappings based on methods or fields, not arbitrary code or method chains. To handle transformations like toUpperCase(), need to use a converter with the using() method.

The converter for the deliveryCity field works fine, but the setter used to set the value converts it to uppercase again.

Converter

  • Types of Converters: Converters can be applied to entire types (e.g., mapping from one class to another) or specific properties (e.g., mapping individual fields within a class), providing flexibility in how mapping logic is defined.
  • Custom Logic: Converters allow you to inject custom logic where necessary, enabling transformations that are beyond the standard field-to-field mapping.
  • Replacing or Complementing TypeMap: They can either replace the mapping defined by a TypeMap or complement it by handling specific fields or types that require specialized logic, such as converting case, formatting dates, or transforming data types.

Algorithm Description for the test:

  1. Setup Field Matching and Data:
    • The ModelMapper configuration is adjusted by enabling field matching with setFieldMatchingEnabled(true). This allows ModelMapper to access and map fields directly when getter/setter methods are not available.
    • The service object is created, representing the source data. For testing purposes, the firstName is set to null to simulate missing data that will be handled later in the mapping process.
  2. Create a Pre-Converter:
    • A pre-converter is set on the TypeMap to handle any necessary transformations before the actual mapping begins.
    • In this case, the pre-converter checks whether the firstName is null in the source object. If it is null, it assigns a default name "DefaultFirstName" to the firstName field in the destination object (ServiceDto).
  3. Create a Converter for Custom Field Mapping:
    • A custom converter (toLowerCaseLastName) is defined to convert the lastName of the Client object to lowercase. This demonstrates the ability to apply a transformation on a source field before mapping it to the target object.
    • Important Note: This converter is applied only for this particular conversion (Client to String) and is registered within the Service-to-ServiceDto mapping via the addMappings method. This ensures that the conversion applies specifically when mapping the Client object’s lastName to the ServiceDto‘s clientLastName field.
  4. Create a Post-Converter:
    • A post-converter is defined and applied after the main mapping process is completed.
    • In the post-converter, only the destination object is manipulated. The post-converter appends a specific string (" is in lowercase") to the already converted clientLastName field.This approach ensures that any changes already made to the destination object during the initial mapping (such as those from the custom converters or pre-converters) are preserved.

Provider

  • Defines a strategy for creating new instances of the target object, particularly useful when the default constructor is not accessible.
  • Providers are used when custom instantiation logic is needed, such as when working with immutable objects or when using builders to create objects.

Normally, ModelMapper uses reflection to create objects by invoking the default constructor. The provider change the game and define how the target instance will be created (in this case, ServiceDto).

The rest of the mapping process works as usual, with ModelMapper applying its strategy to fill the fields. If the target object is immutable, the situation can be handled using either a provider or a converter.

In the case of using a converter, the object is still initially created using the default constructor, but it is subsequently replaced with the result of the conversion. A provider is more appropriate when you need complete control over how the object is created, as it bypasses the need for a default constructor entirely. By using a provider, you can define the exact instantiation logic, making it especially useful for cases like immutable objects or when employing a builder pattern.

Condition

Condition is essentially a functional interface. It takes a MappingContext as input and returns a boolean value that determines whether the mapping should proceed.

  • Specifies when a mapping should occur based on custom criteria or logic.
  • Conditions allow you to selectively map fields, ensuring that properties are mapped only when certain conditions are met (e.g., mapping only non-null values or fields that meet specific business rules).
This test demonstrates how to use conditions to control which fields get mapped based on specific criteria.

What is the ExpressionMap?

ExpressionMap<S, D> is a functional interface provided ModelMapper that allows you to define mappings between source (S) and destination (D) objects using lambda expressions. This is an alternative to the more verbose PropertyMap<S, D>, Instead of subclassing abstract PropertyMap<S, D> and implementing a lengthy mapping configuration, ExpressionMap allows for concise mapping rules.

How to Use ExpressionMap with ModelMapper:

  1. Basic Setup: First, you need to create an instance of ModelMapper and use the addMappings method to define an ExpressionMap.
  2. Define Custom Mapping: The lambda expression in addMappings( takes a ConfigurableConditionExpression (mapper) as its argument, allowing you to specify how properties from the source object should map to the destination object.
ModelMapper modelMapper = new ModelMapper();

TypeMap<SourceClass, DestinationClass> typeMap = modelMapper.createTypeMap(SourceClass.class, DestinationClass.class);

typeMap.addMappings(mapper -> {
    mapper.map(SourceClass::getName, DestinationClass::setName);
    mapper.using(converter).map(SourceClass::getAge, DestinationClass::setFormattedAge);
    mapper.skip(DestinationClass::setInternalId);
});

Note: This approach has some limitations, as it only works with getter and setter methods.

The ConfigurableConditionExpression is a low-level interface within the ModelMapper framework’s hierarchy. Through this interface, the mapper instance gains access to several useful methods, allowing for precise and customizable configuration of property mappings.


Advices for Using ModelMapper Effectively

  1. Use Validation for Mapping Configuration
    The validate() method helps verify that all expected fields are properly mapped. This ensures that all necessary mappings are defined and prevents issues where fields might be missed, especially in complex object structures.
   modelMapper.validate();
  1. Limit Deep Mapping
    Avoid deep mappings unless explicitly necessary. Deep mapping can lead to performance bottlenecks and incorrect results when not carefully configured. You can disable deep mappings using the following configuration and instead define specific nested mappings:
   modelMapper.getConfiguration().setDeepMapping(false);
  1. Control Field Access with Matching Strategies
    Use matching strategies to control how fields are matched between the source and destination objects. ModelMapper provides two matching strategies:
  • STRICT: Enforces precise matching for field names and types, reducing the risk of unintended mappings.
  • LOOSE: Allows more flexibility in matching, which can be useful for simpler use cases.
modelMapper.getConfiguration()
        .setMatchingStrategy(MatchingStrategies.STRICT);
  1. Field vs Method Mapping
    If you need to map fields directly (for example, in cases where there are no getter or setter methods available), you can enable field mapping by adjusting the configuration. This allows ModelMapper to directly access and map fields instead of relying on method-based mapping.
modelMapper.getConfiguration()
        .setFieldMatchingEnabled(true);
  1. Debug with ModelMapper’s Inspection Tools
    ModelMapper provides an inspection method called getTypeMap() that lets you view the mappings between objects. This is extremely useful for debugging, as it allows you to examine how fields are being mapped and ensure that everything is configured correctly.
  System.out.println(typeMap.getMappings());
  1. Issues with Final Fields
    ModelMapper cannot directly map final fields using setters, as they cannot be modified once initialized. In such cases, both Provider and Converter can be used for constructor-based mappings. However, the Provider is preferable since it avoids creating an unnecessary instance using the default constructor, which would otherwise be discarded when using a converter.
typeMap.setProvider(request -> new DestinationClass(
    request.getSource().getFinalField1(),
    request.getSource().getFinalField2()
));
  1. Multiple instances
    In most cases, it’s a best practice to use a single instance of ModelMapper and configure it once, applying any custom mappings, converters, or settings.
  2. ModelMapper implicitly performs bidirectional mapping, meaning it automatically creates mappings in both directions (source to destination and destination to source). This can lead to unintended behavior, especially when working with certain types like records or immutable objects.
  3. Records
    Working with records in ModelMapper presents two major issues, making it impractical to use ModelMapper with records.

    1. Instance Creation: Due to the bidirectional mapping behavior and the immutable nature of records, ModelMapper cannot automatically instantiate records. As a result, manual mapping must be provided for both directions (from record to entity and vice versa).

    2. Field Access: ModelMapper struggles with records because their accessor methods don’t follow the standard JavaBean convention (e.g., name() instead of getName()). Furthermore, records have final fields that cannot be modified via reflection, which makes it impossible for ModelMapper to map fields properly.

    Solution: To effectively work with records, you need to write custom mapping methods for both directions, ensuring the correct mapping of fields without relying on ModelMapper’s default behavior.

Conclusion

ModelMapper is a convenient tool that provides a flexible mapping solution. However, my experience with it was less than ideal due to my choice of using records and immutable objects, which required manual mapping most of the time. In such cases, it feels like there’s no mapper at all. That said, for projects without immutable objects or the need for conversion, ModelMapper works quite well.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top