Prevent Cyclic references when converting with MapStruct

There is no detection or special handling of cases like this in MapStruct yet, but there is a feature request for it: #469. If you got any ideas how to deal with cycles, please drop a comment at that issue.


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> );`

At least in mapstruct 1.3 you can use the following:

The solution is widely inspired by https://github.com/mapstruct/mapstruct-examples/tree/master/mapstruct-mapping-with-cycles/src/main/java/org/mapstruct/example/mapper

Define a Context class (widely inspired by https://github.com/mapstruct/mapstruct-examples/blob/master/mapstruct-mapping-with-cycles/src/main/java/org/mapstruct/example/mapper/CycleAvoidingMappingContext.java ):

/**
 * An implementation to track cycles in graphs to be used as {@link Context} parameter.
 *
 */
public class CycleAvoidingMappingContext {
    private Map<Object, Object> knownInstances = new IdentityHashMap<Object, Object>();

    /**
     * Gets an instance out of this context if it is already mapped.
     * 
     * @param source
     *        given source
     * @param targetType
     *        given target type.
     * @return Returns the resulting type.
     */
    @BeforeMapping
    public <T> T getMappedInstance(Object source, @TargetType Class<T> targetType) {
        return targetType.cast(knownInstances.get(source));
    }

    /**
     * Puts an instance into the cache, so that it can be remembered to avoid endless mapping.
     * 
     * @param source
     *        given source
     * @param target
     *        given target
     */
    @BeforeMapping
    public void storeMappedInstance(Object source, @MappingTarget Object target) {
        knownInstances.put( source, target );
    }
}

In each mapper, which maps classes with cyclic references, add this org.mapstruct.Context:

/**
 * Mapper. Automatically implemented by mapstruct.
 * 
 */
@Mapper
public interface SomeObjWithCyclesMapper {

    /**
     * instance.
     */
    SomeObjWithCyclesMapper INSTANCE = Mappers.getMapper(SomeObjWithCyclesMapper.class);

    /**
     * Mapper method to map entity to domain. Automatically implemented by mapstruct.
     * 
     * @param entity
     *        given entity.
     * @param context
     *        context to avoid cycles.
     * @return Returns the domain object.
     */
    SomeObjWithCycles entityToDomain(SomeObjWithCyclesEntity entity, @Context CycleAvoidingMappingContext context);

    /**
     * Mapper method to map domain object to entity. Automatically implemented by mapstruct.
     * 
     * @param domain
     *        given domain object.
     * @param context
     *        context to avoid cycles.
     * @return Returns the entity.
     */
    SomeObjWithCyclesEntity domainToEntity(SomeObjWithCycles domain, @Context CycleAvoidingMappingContext context);
    
}

Usage (added 2021-09-21):

Then you can call the mapper method with:

SomeObjWithCyclesMapper.INSTANCE.domainToEntity(objWithCycles, new CycleAvoidingMappingContext());

Where objWithCycles is the object of class SomeObjWithCycles which you want to map.

HINT (added 2022-05-27)

Stating the obvious: This solves recursion problems occurring while converting objects using mapstruct. If you have recursion problems using other technologies, e.g. RestEasy, you have to refer to their manuals for solving problems specific to these technologies.


Notifica and Avvisinotifica are not helping me understand your models. Thus lets say you have the above Child and Father models,

public class Child {
    private int id;
    private Father father;
    // Empty constructor and getter/setter methods omitted.
}

public class Father {
    private int x;
    private List<Child> children;
    // Empty constructor and getter/setter methods omitted.
}

public class ChildDto {
    private int id;
    private FatherDto father;
    // Empty constructor and getter/setter methods omitted.
}

public class FatherDto {
    private int id;
    private List<ChildDto> children;
    // Empty constructor and getter/setter methods omitted.
}  

You should create a Mapper like this,

@Mapper
public abstract class ChildMapper {

    @AfterMapping
    protected void ignoreFathersChildren(Child child, @MappingTarget ChildDto childDto) {
        childDto.getFather().setChildren(null);
    }

    public abstract ChildDto myMethod(Child child);
}

=== Initial Version of Mapstuct

It is better to follow the next ways. This solution assumes that the ChildDto::father property is of type Father, not FatherDto, which is not a correct data architecture.
The @AfterMapping annotation means that the method will be imported inside the generated source, after the mapping of the properties. Thus, the Mapper implementation will be like this,

@Component
public class ChildMapperImpl extends ChildMapper {

    @Override
    public ChildDto myMethod(Child child) {
        if ( child == null ) {
            return null;
        }

        ChildDto childDto = new ChildDto();

        childDto.setId( child.getId() );
        childDto.setFather( child.getFather() );

        ignoreFathersChildren( child, childDto );

        return childDto;
    }
}

In this implementation the child has the parent set. This means that a cycle reference exists, but using the ignoreFathersChildren(child, childDto) method we remove the reference (we set it as null).

=== Update 1

Using the mapstruct version 1.2.0.Final you can do it better,

@Mapper
public interface ChildMapper {

    @Mappings({
//         @Mapping(target = "father", expression = "java(null)"),
         @Mapping(target = "father", qualifiedByName = "fatherToFatherDto")})
    ChildDto childToChildDto(Child child);

    @Named("fatherToFatherDto")
    @Mappings({
         @Mapping(target = "children", expression = "java(null)")})
    FatherDto fatherToFatherDto(Father father);
}

=== Update 2

Using the mapstruct version 1.4.2.Final you can do it even better,

@Named("FatherMapper")
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface FatherMapper {

    @Named("toDto")
    @Mappings
    FatherDto toDto(Father father);

    @Named("toDtoWithoutChildren")
    @Mappings({
         @Mapping(target = "children", expression = "java(null)")})
    FatherDto toDtoWithoutChildren(Father father);
}

@Named("ChildMapper")
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE, uses = {FatherMapper.class})
public interface ChildMapper {

    @Named("toDto")
    @Mappings({
         @Mapping(target = "father", qualifiedByName = {"FatherMapper", "toDtoWithoutChildren"})})
    ChildDto toDto(Child child);

    @Named("toDtoWithoutFather")
    @Mappings({
         @Mapping(target = "father", expression = "java(null)")})
    ChildDto toDtoWithoutFather(Child child);
}