Mapping an entity hierarchy using Mapstruct and the Visitor Pattern
By Tasos Papadopoulos
- 4 minutes read - 848 wordsIntroduction
This post is based on my answer to a question on Stack Overflow. The original poster had a seemingly simple problem but the original approach didn’t solve the issue.
Let’s dive into the problem and its constraints.
This problem seems to be simple
Let’s assume that we have a simple Java entity hierarchy representing Products. There is a base entity Product
and subclasses like Book
and Furniture
1.
class Product {
private long id;
private String productName;
}
class Book extends Product {
private String author;
}
class Furniture extends Product {
private String color;
}
Now, also assume that we have a List<Product> products
populated by our repository and we want to map these entities to a list of Data Transfer Objects1:
class ProductDto {
private long id;
private String productName;
}
class BookDto extends ProductDto {
private String author;
}
class FurnitureDto extends ProductDto {
String color;
}
My favorite mapping library, Mapstruct, can easily handle such tasks. No boilerplate code is needed, just a few interfaces1 and Java beans are mapped:
import org.mapstruct.Mapper;
@Mapper(uses = {BookMapper.class,FurnitureMapper.class})
public interface ProductMapper {
ProductDto productToProductDto(Product product);
}
@Mapper
public interface BookMapper {
BookDto bookToBookDto(Book book);
}
@Mapper
public interface FurnitureMapper {
FurnitureDto furnitureToFurnitureDto(Furniture furniture);
}
Having entities, DTOs, and mappers all set up, this is the most straightforward approach for our service:
public List<ProductDto> getAllProducts() {
List<ProductDto> listOfProducts = new ArrayList<>();
productRepository
.findAll()
.forEach(productEntity ->
listOfProducts.add(productMapper.productToProductDto(productEntity))
);
return listOfProducts;
}
This, however, does not work. The service will only call the base mapper (ProductMapper
) which can’t map children’s fields (such as author
or color
).
But why?
Down the rabbit hole
While you iterate the list of entities, which contains different types (Product
, Book
, Furniture
), you need to call a different mapping method for each type (i.e. a different MapStruct mapper).
One might expect that Java would automagically select the correct overload: at runtime, the list has objects of different types and there are overloads for each type. When the iterator reaches a Book
object, it should pass it to BookMapper
, right?
Wrong.
The root cause lies in Java Language Specification on overloading:
When a method is invoked (§15.12), the number of actual arguments (and any explicit type arguments) and the compile-time types of the arguments are used, at compile-time, to determine the signature of the method that will be invoked (§15.12.2)
So, method selection happens at compile-time, and at that point, the compiler only sees a list of Product
entities (the ones returned by your repository method) so it will only invoke the ProductMapper
.
Our only option is to explicitly invoke the correct overloaded method. A brutal approach would be along the lines of:
public List<ProductDto> getAllProducts() {
List<ProductDto> listOfProducts = new ArrayList<>();
productRepository
.findAll()
.forEach(productEntity ->
ProductDto dto;
if (productEntity instanceof Book){
dto = productMapper.productToProductDto((Book) productEntity));
}
if (productEntity instanceof Furniture){
dto = productMapper.productToProductDto((Furniture) productEntity));
}
dto = productMapper.productToProductDto((Product) productEntity));
listOfProducts.add(dto)
);
return listOfProducts;
}
With explicit casting in place, our service can now call the correct overloaded method to perform the mapping. Problem solved? Kinda… The solution is valid, but it’s also ugly, a maintenance hell, complex, fragile and ugly (yes, I know I already mentioned ugly).
Is there a better way?
A visitor from the past
Since we need to pick the right mapper manually (at compile time), we get to choose which way is cleaner or more maintainable. This is definitely opinioned.
My idea was to use the visitor pattern (actually a variation of it) in the following way:
Introduce a new interface for your entities that need to be mapped:
public interface MappableEntity {
public ProductDto map(EntityMapper mapper);
}
The entities need to implement this interface, for example:
class Book extends Product implements MappableEntity {
//...
@Override
public ProductDto map(EntityMapper mapper) {
return mapper.map(this);
}
}
This is the magic part. We choose which method to call because the parameter is this
, which is a Book!
EntityMapper
is the visitor interface
public interface EntityMapper {
ProductDto map(Product entity);
BookDto map(Book entity);
FurnitureDto map(Furniture entity);
// Add your next entity here
}
MasterMapper
for the rescue
import org.mapstruct.factory.Mappers;
// Not a class name I'm proud of
public class MasterMapper implements EntityMapper {
@Override
public ProductDto map(Product product) {
ProductMapper productMapper = Mappers.getMapper(ProductMapper.class);
return productMapper.map(product);
}
@Override
public BookDto map(Book product) {
BookMapper productMapper = Mappers.getMapper(BookMapper.class);
return productMapper.map(product);
}
@Override
public FurnitureDto map(Furniture product) {
FurnitureMapper productMapper = Mappers.getMapper(FurnitureMapper.class);
return productMapper.map(product);
}
// Add your next mapper here
}
Having the visitor pattern in place, the service class becomes:
public List<ProductDto> mapProducts(List<Product> listOfEntities) {
MasterMapper mm = new MasterMapper();
List<ProductDto> listOfProducts = new ArrayList<>(listOfEntities.size());
listOfEntities.forEach(productEntity -> {
if (productEntity instanceof MappableEntity) {
MappableEntity me = productEntity;
ProductDto dto = me.map(mm);
listOfProducts.add(dto);
} else {
// Throw an AssertionError during development (who would run server VMs with -ea ?!?!)
assert false : "Can't properly map " + productEntity.getClass() + " as it's not implementing MappableEntity";
// Use default mapper as a fallback
final ProductDto defaultDto = Mappers.getMapper(ProductMapper.class).map(productEntity);
listOfProducts.add(defaultDto);
}
});
return listOfProducts;
}
Photo by Shlomo Shalev on Unsplash