DEV Community

Jens
Jens

Posted on • Edited on

Mapping inheritance hierarchies with MapStruct

Intro

MapStruct provides a rich set of features for mapping Java types. The technical documentation describes extensively the classes and annotations provided by MapStruct and how to use them. More complex use cases are described in several community written articles all over the web. To complement the pool of available articles, this article will focus on mapping inheritance hierarchies and provide a possible solution that offers simplicity and reusability. I assume that the reader has a basic knowledge of MapStruct. If you're interested in a running example, feel free to check out this repo and try things out.

Example

To demonstrate the capabilities of MapStruct in a simple way, we will use a very small and thus useless domain model for which the use of MapStruct seems overly complex, but which allows the code snippets to remain simple throughout the article. The real benefits of MapStruct become apparent especially with larger models.

// Source classes
public class SourceProject {
  private String name;
  private LocalDate dueDate;
  // getters + setters omitted throughout the code
}

// Target classes
public class TargetProject {
  private ProjectInformation projectInformation;
}

public class ProjectInformation {
  private String projectName;
  private LocalDate endDate;
}

Enter fullscreen mode Exit fullscreen mode

As you can see, the source and target entities express the same information but are structured slightly differently. A mapper can be defined like this...

@Mapper
public interface ProjectMapper {
  @Mapping(target = "projectInformation.projectName", source = "name")
  @Mapping(target = "projectInformation.endDate", source = "dueDate")
  TargetProject mapProject(SourceProject source);
}
Enter fullscreen mode Exit fullscreen mode

...and MapStruct will generate code that will look like this:

public class ProjectMapperImpl implements ProjectMapper {

    @Override
    public TargetProject mapProject(SourceProject source) {
        if ( source == null ) {
            return null;
        }

        TargetProject targetProject = new TargetProject();

        targetProject.setProjectInformation( sourceProjectToProjectInformation( source ) );

        return targetProject;
    }

    protected ProjectInformation sourceProjectToProjectInformation(SourceProject sourceProject) {
        if ( sourceProject == null ) {
            return null;
        }

        ProjectInformation projectInformation = new ProjectInformation();

        projectInformation.setProjectName( sourceProject.getName() );
        projectInformation.setEndDate( sourceProject.getDueDate() );

        return projectInformation;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's introduce some new entities that use inheritance:

// Source classes
@Data
public class SourceScrumProject extends SourceProject {
  private Integer velocity;
}

// Target classes
@Data
public class TargetScrumProject extends TargetProject {
  private Velocity velocity;
}

@Data
public class Velocity {
  private Integer value;
}
Enter fullscreen mode Exit fullscreen mode

If we want to use the parent mapper universally to map both the parent entity and child entities, we can use the @SubclassMapping annotation, which generates a dispatching via instanceof checks to the mapping of possible child classes.

@Mapper
public interface ProjectMapper {
  @Mapping(target = "projectInformation.projectName", source = "name")
  @Mapping(target = "projectInformation.endDate", source = "dueDate")
  @SubclassMapping(source = SourceScrumProject.class, target = TargetScrumProject.class)
  TargetProject mapProject(SourceProject source);

  @Mapping(target = "velocity.value", source = "velocity")
  @Mapping(target = "projectInformation.projectName", source = "name")
  @Mapping(target = "projectInformation.endDate", source = "dueDate")
  TargetScrumProject mapScrumProject(SourceScrumProject source);
}

Enter fullscreen mode Exit fullscreen mode

This generates the following code.

public class ProjectMapperImpl implements ProjectMapper {

    @Override
    public TargetProject mapProject(SourceProject source) {
        if ( source == null ) {
            return null;
        }

        if (source instanceof SourceScrumProject) {
            return mapScrumProject( (SourceScrumProject) source );
        }
        else {
            TargetProject targetProject = new TargetProject();

            targetProject.setProjectInformation( sourceProjectToProjectInformation( source ) );

            return targetProject;
        }
    }

    @Override
    public TargetScrumProject mapScrumProject(SourceScrumProject source) {
        if ( source == null ) {
            return null;
        }

        TargetScrumProject targetScrumProject = new TargetScrumProject();

        targetScrumProject.setVelocity( sourceScrumProjectToVelocity( source ) );
        targetScrumProject.setProjectInformation( sourceScrumProjectToProjectInformation( source ) );

        return targetScrumProject;
    }

    protected ProjectInformation sourceProjectToProjectInformation(SourceProject sourceProject) {
        if ( sourceProject == null ) {
            return null;
        }

        ProjectInformation projectInformation = new ProjectInformation();

        projectInformation.setProjectName( sourceProject.getName() );
        projectInformation.setEndDate( sourceProject.getDueDate() );

        return projectInformation;
    }

    protected Velocity sourceScrumProjectToVelocity(SourceScrumProject sourceScrumProject) {
        if ( sourceScrumProject == null ) {
            return null;
        }

        Velocity velocity = new Velocity();

        velocity.setValue( sourceScrumProject.getVelocity() );

        return velocity;
    }

    protected ProjectInformation sourceScrumProjectToProjectInformation(SourceScrumProject sourceScrumProject) {
        if ( sourceScrumProject == null ) {
            return null;
        }

        ProjectInformation projectInformation = new ProjectInformation();

        projectInformation.setProjectName( sourceScrumProject.getName() );
        projectInformation.setEndDate( sourceScrumProject.getDueDate() );

        return projectInformation;
    }
}
Enter fullscreen mode Exit fullscreen mode

We can already see some problems here:

  1. We are duplicating the @Mapping annotation from the parent mapping.
  2. Parts of the generated code are duplicated (sourceProjectToProjectInformation and sourceScrumProjectToProjectInformation).
  3. The interface becomes wider as it contains mapping methods for both the parent and child entities.

With only these two fields this doesn't seem terrible, but imagine what the generated code would look like if we had more child classes containing more fields. The effect would be much bigger.

Let's try to tackle problem #1. MapStruct offers the annotation @InheritConfiguration which allows us to reuse mapping configuration from either the same class or the mapping configuration class used:

@Mapper
public interface ProjectMapper {
  @Mapping(target = "projectInformation.projectName", source = "name")
  @Mapping(target = "projectInformation.endDate", source = "dueDate")
  @SubclassMapping(source = SourceScrumProject.class, target = TargetScrumProject.class)
  TargetProject mapProject(SourceProject source);

  @Mapping(target = "velocity.value", source = "velocity")
  @InheritConfiguration(name = "mapProject")
  TargetScrumProject mapScrumProject(SourceScrumProject source);
}
Enter fullscreen mode Exit fullscreen mode

This at least saves us a lot of duplicate configuration. Spoiler: We will not want to use this anymore at a later stage. But let's first tackle problem #2 and #3.

Since we could have potentially wide interfaces with a lot of duplicated code, using, understanding and debugging of the generated code could become more difficult. It would be easier if we had a mapper for each subclass that is self-contained and only either dispatches to a child mapper or performs a mapping, but not both. So let's move the mapping of the Scrum projects to a separate interface.

@Mapper(uses = ScrumProjectMapper.class)
public interface ProjectMapper {
  @Mapping(target = "projectInformation.projectName", source = "name")
  @Mapping(target = "projectInformation.endDate", source = "dueDate")
  @SubclassMapping(source = SourceScrumProject.class, target = TargetScrumProject.class)
  TargetProject mapProject(SourceProject source);
}

@Mapper
public interface ScrumProjectMapper {
  @Mapping(target = "velocity.value", source = "velocity")
  @InheritConfiguration(name = "mapProject") // not working
  TargetScrumProject mapScrumProject(SourceScrumProject source);
}
Enter fullscreen mode Exit fullscreen mode

We tell ProjectMapper to dispatch the mapping of ScrumProjects to ScrumProjectMapper via the uses-clause. The problem here is that the configuration from the mapProject-method is no longer visible to the ScrumProjectMapper. We could of course let it extend ProjectMapper, but then we have the problem of the wide interface and duplicated code again, as all methods are merged into ScrumProjectMapper. We could instead make ProjectMapper a config using the @MapperConfig annotation and reference it in ScrumProjectMapper, but since it also uses ScrumProjectMapper in the uses-clause to enable the dispatching, MapStruct would complain about the circular dependency. Furthermore, if we have an inheritance hierarchy with a height > 1, we quickly notice that MapStruct does not pass the config down the mapper hierarchy more than one level, making the config at level 0 unavailable on levels 2 and beyond.

Fortunately, there is a solution. The @Mapping annotation can be applied to other annotations. By declaring an annotation ProjectMappings, which basically wraps all the mapping information for Projects, we can reuse it anywhere we want. Let's see what this could look like.

@Mapper(uses = ScrumProjectMapper.class)
public interface ProjectMapper {
  @Mappings
  @SubclassMapping(source = SourceScrumProject.class, target = TargetScrumProject.class)
  TargetProject mapProject(SourceProject source);

  @Mapping(target = "projectInformation.projectName", source = "name")
  @Mapping(target = "projectInformation.endDate", source = "dueDate")
  @interface Mappings {
  }
}

@Mapper
public interface ScrumProjectMapper {
  @Mapping(target = "velocity.value", source = "velocity")
  @ProjectMapper.Mappings
  TargetScrumProject mapScrumProject(SourceScrumProject source);
}
Enter fullscreen mode Exit fullscreen mode

Imagine that we have more child classes than just ScrumProject. By simply bundling the mapping information in the shared annotation we can centralize the information and avoid all the pitfalls that come with duplication. This also works for deeper inheritance hierarchies. I just need to annotate my mapping method with the @Mappings-annotation of the parent mapper that uses the annotation of its parent mapper, and so on.

We can see in the generated code now that the mappers either dispatch or do the mapping only for the classes they're built for:

public class ProjectMapperImpl implements ProjectMapper {

    private final ScrumProjectMapper scrumProjectMapper = Mappers.getMapper( ScrumProjectMapper.class );

    @Override
    public TargetProject mapProject(SourceProject source) {
        if ( source == null ) {
            return null;
        }

        if (source instanceof SourceScrumProject) {
            return scrumProjectMapper.mapScrumProject( (SourceScrumProject) source );
        }
        else {
            TargetProject targetProject = new TargetProject();

            targetProject.setProjectInformation( sourceProjectToProjectInformation( source ) );

            return targetProject;
        }
    }

    protected ProjectInformation sourceProjectToProjectInformation(SourceProject sourceProject) {
        if ( sourceProject == null ) {
            return null;
        }

        ProjectInformation projectInformation = new ProjectInformation();

        projectInformation.setProjectName( sourceProject.getName() );
        projectInformation.setEndDate( sourceProject.getDueDate() );

        return projectInformation;
    }
}

public class ScrumProjectMapperImpl implements ScrumProjectMapper {

    @Override
    public TargetScrumProject mapScrumProject(SourceScrumProject source) {
        if ( source == null ) {
            return null;
        }

        TargetScrumProject targetScrumProject = new TargetScrumProject();

        targetScrumProject.setVelocity( sourceScrumProjectToVelocity( source ) );
        targetScrumProject.setProjectInformation( sourceScrumProjectToProjectInformation( source ) );

        return targetScrumProject;
    }

    protected Velocity sourceScrumProjectToVelocity(SourceScrumProject sourceScrumProject) {
        if ( sourceScrumProject == null ) {
            return null;
        }

        Velocity velocity = new Velocity();

        velocity.setValue( sourceScrumProject.getVelocity() );

        return velocity;
    }

    protected ProjectInformation sourceScrumProjectToProjectInformation(SourceScrumProject sourceScrumProject) {
        if ( sourceScrumProject == null ) {
            return null;
        }

        ProjectInformation projectInformation = new ProjectInformation();

        projectInformation.setProjectName( sourceScrumProject.getName() );
        projectInformation.setEndDate( sourceScrumProject.getDueDate() );

        return projectInformation;
    }
}
Enter fullscreen mode Exit fullscreen mode

I'd say this makes understanding and debugging a single mapper easier. As we've already covered a lot of ground, let's wrap things up at this point. There are still some edge cases that can lead to further problems, but these will be covered in the next part.

Wrap-up

Writing mappers with MapStruct for inheritance hierarchies should be a common task and easy to achieve, but you can quickly get stuck in some of MapStruct's quirks. Mapping the entire hierarchy in one class results in large classes implementing wide interfaces that are difficult to read and debug. When splitting the mappers into one per class, we want to reuse the mapping information from the parent mappers to avoid duplicating mapping information. Extending the parent mapper to make its mapping configuration visible for use in @InheritConfiguration is not desirable, as we will again have the problem of a wide interface with a lot of duplicated code. Using the parent mapper as a config is also not possible due to circular dependencies. We could see that the creating a custom annotation that bundles the mapping information for use in child mappers solves the problem. By additionally using SubclassMapping, the parent mapper provides the bundled information on how to map the entity it knows, contains only the mapping for exactly that class and dispatches the mapping of any other child entities down the hierarchy of mappers.

Top comments (0)