Зарегистрируйтесь, чтобы продолжить обучение

Преобразование DTO в сущность Spring Boot

Помимо преобразования в DTO, существует и обратная задача — преобразование DTO в Entity. Зачем это делать, если можно наполнять сущность напрямую?

Первая причина — это безопасность. Когда у нас есть API для создания или изменения сущности, обычно мы хотим дать возможность менять только часть свойств. Но если мы используем в @RequestBody нашу сущность напрямую, то у клиента API появляется возможность поменять любые свойства сущности:

@PutMapping("/users/{id}")
@ResponseStatus(HttpStatus.OK)
// Клиенты могут менять все свойства внутри пользователя
public UserDTO update(@RequestBody User user, @PathVariable Long id) {
    repository.save(user);
}

Мы не советуем использовать userData как сущность и сразу сохранять в базу — такой подход создает потенциальную опасность.

Вторая причина — схема данных. Со временем именование свойств может меняться и в базе данных, и в API — например, при внедрении новой версии. Разделение сущностей и DTO позволяет делать это независимо. DTO представляет внешний интерфейс для API. В свою очередь, сущности описывают внутреннюю модель данных.

Кроме того, существует еще несколько причин, которые мы разберем подробнее в других уроках:

  • Дополнительные преобразования данных перед тем, как они попадут в сущность — например, нормализация электронной почты.
  • Дополнительная валидация, которая может понадобиться в конкретном API. Хорошим примером служит подтверждение пароля. Подтверждение пароля не существует на уровне сущности, это вопрос проверки корректности входных данных.

Преобразование из сущности в DTO и наоборот обычно отличаются набором свойств. Например, в большинстве случаев идентификатор генерируется в базе данных — мы не хотим передавать его в API. При этом при возврате ответа в API мы хотим вернуть идентификатор среди остальных свойств. Поэтому есть смысл создавать разные 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 для каждого действия. Создание потребует три поля — slug, name и body. В вывод добавятся поля id и createdAt:

// Создание поста
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 PostDTO {
    private String id;
    private String slug;
    private String name;
    private String body;
    private LocalDate createdAt;
}

Реализуем создание и вывод. Вывод потребует преобразования только в DTO, а создание — оба преобразования (из сущности в DTO и наоборот):

package io.hexlet.spring.controller.api;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import io.hexlet.spring.dto.PostCreateDTO;
import io.hexlet.spring.dto.PostDTO;
import io.hexlet.spring.model.Post;
import io.hexlet.spring.exception.ResourceNotFoundException;
import io.hexlet.spring.repository.PostRepository;

@RestController
@RequestMapping("/api")
public class PostsController {
    @Autowired
    private PostRepository repository;

    @GetMapping("/posts/{id}")
    @ResponseStatus(HttpStatus.OK)
    public PostDTO show(@PathVariable Long id) {
        var post = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Not Found: " + id));
        var postDTO = toDTO(post); // Только в DTO
        return postDTO;
    }

    @PostMapping("/posts")
    @ResponseStatus(HttpStatus.CREATED)
    public PostDTO create(@RequestBody PostCreateDTO postData) {
        var post = toEntity(postData); // Сначала в Entity
        repository.save(post);
        var postDTO = toDTO(post); // Потом в DTO
        return postDTO;
    }

    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;
    }
}

В методе create() мы поменяли тип входных данных на PostCreateDTO. Уже внутри эти данные копируются в только что созданный объект post. После сохранения в базу данных мы снова выполняем преобразование из Post в PostDTO, чтобы сформировать тело ответа. На этом моменте проявляется разница между тем, что приходит на вход, и тем, что должно быть на выходе. Например, идентификатор появляется только после того, как мы выполняем сохранение в базу данных. Поэтому мы получаем такую цепочку: PostCreateDTO => Post => PostDTO.


Самостоятельная работа

Теперь мы разделим входные данные от моделей базы и обеспечим валидацию.

  1. Создаём DTO для входящих запросов

    • DTO должны содержать только необходимые для запроса поля.
    • Добавьте валидацию с помощью jakarta.validation аннотаций.

      Пример: PostCreateDTO
      import jakarta.validation.constraints.NotBlank;
      import jakarta.validation.constraints.Size;
      
      public class PostCreateDTO {
      
          @NotBlank
          @Size(min = 3, max = 100)
          private String title;
      
          @NotBlank
          @Size(min = 10)
          private String content;
      
          public String getTitle() {
              return title;
          }
      
          public void setTitle(String title) {
              this.title = title;
          }
      
          public String getContent() {
              return content;
          }
      
          public void setContent(String content) {
              this.content = content;
          }
      }
      
  2. Изменяем контроллер для использования DTO

    Пример: PostController (создание поста)
    @RestController
    @RequestMapping("/api/posts")
    public class PostController {
    
        private final PostRepository postRepository;
    
        public PostController(PostRepository postRepository) {
            this.postRepository = postRepository;
        }
    
        @PostMapping
        public ResponseEntity<PostDTO> createPost(@Valid @RequestBody PostCreateDTO dto) {
            var post = new Post();
            post.setTitle(dto.getTitle());
            post.setContent(dto.getContent());
            post.setPublished(true);
            post.setCreatedAt(LocalDateTime.now());
            post.setUpdatedAt(LocalDateTime.now());
    
            postRepository.save(post);
    
            var response = new PostDTO();
            response.setId(post.getId());
            response.setTitle(post.getTitle());
            response.setContent(post.getContent());
            response.setPublished(post.isPublished());
            response.setCreatedAt(post.getCreatedAt());
            response.setUpdatedAt(post.getUpdatedAt());
    
            return ResponseEntity.status(HttpStatus.CREATED).body(response);
        }
    }
    
  3. Обрабатываем ошибки валидации

    • Spring Boot сам проверяет @Valid параметры и выбрасывает MethodArgumentNotValidException.
    • Можно добавить глобальный обработчик, чтобы вернуть красивый JSON-ответ со статусом 422 Unprocessable Entity.

      Пример: GlobalExceptionHandler
      @RestControllerAdvice
      public class GlobalExceptionHandler {
      
          @ExceptionHandler(MethodArgumentNotValidException.class)
          public ResponseEntity<Map<String, String>> handleValidationErrors(MethodArgumentNotValidException ex) {
              Map<String, String> errors = new HashMap<>();
              ex.getBindingResult().getFieldErrors().forEach(error ->
                      errors.put(error.getField(), error.getDefaultMessage())
              );
              return ResponseEntity.unprocessableEntity().body(errors);
          }
      }
      

Проверяем работу

  • Отправьте POST-запрос без обязательных полей или с коротким текстом.
  • Убедитесь, что сервер вернёт JSON с описанием ошибок и статусом 422.

Итог: контроллеры больше не принимают сущности напрямую. Мы используем входные DTO с валидацией, а ошибки красиво обрабатываются.


Дополнительные материалы

  1. Аннотация @RequestBody

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff