In Java development, especially within layered architectures, translating data between different object models (like database Entities and API DTOs) is a frequent task. Doing this manually involves writing repetitive, error-prone boilerplate code (getters/setters). Java Object Mapping frameworks automate this process, saving time, reducing errors, and improving code maintainability.
Lets five popular mapping frameworks: MapStruct, ModelMapper, Orika, Dozer, and JMapper. We’ll examine how each works, its pros and cons, and demonstrate its usage through practical examples verified with JUnit 5 tests.
Why Use Mapping Frameworks?
- Reduce Boilerplate: Drastically cuts down manual
get/set
code. - Improve Maintainability: Centralized mapping logic is easier to update.
- Enhance Readability: Declarative mapping definitions are often clearer.
- Type Safety & Error Reduction: Compile-time frameworks catch errors early; runtime frameworks offer robust handling.
- Convention over Configuration: Many frameworks smartly map matching fields automatically.
Common Scenario: Entities and DTOs
For our examples, we’ll use the following common scenario: mapping between Entity
objects (representing data layer/business domain) and DTO
objects (representing data transfer/API layer).
Shared Domain/Entity Objects:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import java.time.LocalDate; import java.util.List; // Assume Lombok annotations (@Data, @AllArgsConstructor, @NoArgsConstructor) or manual getters/setters for brevity //@Data @NoArgsConstructor @AllArgsConstructor public class User { private Long id; private String firstName; private String lastName; private String email; private LocalDate registrationDate; private Address primaryAddress; private List<Order> orders; // Getters & Setters... } //@Data @NoArgsConstructor @AllArgsConstructor public class Address { private String street; private String city; private String postalCode; // Getters & Setters... } //@Data @NoArgsConstructor @AllArgsConstructor public class Order { private String orderId; private double totalAmount; private LocalDate orderDate; // Getters & Setters... } |
Shared Data Transfer Objects (DTOs):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import java.util.List; // Assume Lombok annotations (@Data, @AllArgsConstructor, @NoArgsConstructor) or manual getters/setters for brevity //@Data @NoArgsConstructor @AllArgsConstructor public class UserDTO { private String userId; // Mapped from Long id private String fullName; // Combined from firstName, lastName private String contactEmail; // Mapped from email private String city; // Flattened from primaryAddress.city private List<OrderSummaryDTO> recentOrders; // Mapped from List<Order> // Getters & Setters... } //@Data @NoArgsConstructor @AllArgsConstructor public class OrderSummaryDTO { private String id; // Mapped from orderId private double amount; // Mapped from totalAmount // Getters & Setters... } |
Goal: Map a complex User
object to a UserDTO
, handling field renaming, type conversion, nested objects, field combination, and collection mapping.
Framework Deep Dives with JUnit 5 Tests
We will use JUnit 5 for testing. Ensure you have the dependency:
Maven (JUnit 5):
1 2 3 4 5 6 7 8 9 10 11 |
<dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> |
Let’s create a common test data setup method that we can reuse.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// In your test classes, you can have a helper method like this: import java.time.LocalDate; import java.util.Arrays; public class TestDataFactory { public static User createSampleUser() { Address addr = new Address(); addr.setStreet("123 Main St"); addr.setCity("Anytown"); addr.setPostalCode("12345"); Order order1 = new Order(); order1.setOrderId("ORD-001"); order1.setTotalAmount(99.99); order1.setOrderDate(LocalDate.now().minusDays(10)); Order order2 = new Order(); order2.setOrderId("ORD-002"); order2.setTotalAmount(50.00); order2.setOrderDate(LocalDate.now().minusMonths(1)); User user = new User(); user.setId(1L); user.setFirstName("John"); user.setLastName("Doe"); user.setEmail("john.doe@example.com"); user.setRegistrationDate(LocalDate.now().minusYears(1)); user.setPrimaryAddress(addr); user.setOrders(Arrays.asList(order1, order2)); return user; } } |
1. MapStruct
- Mechanism: Compile-time code generation via annotation processing.
- Pros: Excellent performance, compile-time type safety, refactoring friendly, integrates well with CDI/Spring.
- Cons: Requires build tool configuration for annotation processing, mapping logic defined in interfaces using annotations.
Maven Dependencies:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<properties> <org.mapstruct.version>1.5.5.Final</org.mapstruct.version> </properties> <dependencies> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>${org.mapstruct.version}</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>11</source> <target>11</target> <annotationProcessorPaths> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>${org.mapstruct.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build> |
Mapper Interface:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
// src/main/java/com/example/mapping/MapStructUserMapper.java package com.example.mapping; // Adjust package name import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Mappings; import org.mapstruct.factory.Mappers; import com.example.domain.*; // Import your domain/dto classes import com.example.dto.*; import java.util.List; @Mapper public interface MapStructUserMapper { MapStructUserMapper INSTANCE = Mappers.getMapper(MapStructUserMapper.class); @Mappings({ @Mapping(source = "id", target = "userId"), // Handles Long -> String @Mapping(target = "fullName", expression = "java(user.getFirstName() + \" \" + user.getLastName())"), @Mapping(source = "email", target = "contactEmail"), @Mapping(source = "primaryAddress.city", target = "city", defaultValue = "N/A"), // Nested + default @Mapping(source = "orders", target = "recentOrders") // Maps the list }) UserDTO userToUserDTO(User user); // MapStruct uses this method automatically for list elements @Mappings({ @Mapping(source = "orderId", target = "id"), @Mapping(source = "totalAmount", target = "amount") }) OrderSummaryDTO orderToOrderSummaryDTO(Order order); // Optional: Define mapping for the List explicitly if needed List<OrderSummaryDTO> ordersToOrderSummaryDTOs(List<Order> orders); } |
JUnit 5 Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
// src/test/java/com/example/mapping/MapStructUserMapperTest.java package com.example.mapping; // Adjust package name import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; import com.example.domain.*; // Import your domain/dto classes import com.example.dto.*; import java.util.List; class MapStructUserMapperTest { private final MapStructUserMapper mapper = MapStructUserMapper.INSTANCE; @Test void shouldMapUserToUserDTO() { // Arrange User user = TestDataFactory.createSampleUser(); // Act UserDTO dto = mapper.userToUserDTO(user); // Assert assertNotNull(dto); assertEquals("1", dto.getUserId()); // Check Long -> String conversion assertEquals("John Doe", dto.getFullName()); // Check combined field assertEquals(user.getEmail(), dto.getContactEmail()); // Check renamed field assertEquals("Anytown", dto.getCity()); // Check nested field mapping assertNotNull(dto.getRecentOrders()); assertEquals(2, dto.getRecentOrders().size()); // Check list mapping size OrderSummaryDTO orderDto1 = dto.getRecentOrders().stream() .filter(o -> "ORD-001".equals(o.getId())).findFirst().orElse(null); assertNotNull(orderDto1); assertEquals(99.99, orderDto1.getAmount()); OrderSummaryDTO orderDto2 = dto.getRecentOrders().stream() .filter(o -> "ORD-002".equals(o.getId())).findFirst().orElse(null); assertNotNull(orderDto2); assertEquals(50.00, orderDto2.getAmount()); } @Test void shouldHandleNullAddressGracefully() { // Arrange User user = TestDataFactory.createSampleUser(); user.setPrimaryAddress(null); // Set nested object to null // Act UserDTO dto = mapper.userToUserDTO(user); // Assert assertNotNull(dto); assertEquals("N/A", dto.getCity()); // Check default value applied } } |
2. ModelMapper
- Mechanism: Runtime mapping using reflection and conventions.
- Pros: Very concise for simple mappings, flexible runtime configuration, easy setup.
- Cons: Performance overhead due to reflection, errors caught at runtime, refactoring can silently break mappings.
Maven Dependency:
1 2 3 4 |
<dependency> <groupId>org.modelmapper</groupId> <artifactId>modelmapper</artifactId> <version>3.2.0</version> </dependency> |
Configuration (often done where mapping occurs or in a config class):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
// No separate interface needed, configuration happens on the ModelMapper instance // Example setup within the test or a configuration class import org.modelmapper.ModelMapper; import org.modelmapper.PropertyMap; import org.modelmapper.Converter; import org.modelmapper.convention.MatchingStrategies; import com.example.domain.*; import com.example.dto.*; public class ModelMapperConfig { public static ModelMapper createModelMapper() { ModelMapper modelMapper = new ModelMapper(); // Use LOOSE strategy for more lenient matching (e.g., nested properties) modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.LOOSE); // --- Define explicit mappings for complex cases --- // Converter for Full Name Converter<User, String> fullNameConverter = ctx -> ctx.getSource() == null ? null : ctx.getSource().getFirstName() + " " + ctx.getSource().getLastName(); // TypeMap for User -> UserDTO specific rules PropertyMap<User, UserDTO> userToDTOMap = new PropertyMap<User, UserDTO>() { protected void configure() { // Explicit mapping for Long -> String map().setUserId(String.valueOf(source.getId())); // Explicit mapping for renamed field (if strategy doesn't catch it) map(source.getEmail()).setContactEmail(null); // Target is 'null' placeholder // Explicit mapping for nested field (if strategy doesn't catch it) map(source.getPrimaryAddress().getCity()).setCity(null); // Use the converter for fullName using(fullNameConverter).map(source).setFullName(null); // List mapping often handled by convention if Order->OrderSummaryDTO mapping is known // map(source.getOrders()).setRecentOrders(null); } }; // TypeMap for Order -> OrderSummaryDTO needed for list mapping PropertyMap<Order, OrderSummaryDTO> orderToSummaryMap = new PropertyMap<Order, OrderSummaryDTO>() { protected void configure() { map().setId(source.getOrderId()); map().setAmount(source.getTotalAmount()); } }; modelMapper.addMappings(userToDTOMap); modelMapper.addMappings(orderToSummaryMap); return modelMapper; } } |
JUnit 5 Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
// src/test/java/com/example/mapping/ModelMapperTest.java package com.example.mapping; // Adjust package name import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.modelmapper.ModelMapper; import static org.junit.jupiter.api.Assertions.*; import com.example.domain.*; // Import your domain/dto classes import com.example.dto.*; class ModelMapperTest { private ModelMapper modelMapper; @BeforeEach void setUp() { modelMapper = ModelMapperConfig.createModelMapper(); } @Test void shouldMapUserToUserDTO() { // Arrange User user = TestDataFactory.createSampleUser(); // Act UserDTO dto = modelMapper.map(user, UserDTO.class); // Assert (Assertions are identical to MapStruct test) assertNotNull(dto); assertEquals("1", dto.getUserId()); assertEquals("John Doe", dto.getFullName()); assertEquals(user.getEmail(), dto.getContactEmail()); assertEquals("Anytown", dto.getCity()); assertNotNull(dto.getRecentOrders()); assertEquals(2, dto.getRecentOrders().size()); OrderSummaryDTO orderDto1 = dto.getRecentOrders().stream() .filter(o -> "ORD-001".equals(o.getId())).findFirst().orElse(null); assertNotNull(orderDto1); assertEquals(99.99, orderDto1.getAmount()); // ... assert order 2 ... } @Test void shouldHandleNullAddressGracefully() { // Arrange User user = TestDataFactory.createSampleUser(); user.setPrimaryAddress(null); // Act UserDTO dto = modelMapper.map(user, UserDTO.class); // Assert assertNotNull(dto); assertNull(dto.getCity()); // Default ModelMapper behaviour for null nested source is null target // Note: If you need a default value like in MapStruct, you'd add a condition // or a post-processing step in the TypeMap/Converter. } } |
3. Orika
- Mechanism: Runtime mapping using bytecode generation (faster than reflection once initialized).
- Pros: High performance (after initial setup), powerful configuration API, handles complex scenarios well (inheritance, generics).
- Cons: Can have a steeper learning curve, runtime errors, setup slightly more involved than ModelMapper.
Maven Dependency:
1 2 3 4 |
<dependency> <groupId>ma.glasnost.orika</groupId> <artifactId>orika-core</artifactId> <version>1.5.4</version> </dependency> |
Configuration (typically in a dedicated class or setup method):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
// src/main/java/com/example/mapping/OrikaConfig.java package com.example.mapping; // Adjust package name import ma.glasnost.orika.MapperFactory; import ma.glasnost.orika.impl.DefaultMapperFactory; import ma.glasnost.orika.converter.ConverterFactory; import ma.glasnost.orika.CustomConverter; import ma.glasnost.orika.metadata.Type; import com.example.domain.*; import com.example.dto.*; public class OrikaConfig { public static MapperFactory createMapperFactory() { MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build(); // Define mapping for User -> UserDTO mapperFactory.classMap(User.class, UserDTO.class) .field("id", "userId") // Handles Long -> String automatically .field("email", "contactEmail") // Simple rename .field("primaryAddress.city", "city") // Nested mapping .field("orders", "recentOrders") // Collection mapping .byDefault() // Map remaining fields with same names if any .customize( new ma.glasnost.orika.CustomMapper<User, UserDTO>() { @Override public void mapAtoB(User user, UserDTO userDTO, ma.glasnost.orika.MappingContext context) { // Custom logic for fullName userDTO.setFullName(user.getFirstName() + " " + user.getLastName()); } } ) .register(); // Don't forget to register the mapping! // Define mapping for Order -> OrderSummaryDTO for the collection mapperFactory.classMap(Order.class, OrderSummaryDTO.class) .field("orderId", "id") .field("totalAmount", "amount") .byDefault() .register(); // Optional: Add custom converter if automatic type conversion isn't sufficient // ConverterFactory converterFactory = mapperFactory.getConverterFactory(); // converterFactory.registerConverter("longToString", new CustomConverter<Long, String>() { ... }); return mapperFactory; } } |
JUnit 5 Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
// src/test/java/com/example/mapping/OrikaMapperTest.java package com.example.mapping; // Adjust package name import ma.glasnost.orika.MapperFacade; import ma.glasnost.orika.MapperFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; import com.example.domain.*; import com.example.dto.*; class OrikaMapperTest { private MapperFacade mapper; @BeforeEach void setUp() { MapperFactory mapperFactory = OrikaConfig.createMapperFactory(); mapper = mapperFactory.getMapperFacade(); } @Test void shouldMapUserToUserDTO() { // Arrange User user = TestDataFactory.createSampleUser(); // Act UserDTO dto = mapper.map(user, UserDTO.class); // Assert (Assertions are identical to MapStruct test) assertNotNull(dto); assertEquals("1", dto.getUserId()); assertEquals("John Doe", dto.getFullName()); assertEquals(user.getEmail(), dto.getContactEmail()); assertEquals("Anytown", dto.getCity()); assertNotNull(dto.getRecentOrders()); assertEquals(2, dto.getRecentOrders().size()); OrderSummaryDTO orderDto1 = dto.getRecentOrders().stream() .filter(o -> "ORD-001".equals(o.getId())).findFirst().orElse(null); assertNotNull(orderDto1); assertEquals(99.99, orderDto1.getAmount()); // ... assert order 2 ... } @Test void shouldHandleNullAddressGracefully() { // Arrange User user = TestDataFactory.createSampleUser(); user.setPrimaryAddress(null); // Act UserDTO dto = mapper.map(user, UserDTO.class); // Assert assertNotNull(dto); assertNull(dto.getCity()); // Orika default handles null nested source -> null target // Add .mapNulls(false) to field mapping if you want to skip mapping nulls explicitly } } |
4. Dozer
- Mechanism: Runtime mapping, primarily using reflection (can also use bytecode generation). Uses XML or Java API for configuration.
- Pros: Mature framework, flexible configuration (XML & Java API), extensive features.
- Cons: Slower performance (reflection-heavy by default), less actively maintained than others, XML configuration can be verbose, runtime errors. Note: Dozer’s development activity has slowed considerably.
Maven Dependency:
1 2 3 4 |
<dependency> <groupId>com.github.dozermapper</groupId> <artifactId>dozer-core</artifactId> <version>6.5.2</version> </dependency> |
Configuration (Java API Example):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
// src/main/java/com/example/mapping/DozerConfig.java package com.example.mapping; // Adjust package name import com.github.dozermapper.core.DozerBeanMapperBuilder; import com.github.dozermapper.core.Mapper; import com.github.dozermapper.core.loader.api.BeanMappingBuilder; import com.github.dozermapper.core.loader.api.TypeMappingOptions; import com.example.domain.*; import com.example.dto.*; public class DozerConfig { public static Mapper createMapper() { BeanMappingBuilder builder = new BeanMappingBuilder() { @Override protected void configure() { // Mapping for User -> UserDTO mapping(User.class, UserDTO.class, TypeMappingOptions.mapNull(true), TypeMappingOptions.mapEmptyString(true)) .fields("id", "userId") // Basic type conversion often handled .fields("email", "contactEmail") .fields("primaryAddress.city", "city") // Nested mapping .fields("orders", "recentOrders") // Custom logic requires a CustomConverter .fields(field("firstName").accessible(true), field("lastName").accessible(true)) // Need explicit access if private .customConverter(FullNameConverter.class); // See converter below // Mapping for Order -> OrderSummaryDTO mapping(Order.class, OrderSummaryDTO.class) .fields("orderId", "id") .fields("totalAmount", "amount"); } }; return DozerBeanMapperBuilder.create() .withMappingBuilder(builder) // Add XML mapping files if used: .withMappingFiles("dozer-mapping.xml") .build(); } // Example Custom Converter for Dozer public static class FullNameConverter implements com.github.dozermapper.core.CustomConverter { @Override public Object convert(Object destination, Object source, Class<?> destClass, Class<?> sourceClass) { if (source == null) { return null; } if (source instanceof User) { User user = (User) source; // Assuming destination field is String (fullName in UserDTO) return user.getFirstName() + " " + user.getLastName(); } // Handle other potential source types or return null/throw exception return null; } } } |
JUnit 5 Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
// src/test/java/com/example/mapping/DozerMapperTest.java package com.example.mapping; // Adjust package name import com.github.dozermapper.core.Mapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; import com.example.domain.*; import com.example.dto.*; class DozerMapperTest { private Mapper mapper; @BeforeEach void setUp() { mapper = DozerConfig.createMapper(); } @Test void shouldMapUserToUserDTO() { // Arrange User user = TestDataFactory.createSampleUser(); // Act UserDTO dto = mapper.map(user, UserDTO.class); // Assert (Assertions are identical to MapStruct test) assertNotNull(dto); assertEquals("1", dto.getUserId()); // Dozer often handles basic type conversion assertEquals("John Doe", dto.getFullName()); assertEquals(user.getEmail(), dto.getContactEmail()); assertEquals("Anytown", dto.getCity()); assertNotNull(dto.getRecentOrders()); assertEquals(2, dto.getRecentOrders().size()); OrderSummaryDTO orderDto1 = dto.getRecentOrders().stream() .filter(o -> "ORD-001".equals(o.getId())).findFirst().orElse(null); assertNotNull(orderDto1); assertEquals(99.99, orderDto1.getAmount()); // ... assert order 2 ... } @Test void shouldHandleNullAddressGracefully() { // Arrange User user = TestDataFactory.createSampleUser(); user.setPrimaryAddress(null); // Act UserDTO dto = mapper.map(user, UserDTO.class); // Assert assertNotNull(dto); // By default (with TypeMappingOptions.mapNull(true)), Dozer maps null source to null target assertNull(dto.getCity()); } } |
5. JMapper
- Mechanism: Primarily runtime mapping, but uses bytecode generation at runtime upon first mapping request (can also be configured for compile-time generation). Configuration via Annotations or API or XML.
- Pros: Good performance (after initial generation), flexible configuration options (Annotations, API, XML).
- Cons: Less commonly used/smaller community than MapStruct/ModelMapper, documentation can be less extensive, runtime errors possible.
Maven Dependency:
1 2 3 4 |
<dependency> <groupId>com.googlecode.jmapper-framework</groupId> <artifactId>jmapper-core</artifactId> <version>1.6.1.CR2</version> </dependency> |
Configuration (Annotation Example on DTO):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
// src/main/java/com/example/dto/UserDTO.java package com.example.dto; // Adjust package name import com.googlecode.jmapper.annotations.JMap; import com.googlecode.jmapper.annotations.JMapConversion; import com.googlecode.jmapper.annotations.JMapper; // For the DTO class itself import com.googlecode.jmapper.enums.ConversionType; import com.example.domain.User; // Need source class import import java.util.List; @JMapper(value = User.class) // Specify the source class for mapping TO this DTO public class UserDTO { @JMap(attributes = {"id"}) // Maps User.id to userId (assumes basic conversion) private String userId; // Custom conversion needed for combining fields // Note: JMapper's direct annotation support for complex expressions like this is limited. // Often requires API configuration or a custom method called via @JMapConversion. private String fullName; // We'll handle this via API config or post-processing in the test @JMap(attributes = {"email"}) // Map User.email to contactEmail private String contactEmail; @JMap(attributes = {"primaryAddress.city"}) // Nested mapping private String city; @JMap(attributes = {"orders"}) // Map collection private List<OrderSummaryDTO> recentOrders; // Standard Getters & Setters... // Example: Define a conversion method IF using @JMapConversion // @JMapConversion(from = {"firstName", "lastName"}, to = {"fullName"}, type = ConversionType.DYNAMIC) // public String convertFullName(String fName, String lName) { // return fName + " " + lName; // } } // You also need to define mapping for OrderSummaryDTO if using annotations entirely // Or configure it using the API. Let's use API for OrderSummaryDTO. |
Configuration (API Example for more complex cases / falling back from annotations):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
// src/main/java/com/example/mapping/JMapperConfig.java package com.example.mapping; import com.googlecode.jmapper.JMapper; import com.googlecode.jmapper.RelationalJMapper; import com.googlecode.jmapper.api.JMapperAPI; import com.googlecode.jmapper.api.enums.MappingType; import com.googlecode.jmapper.api.enums.NullPointerControl; import static com.googlecode.jmapper.api.JMapperAPI.*; import com.example.domain.*; import com.example.dto.*; public class JMapperConfig { // Mapper for User -> UserDTO (using API to handle fullName) public static JMapper<UserDTO, User> createUserMapper() { JMapperAPI api = new JMapperAPI() .add(mappedClass(UserDTO.class) .add(global() // Define global mapping for User.class source .source(User.class) .nullPointerControl(NullPointerControl.SOURCE // Handle null source User object )) .add(attribute("userId").value("id")) // Map id -> userId .add(attribute("contactEmail").value("email")) .add(attribute("city").value("primaryAddress.city") .nullPointerControl(NullPointerControl.ALL)) // Handle null address .add(attribute("recentOrders").value("orders") .mappingType(MappingType.ALL_FIELDS)) // Map collections // fullName requires custom logic not easily done via basic API attributes // It's often handled post-mapping or via more complex JMapper features/extensions ); return new JMapper<>(UserDTO.class, User.class, api); } // Relational Mapper often used for collections: Order -> OrderSummaryDTO public static RelationalJMapper<OrderSummaryDTO> createOrderSummaryMapper() { JMapperAPI orderApi = new JMapperAPI() .add(mappedClass(OrderSummaryDTO.class) .add(global().source(Order.class)) .add(attribute("id").value("orderId")) .add(attribute("amount").value("totalAmount")) ); // RelationalJMapper needs a "key" to link items if used in complex relations, // but for simple list mapping, we might just use a regular JMapper inside a loop or stream. // For simplicity here, let's assume we create a standard JMapper for the element type. // return new RelationalJMapper<>(OrderSummaryDTO.class, orderApi); // If needed for relational return new JMapper<>(OrderSummaryDTO.class, Order.class, orderApi); // Standard mapper for elements } // Helper method to manually handle full name after mapping public static void setFullName(User source, UserDTO destination) { if (source != null && destination != null) { destination.setFullName(source.getFirstName() + " " + source.getLastName()); } } // Helper method to manually map list using element mapper public static void mapOrdersList(User source, UserDTO destination, JMapper<OrderSummaryDTO, Order> orderMapper) { if (source != null && source.getOrders() != null && destination != null && orderMapper != null) { java.util.List<OrderSummaryDTO> dtoList = new java.util.ArrayList<>(); for (Order order : source.getOrders()) { dtoList.add(orderMapper.getDestination(order)); } destination.setRecentOrders(dtoList); } else if (destination != null) { destination.setRecentOrders(java.util.Collections.emptyList()); } } } |
JUnit 5 Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
// src/test/java/com/example/mapping/JMapperTest.java package com.example.mapping; import com.googlecode.jmapper.JMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; import com.example.domain.*; import com.example.dto.*; import java.util.List; class JMapperTest { private JMapper<UserDTO, User> userMapper; private JMapper<OrderSummaryDTO, Order> orderSummaryMapper; // Mapper for list elements @BeforeEach void setUp() { userMapper = JMapperConfig.createUserMapper(); orderSummaryMapper = JMapperConfig.createOrderSummaryMapper(); // Get element mapper } @Test void shouldMapUserToUserDTO() { // Arrange User user = TestDataFactory.createSampleUser(); // Act // JMapper API mapping (handles fields defined in API) UserDTO dto = userMapper.getDestination(user); // Manual post-processing for fields not easily mapped via API/Annotations JMapperConfig.setFullName(user, dto); JMapperConfig.mapOrdersList(user, dto, orderSummaryMapper); // Map the list manually // Assert (Assertions are identical to MapStruct test) assertNotNull(dto); assertEquals("1", dto.getUserId()); assertEquals("John Doe", dto.getFullName()); // Check manually mapped field assertEquals(user.getEmail(), dto.getContactEmail()); assertEquals("Anytown", dto.getCity()); assertNotNull(dto.getRecentOrders()); assertEquals(2, dto.getRecentOrders().size()); // Check manually mapped list OrderSummaryDTO orderDto1 = dto.getRecentOrders().stream() .filter(o -> "ORD-001".equals(o.getId())).findFirst().orElse(null); assertNotNull(orderDto1); assertEquals(99.99, orderDto1.getAmount()); // ... assert order 2 ... } @Test void shouldHandleNullAddressGracefully() { // Arrange User user = TestDataFactory.createSampleUser(); user.setPrimaryAddress(null); // Set nested object to null // Act UserDTO dto = userMapper.getDestination(user); // JMapper with NullPointerControl handles this JMapperConfig.setFullName(user, dto); // Still need manual steps JMapperConfig.mapOrdersList(user, dto, orderSummaryMapper); // Assert assertNotNull(dto); // NullPointerControl.ALL on the nested mapping ensures city is null assertNull(dto.getCity()); } } |
Note on JMapper Example: JMapper’s annotation approach is powerful for direct mappings but less flexible for complex transformations like combining fields directly within annotations. The API provides more control but can become verbose. Often, a mix or post-processing steps (like the setFullName
helper) might be used for complex cases. Mapping collections often involves getting a mapper for the element type and iterating, although RelationalJMapper
offers more advanced features for specific relational scenarios.
Comparison and Choosing the Right Framework
Feature | MapStruct | ModelMapper | Orika | Dozer | JMapper |
---|---|---|---|---|---|
Mechanism | Compile-time code gen | Runtime reflection | Runtime bytecode gen | Runtime reflection (opt. bc gen) | Runtime bytecode gen (opt. CT) |
Performance | Excellent | Moderate (reflection overhead) | Very Good (post-init) | Slower (reflection) | Very Good (post-init) |
Type Safety | Compile-time | Runtime | Runtime | Runtime | Runtime (opt. Compile-time) |
Refactoring | High (compiler checks) | Low (silent breaks possible) | Moderate | Low | Moderate |
Config Style | Annotations (Interface) | API (fluent), Conventions | API (fluent) | API, XML | Annotations, API, XML |
Maintenance | Actively Maintained | Actively Maintained | Actively Maintained | Low Activity | Moderate Activity |
Ease of Use | Moderate (setup, annotations) | Easy (for simple cases) | Moderate/High (powerful API) | Moderate (API/XML) | Moderate (API/Annotations) |
Community | Large & Growing | Large | Moderate | Large (historical) | Smaller |
Guidelines:
- Performance & Type Safety Paramount: Choose MapStruct. It’s the top performer and catches errors early. Ideal for large projects and teams valuing robustness.
- Rapid Development & Simplicity: ModelMapper offers a very quick start due to its convention-over-configuration approach. Great for smaller projects or prototypes, but be wary of runtime errors and performance in hot paths.
- Runtime Performance & Flexibility: Orika provides a good balance. It’s fast after initialization and has a powerful API for complex scenarios. Steeper learning curve than ModelMapper.
- Existing Dozer Project / XML Config Preference: Dozer might be considered if already used or if XML configuration is strongly preferred, but be aware of its performance and lower maintenance activity. Evaluate migration potential.
- Alternative with Bytecode Gen / Annotation Focus: JMapper is an alternative offering good performance and flexible configuration (including annotations). Its community is smaller, which might impact support and resources.
Conclusion
Java Object Mapping frameworks are essential tools for modern Java development, saving significant effort and reducing errors.
- MapStruct stands out for its compile-time safety and performance.
- ModelMapper excels in simplicity and convention-based mapping.
- Orika offers high runtime performance and a powerful API.
- Dozer is a mature but slower, less active option, often found in legacy systems.
- JMapper provides another performant alternative with flexible configuration but a smaller community footprint.
By understanding their different approaches, configuration styles, and trade-offs—and seeing them in action with unit tests—you can confidently select the best mapping framework for your specific Java project needs. Remember to consult the official documentation for the most up-to-date features and advanced configurations of each framework.