How to create DTOs with records and MapStruct in Spring Boot

Catherine Edelveis
7 min readJun 10, 2024

--

I wouldn’t go so far as to claim that Data Transfer Objects (DTOs) are an essential part of every application. But in case you can’t do without them, there’s a way to reduce boilerplate code and simplify the process of passing data through DTOs — all thanks to Java records and the MapStruct framework.

So below, you will find a tutorial on integrating these two powerful solutions into your project. Let’s roll!

By the way, Medium is not my only dwelling place, I also write about cool Spring Boot features to the BellSoft blog. For instance, you can check out my latest post on using CDS with Spring Boot to reduce the startup times of your application (spoiler alert: it’s super-easy, but the gains are tangible!)

What are records

Records were first introduced in Java 14 and finalized in Java 16. They represent immutable data carriers that enable the developers to omit a lot of boilerplate code when defining a simple data class. By default, all fields defined in a record class are private and final. methods (such as equals(), hashCode(), toString()) are already there, and so is the constructor.

But why do we need records when we have Lombok? True, Lombok helps to reduce boilerplate, too. But you have to add numerous annotations to the class when using Lombok, and God forbid you forget one of them. I once forgot to add the @Getter annotation to one of my DTOs and spent half an hour with a debugger in an attempt to understand why the DTO fields are null.

Nevertheless, in some cases we have to stick to Lombok. JPA entities, for instance, can’t be turned into records as Thorben Jannsen explained in his article because, among other aspects, they can’t be final and must provide getters, setters, and a parameterless constructor.

But DTOs are a perfect match for records, which we will see below.

Create DTOs using records

Let’s first look at the Film class we are going to create DTOs for:

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
@Entity
@Table(name = "film")
public class Film {

@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "name")
@NotNull
private String name;

@Column(name = "description", columnDefinition = "TEXT", length = 200)
@NotNull
private String description;

@Column(name = "premiere_date", columnDefinition = "DATE")
@DateTimeFormat(pattern = "yyyy-MM-dd")
@NotNull
private LocalDate premiereDate;

@ManyToMany(fetch = FetchType.LAZY, cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinTable(name = "film_genre",
joinColumns = @JoinColumn(name = "film_id"),
inverseJoinColumns = @JoinColumn(name = "genre_id"))
private Set<Genre> genres = new HashSet<>();

@Column(name = "duration")
@NotNull
@Positive
private int duration;

@ManyToOne(cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name = "mpaa_id")
private Mpaa mpaa;

@ManyToMany(fetch = FetchType.LAZY, cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinTable(name = "film_actor",
joinColumns = @JoinColumn(name = "film_id"),
inverseJoinColumns = @JoinColumn(name = "star_id"))
private Set<FilmPerson> actors = new HashSet<>();

@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "film_id")
private Set<Rating> ratings = new HashSet<>();


@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Film film = (Film) o;
return Objects.equals(id, film.id);
}

@Override
public int hashCode() {
return Objects.hash(id);
}

As you can see, this class includes several fields plus nested entities. But when we display information about a film, we don’t want to include all information about actors, genres, and MPAA rating, only the names. On the other hand, we don’t want to pass sets of whole objects on the client-side to populate a new film: passing an id should suffice. So let’s create our custom DTOs accordingly.

@Builder
public record FilmResponse(String name,
String description,
LocalDate premiereDate,
int duration,
List<String> genres,
String mpaa,
List<String> actors,
double rating) {

}

At first, you may not notice the difference from a standard list of variables. But there is a difference. See, no access modifiers, no constructors, no boilerplate, no numerous Lombok annotations (except for the @Builder annotation) — very laconic and beautiful! And if you don’t have complex if statements in your mapping methods, you can get rid of @Builder as well and later create DTO objects using a constructor provided with records.

What about a new film request? We want to add validation annotations to delegate data verification to Spring Boot. Good news is that records allow us to add annotations to the fields!

@Builder
public record NewFilmRequest(@NotBlank String name,
@NotBlank @Size(max = 200) String description,
@NotNull LocalDate premiereDate,
@NotNull @Positive int duration,
@NotNull Set<Long> genreIds,
@NotNull Set<Long> actorIds,
@NotNull Long mpaaId) {
}

You can create a DTO for updating a film or any other DTO you need for a film in a similar way.

DTOs are ready, let’s move on to mapping them!

Use MapStruct to convert Entities to DTOs and vice versa

Add MapStruct dependencies

First of all, we need to add the necessary MapStruct dependencies. If you use Maven, add the following MapStruct dependency to pom.xml:

<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>

We also need to add the MapStruct processor to the Maven plugin, which generates mapper implementations during the build stage. In addition, we should add the lombok-mapstruct-binding dependency to the plugin if we want to use Lombok:

<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.5.5.Final</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>

I’m using the latest versions of MapStruct and its processor at the moment of writing the article. You can always check the MapStruct Core and MapStruct Processor Maven repositories for the updates.

Basic mapping with MapStruct interface

Let’s set aside our Film entity for a minute and look at more basic mapping examples.

In a perfect world, the DTO fields are the same as those of our entity. For instance, we have a Post entity:

public class Post {
private Long id;
private String text;
private Long userId;
}

And the corresponding DTO record:

public record PostDto(Long id, String text, Long UserId) {
}

In this case, we can create a Mapper interface with the @Mapper annotation and two simple methods:

@Mapper
public interface PostMapper {
PostMapper INSTANCE = Mappers.getMapper(PostMapper.class);
PostDto mapPostToDto(Post post);
Post mapDtoToPost(PostDto dto);
}

The INSTANCE field can be used when you need to perform the mapping in your Service classes (or wherever you prefer to do the mapping).

That’s it! You don’t have to create a Mapper implementation because it will be automatically generated when you run the application. Below is the implementation MapStruct generated for us under target/generated-sources/annotations/com/example/demo/PostMapperImpl.java:

@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2024-06-10T13:04:45+0300",
comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.3 (BellSoft)"
)
public class PostMapperImpl implements PostMapper {

@Override
public PostDto mapPostToDto(Post post) {
if ( post == null ) {
return null;
}

Long id = null;
String text = null;

id = post.getId();
text = post.getText();

Long userId = null;

PostDto postDto = new PostDto( id, text, userId );

return postDto;
}

@Override
public Post mapDtoToPost(PostDto dto) {
if ( dto == null ) {
return null;
}

Post post = new Post();

post.setId( dto.id() );
post.setText( dto.text() );

return post;
}
}

Piece of cake, right? Let’s see how MapStruct handles other use cases.

For instance, in a less perfect world, we don’t want to map certain fields like id, so our PostDto will be slightly different:

public record PostDto(String text, Long userId) {
}

In this case, we can exclude the missing field from mapping by adding the unmappedTargetPolicy to the @Mapper annotation:

@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface PostMapper {
PostMapper INSTANCE = Mappers.getMapper(PostMapper.class);
PostDto mapPostToDto(Post post);

Post mapDtoToPost(PostDto dto);
}

As a result, MapStruct will ignore unmapped properties and map only what can be mapped. In addition, it won’t issue any warnings during compilation.

What if some fields in DTO and Entity have different names? For instance, userId in Post and authorId in PostDto:

public record PostDto(String text, Long authorId) {
}

To map fields with different names, we need to add the @Mapping annotation to PostMapper methods and configure source to target fields in the following way:

@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface PostMapper {
PostMapper INSTANCE = Mappers.getMapper(PostMapper.class);
@Mapping(target = "authorId", source = "userId")
PostDto mapPostToDto(Post post);

@Mapping(target = "userId", source = "authorId")
Post mapDtoToPost(PostDto dto);

}

After that, you just sit back, relax, and let MapStruct do its magic!

Advanced mapping with Dependency Injection

Let’s return to Film and its DTOs. The FilmResponse DTO differs from the Entity to a significant extent: instead of returning Sets of nested entities (Genre, FilmPerson), it returns a List of their names. In addition, we have to calculate a mean rating for the film based on the ratings provided as a Set in our entity.

NewFilmRequest is even more complicated: it includes only ids of Entities, which we have to look up in the repository and add their references to the Film object to be able to save it. It means that we have to inject the repository instance to the Mapper.

Sounds like a lot of huffing and puffing to do to configure the mapper, but actually, nothing MapStruct can’t handle.

To be able to use Spring CDI and IoC, we need to enhance our Mapper in the following way:

  • Change interface to abstract class. It will also enable us to customize the mapping methods;
  • Add the componentModel = “spring” configuration to the @Mapper annotation to turn our Mapper into Spring Bean and be able to inject it via @Autowired;
  • Remove the INSTANCE field because the Mapper is a normal Spring bean now and should be directly added to classes using it;
  • Inject the repository instance into the Mapper via @Autowired. Note that MapStruct doesn’t support constructor injection, so you have to perform either field injection, which is not recommended, or setter injection.

As a result, our FilmMapper class will look like this:

@Mapper(unmappedTargetPolicy = org.mapstruct.ReportingPolicy.IGNORE,
componentModel = "spring")
public abstract class FilmMapper {

protected ReferenceFinderRepository repository;
@Autowired
protected void setReferenceFinderRepository(ReferenceFinderRepository repository) {
this.repository = repository;
}

public FilmResponse mapToFilmResponse(Film film) {
return FilmResponse.builder()
.name(film.getName())
.description(film.getDescription())
.duration(film.getDuration())
.premiereDate(film.getPremiereDate())
.language(film.getLanguage().getName())
.mpaa(film.getMpaa().getName())
.genres(film.getGenres().stream().map(Genre::getName).toList())
.actors(film.getActors().stream().map(FilmPerson::getName).toList()).build();
}

public Film mapToFilm(NewFilmRequest filmRequest) {
Film.FilmBuilder film = Film.builder()
.name(filmRequest.name())
.description(filmRequest.description())
.premiereDate(filmRequest.premiereDate())
.duration(filmRequest.duration())
.mpaa(repository.getMpaaReference(filmRequest.mpaaId()));

film.genres(filmRequest.genreIds()
.stream()
.map(repository::getGenreReference)
.collect(Collectors.toSet()));

film.actors(filmRequest.actorIds()
.stream()
.map(repository::getFilmPersonReference)
.collect(Collectors.toSet()));

double meanRating = 0.0;
if (!film.getRatings().isEmpty()) {
meanRating = film.getRatings()
.stream()
.map(Rating::getPoints)
.mapToInt(Integer::intValue)
.summaryStatistics()
.getAverage();
}

rresponse.rating(round(meanRating, 2));

return film.build();
}
}

Summary

As you can see, using records and MapStruct to work with DTOs enable us to reduce boilerplate error-prone code. In addition, MapStruct is very flexible and can be used even for complex mapping cases. MapStruct can do a lot more: describing all its capabilities would be too much for a blog post. Refer to MapStruct documentation to learn more about its features.

Happy coding!

--

--

Catherine Edelveis

Java developer passionate about Spring Boot X: @cat_edelveis Find my other articles here: https://bell-sw.com/blog/