How to Map Nested Properties Using MapStruct
This article explores how to perform nested mapping using the MapStruct library. Nested mapping is a common requirement in enterprise applications where data models are composed of complex, hierarchical structures.
MapStruct is a Java annotation processor that generates type-safe, high-performance mapping code at compile-time. It significantly reduces boilerplate code required for object mapping, making it ideal for converting between domain models and Data Transfer Objects (DTOs).
This article demonstrates how to automatically handle collections of nested objects and implement bi-directional mapping for conversion between entities and DTOs.
1. Use Case Overview
Imagine an application that manages music libraries. It has the following model structure:
- A
Library
contains a list ofSong
objects. - Each
Song
includes aTrack
object. - The
Track
includes details likeartistName
,albumName
, andduration
.
We want to map this into a flat DTO structure where:
SongDTO
includestitle
,album
,artist
, and a nestedTrackDTO
forduration
.
2. Maven Configuration
Below is the Maven configuration that includes the MapStruct library and the annotation processor plugin for compilation.
<dependencies> <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>1.6.3</version> </dependency> </dependencies> <build> <finalName>mapstruct-nested-mapping</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.13.0</version> <configuration> <annotationProcessorPaths> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>1.6.3</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build>
This configuration includes the MapStruct dependency. The annotation processor is essential for MapStruct to generate the implementation classes during compilation.
3. Source Entity Classes
Let’s define the domain model classes: Library
, Song
, and Track
. These will represent the core domain logic in the application.
public class Library { private String name; private List<Song> songs; }
public class Song { private String title; private Track track; }
public class Track { private String artistName; private String albumName; private int duration; // duration in seconds }
These classes define a Library
containing multiple Song
instances. Each Song
is linked to a Track
, which contains metadata.
4. Target DTO Classes
Next, we define the target Data Transfer Objects that we want to map to.
public class TrackDTO { private int duration; }
TrackDTO
represents a simplified version of the Track
entity, exposing only the duration
field.
public class SongDTO { private String title; private String artist; private String album; private TrackDTO track; }
SongDTO
provides a flattened view of the Song
entity by directly exposing the artist
and album
fields that are originally nested within Track
. It also retains a nested TrackDTO
for duration, demonstrating how MapStruct can handle both flattened and nested mappings in the same class.
public class LibraryDTO { private String name; private List<SongDTO> songs; }
LibraryDTO
serves as the top-level DTO containing a collection of SongDTO
objects. It mirrors the Library
entity structure.
5. Mapper Interface with Nested Mappings
The @Mapping
annotation is essential for handling cases where field names or structures differ between the source and target objects. It explicitly tells MapStruct how to map specific fields, including mapping from nested properties (e.g., track.artistName
) to flattened DTO fields (e.g., artist
).
Below is the mapper interface that defines how the Library
, Song
, and Track
entities are converted into their corresponding DTOs, and vice versa.
@Mapper public interface LibraryMapper { LibraryMapper INSTANCE = Mappers.getMapper(LibraryMapper.class); // Flatten nested Track to SongDTO @Mapping(source = "track.artistName", target = "artist") @Mapping(source = "track.albumName", target = "album") SongDTO songToDto(Song song); // Track to TrackDTO TrackDTO trackToDto(Track track); // Library to LibraryDTO (includes List<Song> to List<SongDTO>) LibraryDTO libraryToDto(Library library); // Reverse mappings @Mapping(target = "track.artistName", source = "artist") @Mapping(target = "track.albumName", source = "album") @Mapping(target = "track.duration", source = "track.duration") Song songFromDto(SongDTO dto); Track trackFromDto(TrackDTO dto); Library libraryFromDto(LibraryDTO dto); }
The @Mapping
annotation is used to define explicit field-to-field mappings when the source and target field names or structures differ. For example, @Mapping(source = "track.artistName", target = "artist")
maps a nested property (artistName
inside Track
) to a flat property (artist
) in SongDTO
.
Similarly, the reverse mappings use @Mapping(target = "track.artistName", source = "artist")
to reconstruct the nested Track
structure from the flattened DTO. The libraryToDto()
and libraryFromDto()
methods automatically handle collections by recursively applying the appropriate mapping logic to the List<Song>
and List<SongDTO>
types.
Finally, when the project is built, the MapStruct processor enabled through the Maven plugin automatically generates the LibraryMapperImpl
class during compilation. This class contains the actual mapping logic based on the definitions provided in the LibraryMapper
interface. Below is a truncated version of the generated LibraryMapperImpl
class:
public class LibraryMapperImpl implements LibraryMapper { @Override public SongDTO songToDto(Song song) { if ( song == null ) { return null; } SongDTO songDTO = new SongDTO(); songDTO.setArtist( songTrackArtistName( song ) ); songDTO.setAlbum( songTrackAlbumName( song ) ); songDTO.setTitle( song.getTitle() ); songDTO.setTrack( trackToDto( song.getTrack() ) ); return songDTO; } @Override public TrackDTO trackToDto(Track track) { if ( track == null ) { return null; } TrackDTO trackDTO = new TrackDTO(); trackDTO.setDuration( track.getDuration() ); return trackDTO; } @Override public LibraryDTO libraryToDto(Library library) { if ( library == null ) { return null; } LibraryDTO libraryDTO = new LibraryDTO(); libraryDTO.setName( library.getName() ); libraryDTO.setSongs( songListToSongDTOList( library.getSongs() ) ); return libraryDTO; } @Override public Song songFromDto(SongDTO dto) { if ( dto == null ) { return null; } Song song = new Song(); if ( dto.getTrack() != null ) { if ( song.getTrack() == null ) { song.setTrack( new Track() ); } trackDTOToTrack( dto.getTrack(), song.getTrack() ); } if ( song.getTrack() == null ) { song.setTrack( new Track() ); } songDTOToTrack( dto, song.getTrack() ); song.setTitle( dto.getTitle() ); return song; } @Override public Track trackFromDto(TrackDTO dto) { if ( dto == null ) { return null; } Track track = new Track(); track.setDuration( dto.getDuration() ); return track; } // ...additional methods }
This class demonstrates how MapStruct compiles the annotated mappings into fully working Java code. Each @Mapping
declared in the interface becomes a field assignment here. Note how MapStruct checks for null
to avoid NullPointerException
, and creates new instances of nested DTOs or entities before assigning values.
6. Example Usage
Here’s a Main
class to run and see the mapper in action.
public class Main { public static void main(String[] args) { // Create nested Track Track track = new Track(); track.setArtistName("Pink Floyd"); track.setAlbumName("The Wall"); track.setDuration(385); // Create Song with nested Track Song song = new Song(); song.setTitle("Another Brick in the Wall"); song.setTrack(track); // Create Library with nested Song Library library = new Library(); library.setName("Classic Rock"); List<Song> songs = new ArrayList<>(); songs.add(song); library.setSongs(songs); // Map Library to LibraryDTO LibraryDTO libraryDTO = LibraryMapper.INSTANCE.libraryToDto(library); System.out.println(" Mapped to DTO:"); System.out.println("LibraryDTO Name: " + libraryDTO.getName()); SongDTO mappedSongDTO = libraryDTO.getSongs().get(0); System.out.println(" SongDTO Title: " + mappedSongDTO.getTitle()); System.out.println(" Artist (flattened): " + mappedSongDTO.getArtist()); System.out.println(" Album (flattened): " + mappedSongDTO.getAlbum()); System.out.println(" Duration (nested TrackDTO): " + mappedSongDTO.getTrack().getDuration()); // Map DTO back to Library entity Library mappedBackLibrary = LibraryMapper.INSTANCE.libraryFromDto(libraryDTO); System.out.println("\n Mapped Back to Entity:"); System.out.println("Library Name: " + mappedBackLibrary.getName()); Song mappedSong = mappedBackLibrary.getSongs().get(0); System.out.println(" Song Title: " + mappedSong.getTitle()); Track mappedTrack = mappedSong.getTrack(); System.out.println(" Artist Name: " + mappedTrack.getArtistName()); System.out.println(" Album Name: " + mappedTrack.getAlbumName()); System.out.println(" Duration: " + mappedTrack.getDuration()); } }
Expected Console Output
Mapped to DTO: LibraryDTO Name: Classic Rock SongDTO Title: Another Brick in the Wall Artist (flattened): Pink Floyd Album (flattened): The Wall Duration (nested TrackDTO): 385 Mapped Back to Entity: Library Name: Classic Rock Song Title: Another Brick in the Wall Artist Name: Pink Floyd Album Name: The Wall Duration: 385
This output confirms:
Track.artistName
was mapped toSongDTO.artist
and then back again.Track.albumName
was mapped toSongDTO.album
and then back again.Track.duration
was preserved through nested mapping viaTrackDTO
.
7. Conclusion
In this article, we explored how MapStruct effectively handles nested mappings between complex Java objects and their corresponding DTOs. By explicitly defining mappings with the @Mapping
annotation, we ensured that nested properties were accurately mapped across layers.
8. Download the Source Code
This article explored how to perform nested mapping using MapStruct.
You can download the full source code of this example here: mapstruct nested mapping