Преобразование сущностей в DTO и обратно — это довольно утомительная операция с большим объемом однообразного кода. Например, такого:
// Импорты
@RestController
@RequestMapping("/api")
public class PostsController {
    // Здесь обработчики
    private PostDTO toDTO(Post post) {
        var dto = new PostDTO();
        dto.setId(post.getId());
        dto.setSlug(post.getSlug());
        dto.setName(post.getName());
        dto.setBody(post.getBody());
        dto.setCreatedAt(post.getCreatedAt());
        return dto;
    }
    private Post toEntity(PostCreateDTO postDTO) {
        var post = new Post();
        post.setSlug(postDTO.getSlug());
        post.setName(postDTO.getName());
        post.setBody(postDTO.getBody());
        return post;
    }
    private Post toEntity(PostUpdateDTO postDTO, Post post) {
        post.setName(postDTO.getName());
        post.setBody(postDTO.getBody());
        return post;
    }
}
Кто-то в своих проектах выбирает такой подход, но есть и альтернатива. Существуют библиотеки, позволяющие автоматизировать конвертацию в обе стороны. Самая популярная из них — это MapStruct. В этом уроке мы подключим ее и научимся использовать.
Установка
Для начала установим MapStruct:
implementation("org.mapstruct:mapstruct:1.5.5.Final")
annotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final")
Кроме обычной зависимости, MapStruct требует еще и установки обработчика аннотации. Такие обработчики выполняются во время компиляции и используются для генерации кода. Ниже мы увидим, зачем это делается и как работает.
Использование
MapStruct работает следующим образом. С его помощью создаются специальные мапперы под каждую сущность. Внутри них определяются правила конвертирования в DTO или из DTO в зависимости от потребностей. Дальше эти мапперы используются в нужных местах, сводя преобразования к одной строчке.
Разберем работу библиотеки на примере сущности Post, взятой из предыдущего урока. Чтобы было понятнее, мы начнем с конца. Сначала посмотрим, как использовать мапперы, а затем научимся их писать.
Сущность
Определение сущности выглядит так:
package io.hexlet.spring.model;
import static jakarta.persistence.GenerationType.IDENTITY;
import java.time.LocalDate;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name = "posts")
@EntityListeners(AuditingEntityListener.class)
public class Post {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;
    @Column(unique = true)
    private String slug;
    private String name;
    @Column(columnDefinition = "TEXT")
    private String body;
    @CreatedDate
    private LocalDate createdAt;
}
DTO
Для этой сущности реализуем три DTO для разных целей:
- Для создания сущности
- Для обновления сущности
- Для отображения сущности
Реализация выглядит так:
// Создание поста
package io.hexlet.spring.dto;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class PostCreateDTO {
    private String slug;
    private String name;
    private String body;
}
// Обновление поста
package io.hexlet.spring.dto;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class PostUpdateDTO {
    private String name;
    private String body;
}
// Вывод поста
package io.hexlet.spring.dto;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class PostDTO {
    private Long id;
    private String slug;
    private String name;
    private String body;
    private LocalDate createdAt;
}
Контроллер
В конце мы напишем маппер, а пока посмотрим, как изменится код контроллера с их использованием. Все преобразование сведется к вызову postMapper.map():
// Остальные импорты
import io.hexlet.spring.mapper.PostMapper;
@RestController
@RequestMapping("/api")
public class PostsController {
    @Autowired
    private PostRepository repository;
    @Autowired
    private PostMapper postMapper;
    @PostMapping("/posts")
    @ResponseStatus(HttpStatus.CREATED)
    public PostDTO create(@RequestBody PostCreateDTO postData) {
        // Преобразование в сущность
        var post = postMapper.map(postData);
        repository.save(post);
        // Преобразование в DTO
        var postDTO = postMapper.map(post);
        return postDTO;
    }
    @PutMapping("/posts/{id}")
    @ResponseStatus(HttpStatus.OK)
    public PostDTO update(@RequestBody @Valid PostUpdateDTO postData, @PathVariable Long id) {
        var post = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Not Found"));
        postMapper.update(postData, post);
        repository.save(post);
        var postDTO = postMapper.map(post);
        return postDTO;
    }
    @GetMapping("/posts/{id}")
    @ResponseStatus(HttpStatus.OK)
    public PostDTO show(@PathVariable Long id) {
        var post = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Not Found: " + id));
        // Преобразование в DTO
        var postDTO = postMapper.map(post);
        return postDTO;
    }
}
Код контроллера стал предельно простым. Вместо ручного копирования данных здесь используется маппер PostMapper, который содержит:
- Метод update()
- Перегруженный метод map(), работающий сразу с тремя классами:- PostCreateDTO
- PostDTO
- Post
 
Мапперы
Перейдем к мапперам:
// src/main/java/io/hexlet/spring/mapper/PostMapper.java
package io.hexlet.spring.mapper;
import org.mapstruct.Mapper;
import org.mapstruct.MappingConstants;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.ReportingPolicy;
import io.hexlet.spring.dto.PostCreateDTO;
import io.hexlet.spring.dto.PostUpdateDTO;
import io.hexlet.spring.dto.PostDTO;
import io.hexlet.spring.model.Post;
@Mapper(
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
    componentModel = MappingConstants.ComponentModel.SPRING,
    unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public abstract class PostMapper {
    public abstract Post map(PostCreateDTO dto);
    public abstract PostDTO map(Post model);
    public abstract void update(PostUpdateDTO dto, @MappingTarget Post model);
}
Маппер — это абстрактный класс с абстрактными методами для конвертации одних объектов в другие. Класс должен быть помечен аннотацией @Mapper с минимально указанной опцией componentModel = MappingConstants.ComponentModel.SPRING. Расположение класса, название класса и методов не фиксированы — программисты сами определяют, как все это организовать. MapStruct не ограничивает нас в DTO, мы можем преобразовывать объекты любых классов.
В документации MapStruct показаны примеры с интерфейсом, а не абстрактным классом. Технически эта библиотека работает и с интерфейсами, и абстрактными классами. Использовать последние удобнее, потому что в абстрактные классы можно сделать инъекцию зависимостей, если это необходимо.
Во время компиляции происходит генерация конкретных мапперов. Посмотреть исходник этих классов можно в директории build/generated/sources/annotationProcessor/java/main/io/spring/mapper. Это очень упрощает отладку. Код маппера PostMapperImpl созданного на базе абстрактного класса PostMapper:
// PostMapperImpl.java
package io.hexlet.blog.mapper;
import io.hexlet.blog.dto.PostCreateDTO;
import io.hexlet.blog.dto.PostDTO;
import io.hexlet.blog.model.Post;
import io.hexlet.blog.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
// Автоматически помечается как компонент, что дает возможность внедрять как зависимость
@Component
public class PostMapperImpl extends PostMapper {
   @Override
   public Post map(PostCreateDTO dto) {
      if (dto == null) {
         return null;
      } else {
         Post post = new Post();
         post.setSlug(dto.getSlug());
         post.setName(dto.getName());
         post.setBody(dto.getBody());
         return post;
      }
   }
   @Override
   public PostDTO map(Post model) {
      if (model == null) {
         return null;
      } else {
         PostDTO postDTO = new PostDTO();
         postDTO.setId(model.getId());
         postDTO.setSlug(model.getSlug());
         postDTO.setName(model.getName());
         postDTO.setBody(model.getBody());
         postDTO.setCreatedAt(model.getCreatedAt());
         return postDTO;
      }
   }
    @Override
    public void update(PostUpdateDTO dto, Post model) {
        if ( dto == null ) {
            return;
        }
        model.setName(dto.getName());
        model.setBody(dto.getBody());
    }
}
MapStruct самостоятельно написал тот код, который мы до этого писали руками. Но как он это сделал? MapStruct сравнивает методы обоих классов и автоматически распознает те, что совпадают. Кроме этого, MapStruct автоматически пытается преобразовать типы, если они не совпадают. В большинстве случаев это работает автоматически, но там где нет, всегда есть возможность дописать правила конвертации и преобразования типов. Для примера представим, что поле name переименовали в title. Если нам нужно сохранить внешнее API без изменений, то мы можем определить правила преобразования в маппере:
package io.hexlet.spring.mapper;
import org.mapstruct.Mapper;
import org.mapstruct.MappingConstants;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.ReportingPolicy;
import io.hexlet.spring.dto.PostCreateDTO;
import io.hexlet.spring.dto.PostDTO;
import io.hexlet.spring.model.Post;
@Mapper(
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
    componentModel = MappingConstants.ComponentModel.SPRING,
    unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public abstract class PostMapper {
    @Mapping(target = "title", source = "name")
    public abstract Post map(PostCreateDTO dto);
    @Mapping(target = "title", source = "name")
    public abstract void update(PostUpdateDTO dto, @MappingTarget Post model);
    @Mapping(target = "name", source = "title")
    public abstract PostDTO map(Post model);
}
Аннотация @Mapping позволяет указать правила преобразования свойств. Самый частый случай — это когда имя свойства в исходном объекте не совпадает с целевым. В аннотации source указывает на объект, который передается как параметр, target — это объект, возвращаемый из метода.
Самостоятельная работа
Когда количество DTO и сущностей растет, ручное копирование полей быстро превращается в источник ошибок и дублирования кода. Чтобы упростить работу, можно использовать библиотеку MapStruct, которая автоматически генерирует мапперы на этапе компиляции.
В этом задании вы замените ручную конвертацию на использование MapStruct.
- Добавьте зависимости в Gradle В build.gradle.kts подключите MapStruct и процессор аннотаций: - Пример: build.gradle.kts- dependencies { implementation("org.mapstruct:mapstruct:1.5.5.Final") annotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final") testAnnotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final") }- Также убедитесь, что у вас подключен - kapt(если используете Kotlin в сборке):- Пример: plugins- plugins { id("org.springframework.boot") version "3.3.0" id("io.spring.dependency-management") version "1.1.5" id("java") kotlin("kapt") version "1.9.25" // важно для генерации }
- Создайте интерфейс маппера - Для сущности - Postи DTO создайте интерфейс:- Пример: PostMapper- import org.mapstruct.Mapper; import org.mapstruct.MappingTarget; @Mapper(componentModel = "spring") public interface PostMapper { PostDTO toDTO(Post post); Post toEntity(PostCreateDTO dto); void updateEntityFromDTO(PostUpdateDTO dto, @MappingTarget Post post); }
- componentModel = "spring"позволяет внедрять маппер как Spring-бин.
- @MappingTargetиспользуется для обновления существующей сущности.
 
- Используйте маппер в контроллере - Пример: PostController- @RestController @RequestMapping("/posts") public class PostController { private final PostRepository postRepository; private final PostMapper postMapper; public PostController(PostRepository postRepository, PostMapper postMapper) { this.postRepository = postRepository; this.postMapper = postMapper; } @PostMapping public ResponseEntity<PostDTO> create(@Valid @RequestBody PostCreateDTO dto) { Post post = postMapper.toEntity(dto); postRepository.save(post); return ResponseEntity.ok(postMapper.toDTO(post)); } @PutMapping("/{id}") public ResponseEntity<PostDTO> update(@PathVariable Long id, @Valid @RequestBody PostUpdateDTO dto) { Post post = postRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); postMapper.updateEntityFromDTO(dto, post); postRepository.save(post); return ResponseEntity.ok(postMapper.toDTO(post)); } }
- Добавьте аналогичный маппер для пользователей - Создайте UserMapper.
- Определите методы для преобразования UserCreateDTO,UserUpdateDTO,UserDTO.
- Подключите в UserController.
 
- Создайте 
Итог
Теперь в проекте используется MapStruct вместо ручного копирования полей. Это дало:
- Автоматическую генерацию кода конвертации на этапе компиляции.
- Чистый и лаконичный код контроллеров и сервисов.
- Простое добавление новых DTO и полей без дублирования логики.
Дополнительные материалы
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.