Actually this approach with a CycleAvoidingMappingContext didn't work for me using MapStruct version 1.3.1. As I couldn't find much working examples I dediced to post my solution here for others to find.
In case of bi-directional relationships such mappings can trigger a StackOverflowError due to circular references.
Example: classes Recipe, Book and Ingredient which are related bidirectionally 1-to-many and many-to-many.
- A recipe has lots of ingredients, but is mentioned in only 1 book.
- One Book has lots of recipes in it.
- An ingredient is only used in 1 recipe (assuming that an Ingredient also has properties which fixes its amount, unit of measure and so on so that it is indeed specific to one recipe only).
public class Recipe {
Long id;
// ... Other recipe properties go here
Book book;
Set<Ingredient> ingredients;
}
public class Book {
Long id;
// ... Other book properties go here
Set<Recipe> recipes;
}
public class Ingredient {
Long id;
// ... Other ingredient properties go here
Recipe recipe;
}
I am assuming you would also have DTO classes with identical properties but ofcourse referring to their corresponding DTO classes.
These would be the default Mapper setups (without depending on Spring in this case) for mapping from your entity classes to your DTO classes:
// MapStruct can handle primitive and standard classes like String and Integer just fine, but if you are using custom complex objects it needs some instructions on how it should map these
@Mapper(uses = {BookMapper.class, IngredientMapper.class})
public interface RecipeMapper {
RecipeMapper INSTANCE = Mappers.getMapper( RecipeMapper.class );
RecipeDTO toDTO(Recipe recipe);
Recipe toEntity(RecipeDTO recipeDTO);
}
@Mapper(uses = {RecipeMapper.class, IngredientMapper.class})
public interface BookMapper {
BookMapper INSTANCE = Mappers.getMapper( BookMapper.class );
BookDTO toDTO(Book book);
Book toEntity(BookDTO book);
}
@Mapper(uses = {RecipeMapper.class, BookMapper.class})
public interface IngredientMapper {
IngredientMapper INSTANCE = Mappers.getMapper( IngredientMapper.class );
IngredientDTO toDTO(Ingredient ingredient);
Ingredient toEntity(IngredientDTO ingredientDTO);
}
If you would stop there and try to map the classes this way, you will get hit by the StackOverflowError due to circular references you have now defined (Recipe contains ingredients which has a property recipe which has ingredients...).
Such default Mapper setups can only be used if there is no bidirectional relationship which would trigger the inverse mapping as well.
You could write this down like A -> B -> A -> B -> A ...
Concerning the object mapping, my experience has shown that you should be able to map this out like: A -> B -> A (excluding the relations this time to break the cycle)
for both Entity to DTO and DTO to Entity mappings. This enables you to:
- Drill down to associated objects in the frontend: ex. display a list of ingredients for a recipe
- Persist the inverse relation when saving an object: ex. If you would only map A -> B. The IngredientDTOs inside RecipeDTO would not have a recipe property and when saving an ingredient you would need to pass in the recipe id as a parameter and jump through some hoops to associate the ingredient entity object with a recipe entity object before saving the ingredient entity to the database.
Defining the mappings like A -> B -> A (excluding the relations this time to break the cycle) will boil down to defining separate mappings for when you want to exclude the related complex objects from the mapping at the point where you want to break the cycle.
@IterableMapping(qualifiedByName = "<MAPPING_NAME>")
is used to map a collection of complex objects, which refers to a mapping for a single complex object.
@Mapping(target = "PropertyName", qualifiedByName = "<MAPPING_NAME>")
can be used to point to an alternative mapping which excludes the inverse relationships when mapping a collection of complex objects (when you want to break the cycle)
@Mapping(target = "[.]", ignore = true)
can be used to indicate that a property of an object should not be mapped at all. So this can be used to completely leave out a (collection of) complex object(s) entirely or to ignore properties inside of a single (not a collection) related complex objects directly in case they are not needed.
If you don't use the qualifiedByName attribute and the matching @Named() annotations, your mappings will not compile with an error about Ambiguous mappings if you have multiple methods with the same return type and input parameter types in the Mapper interface.
It may be a good practice use method names matching the @Named annotation value in case you use named mappings.
So, we will note down the wanted behavior first and then code it:
1. When mapping a Recipe, we will need to map the book property in such a way that its inverse relation to recipes is mapped without the book property the second time
Recipe A -> Book X -> Recipe A (without book property value as this would close the cycle)
-> Recipe B (without book property value, as same mapping is used for all these recipes unfortunately as we don't know up front which one will cause the cyclic reference)...
-> Ingredients I (without recipe property value as they would all point back to A)
2. When mapping a Book, we will need to map the recipes property in such a way that its inverse relation to book isn't mapped as it will point back to the same book.
Book X -> Recipe A (without book property as this would close the cycle)
-> Ingredients (without recipe property as all these will point back to Recipe A)
-> Recipe B (without book property, as same mapping is used for all these and all could potentially close the cycle)
-> Recipe C
3. When mapping an Ingredient, we will need to map the recipe property in such a way that its inverse relation to ingredient isn't mapped as one of those ingredients will point back to the same ingredient
The book property inside the recipe will need to be mapped without the recipes property as one of those will also loop back to the recipe.
@Mapper(uses = {BookMapper.class, IngredientMapper.class})
public interface RecipeMapper {
RecipeMapper INSTANCE = Mappers.getMapper( RecipeMapper.class );
@Named("RecipeSetIgnoreBookAndIngredientChildRecipes")
@IterableMapping(qualifiedByName = "RecipeIgnoreBookAndIngredientChildRecipes")
Set<RecipeDTO> toDTOSetIgnoreBookAndIngredientChildRecipes(Set<Recipe> recipes);
@Named("RecipeSetIgnoreIngredientsAndBookChildRecipe")
@IterableMapping(qualifiedByName = "RecipeIgnoreIngredientsAndBookChildRecipe")
Set<RecipeDTO> toDTOSetIgnoreIngredientsAndBookChildRecipe(Set<Recipe> recipes);
// In this mapping we will ignore the book property and the recipe property of the Ingredients to break the mapping cyclic references when we are mapping a book object
// Don't forget to add the matching inverse mapping from DTO to Entity, this is basically just a copy with switch input parameter and return types
@Named("RecipeIgnoreBookAndIngredientChildRecipes")
@Mappings({
@Mapping(target = "book", ignore = true), // book is a single custom complex object (not a collection), so we can directly ignore its child properties from there
@Mapping(target = "ingredients", qualifiedByName = "IngredientSetIgnoreRecipes"), // ingredients is a collection of complex objects, so we can't directly ignore its child properties as in the end, a Mapper needs to be defined to Map a single POJO into another
})
RecipeDTO toDTOIgnoreBookAndIngredientChildRecipes(Recipe recipe);
@Named("RecipeIgnoreIngredientsAndBookChildRecipe")
@Mappings({
@Mapping(target = "book.recipes", ignore = true),
@Mapping(target = "ingredients", ignore = true),
})
RecipeDTO toDTOIgnoreIngredientsAndBookChildRecipe(Recipe recipe);
// Don't forget to add the matching inverse mapping from DTO to Entity, this is basically just a copy with switch input parameter and return types
@Mappings({
@Mapping(target = "book.recipes", ignore = true), // book is a single custom complex object (not a collection), so we can directly ignore its child properties from there
@Mapping(target = "ingredients", qualifiedByName = "IngredientSetIgnoreRecipes"), // ingredients is a collection of complex objects, so we can't directly ignore its child properties as in the end, a Mapper needs to be defined to Map a single POJO into another
})
RecipeDTO toDTO(Recipe recipe);
@Named("RecipeSetIgnoreBookAndIngredientChildRecipes")
@IterableMapping(qualifiedByName = "RecipeIgnoreBookAndIngredientChildRecipes")
Set<Recipe> toEntitySetIgnoreBookAndIngredientChildRecipes(Set<RecipeDTO> recipeDTOs);
@Named("RecipeSetIgnoreIngredientsAndBookChildRecipe")
@IterableMapping(qualifiedByName = "RecipeIgnoreIngredientsAndBookChildRecipe")
Set<Recipe> toEntitySetIgnoreIngredientsAndBookChildRecipe(Set<RecipeDTO> recipeDTOs);
@Mappings({
@Mapping(target = "book.recipes", ignore = true), // book is a single custom complex object (not a collection), so we can directly ignore its child properties from there
@Mapping(target = "ingredients", qualifiedByName = "IngredientSetIgnoreRecipes"), // ingredients is a collection of complex objects, so we can't directly ignore its child properties as in the end, a Mapper needs to be defined to Map a single POJO into another
})
Recipe toEntity(RecipeDTO recipeDTO);
@Named("RecipeIgnoreBookAndIngredientChildRecipes")
@Mappings({
@Mapping(target = "book", ignore = true), // book is a single custom complex object (not a collection), so we can directly ignore its child properties from there
@Mapping(target = "ingredients", qualifiedByName = "IngredientSetIgnoreRecipes"), // ingredients is a collection of complex objects, so we can't directly ignore its child properties as in the end, a Mapper needs to be defined to Map a single POJO into another
})
Recipe toEntityIgnoreBookAndIngredientChildRecipes(RecipeDTO recipeDTO);
@Named("RecipeIgnoreIngredientsAndBookChildRecipe")
@Mappings({
@Mapping(target = "book.recipes", ignore = true),
@Mapping(target = "ingredients", ignore = true),
})
Recipe toEntityIgnoreIngredientsAndBookChildRecipe(RecipeDTO recipeDTO);
}
@Mapper(uses = {RecipeMapper.class, IngredientMapper.class})
public interface BookMapper {
BookMapper INSTANCE = Mappers.getMapper( BookMapper.class );
@Mappings({
@Mapping(target = "recipes", qualifiedByName = "RecipeSetIgnoreBookAndIngredientChildRecipes"),
})
BookDTO toDTO(Book book);
@Mappings({
@Mapping(target = "recipes", qualifiedByName = "RecipeSetIgnoreBookAndIngredientChildRecipes"),
})
Book toEntity(BookDTO book);
}
@Mapper(uses = {RecipeMapper.class, BookMapper.class})
public interface IngredientMapper {
IngredientMapper INSTANCE = Mappers.getMapper( IngredientMapper.class );
// Don't forget to add the matching inverse mapping from DTO to Entity, this is basically just a copy with switch input parameter and return types
@Named("IngredientSetIgnoreRecipes")
IterableMapping(qualifiedByName = "IngredientIgnoreRecipes") // Refer to the mapping for a single object in the collection
Set<IngredientDTO> toDTOSetIgnoreRecipes(Set<Ingredient> ingredients);
// Don't forget to add the matching inverse mapping from DTO to Entity, this is basically just a copy with switch input parameter and return types
@Named("IngredientIgnoreRecipes")
@Mappings({
@Mapping(target = "recipes", ignore = true), // ignore the recipes property entirely
})
IngredientDTO toDTOIgnoreRecipes(Ingredient ingredient);
@Mappings({
@Mapping(target = "recipes", qualifiedByName = "RecipeSetIgnoreIngredientsAndBookChildRecipe")
})
IngredientDTO toDTO(Ingredient ingredient);
@Named("IngredientSetIgnoreRecipes")
IterableMapping(qualifiedByName = "IngredientIgnoreRecipes") // Refer to the mapping for a single object in the collection
Set<Ingredient> toEntitySetIgnoreRecipes(Set<IngredientDTO> ingredientDTOs);
@Named("IngredientIgnoreRecipes")
@Mappings({
@Mapping(target = "recipes", ignore = true),
})
Ingredient toEntityIgnoreRecipes(IngredientDTO ingredientDTO);
@Mappings({
@Mapping(target = "recipes", qualifiedByName = "RecipeSetIgnoreIngredientsAndBookChildRecipe")
})
Ingredient toEntityIgnoreRecipes(IngredientDTO ingredientDTO);
}
Usage
<ENTITY_NAME>DTO <eNTITY_NAME>DTO = <ENTITY_NAME>Mapper.INSTANCE.toDTO( <eNTITY_NAME> );`