УРОК 7 / 30 0%

DTO и MapStruct

🎯

Цель урока

Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.

🧠

Теория · для собеса

1 Что такое DTO

DTO (Data Transfer Object) — объект для передачи данных между слоями. В Spring Boot чаще всего — между Controller и Service.

2 Почему НЕ отдавать Entity напрямую

1. Безопасность (пароли, токены)


@Entity
public class User {
    private String passwordHash;  // нельзя показать клиенту!
    private String email;
}

Если вернём User как JSON, пароль уйдёт клиенту (даже если поле private — Jackson сериализует через getter).

2. Версионирование API

Entity изменилась (добавили поле) — DTO остаётся прежним. Клиенты не сломались.

3. Lazy-loading exceptions

Entity имеет @OneToMany с LAZY. Отдаём Entity → Jackson вызывает getComments() → Hibernate тащит из БД → за пределами транзакцииLazyInitializationException.

4. Разные формы для разных endpoint'ов


TaskSummaryResponse  // для списка (без description)
TaskDetailResponse   // для одного (с description и comments)
3 MapStruct vs ручной маппинг

MapStruct — code-gen библиотека. На этапе компиляции создаёт реализацию маппера. Быстрее рефлексии (ModelMapper), безопасно (compile-time проверки).


@Mapper
public interface TaskMapper {
    TaskResponse toResponse(Task task);
    Task toEntity(CreateTaskRequest req);
}

Сгенерирует:


public class TaskMapperImpl implements TaskMapper {
    public TaskResponse toResponse(Task task) {
        if (task == null) return null;
        TaskResponse r = new TaskResponse();
        r.setId(task.getId());
        r.setTitle(task.getTitle());
        // ...
        return r;
    }
}

---

💻

Практика: улучшаем CRUD

  1. 1
    Зависимости
    
    dependencies {
        implementation("org.mapstruct:mapstruct:1.6.3")
        annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3")
    }
    
  2. 2
    DTO-классы (Java records)
    
    package com.taskflow.dto;
    
    import jakarta.validation.constraints.NotBlank;
    import jakarta.validation.constraints.Size;
    
    public record CreateTaskRequest(
        @NotBlank @Size(max = 200) String title,
        @Size(max = 5000) String description
    ) {}
    
    
    package com.taskflow.dto;
    
    public record UpdateTaskRequest(
        @NotBlank @Size(max = 200) String title,
        @Size(max = 5000) String description,
        boolean done
    ) {}
    
    
    package com.taskflow.dto;
    
    import java.time.Instant;
    
    public record TaskResponse(
        Long id,
        String title,
        String description,
        boolean done,
        Instant createdAt,
        Instant updatedAt
    ) {}
    
    
    package com.taskflow.dto;
    
    public record TaskSummaryResponse(
        Long id,
        String title,
        boolean done
    ) {}
    
  3. 3
    MapStruct-маппер
    
    package com.taskflow.mapper;
    
    import com.taskflow.dto.CreateTaskRequest;
    import com.taskflow.dto.TaskResponse;
    import com.taskflow.dto.TaskSummaryResponse;
    import com.taskflow.dto.UpdateTaskRequest;
    import com.taskflow.model.Task;
    import org.mapstruct.Mapper;
    import org.mapstruct.MappingTarget;
    
    @Mapper(componentModel = "spring")
    public interface TaskMapper {
        TaskResponse toResponse(Task task);
        TaskSummaryResponse toSummary(Task task);
    
        Task toEntity(CreateTaskRequest req);
    
        void updateEntityFromRequest(UpdateTaskRequest req, @MappingTarget Task task);
    }
    

    @MappingTarget — обновляет существующий объект, не создаёт новый (идеально для PUT/PATCH).

  4. 4
    Обновляем TaskService
    
    @Service
    public class TaskService {
        private final TaskRepository repo;
        private final TaskMapper mapper;
    
        public TaskService(TaskRepository repo, TaskMapper mapper) {
            this.repo = repo;
            this.mapper = mapper;
        }
    
        public List findAll() {
            return repo.findAll().stream().map(mapper::toResponse).toList();
        }
    
        public Optional findById(Long id) {
            return repo.findById(id).map(mapper::toResponse);
        }
    
        @Transactional
        public TaskResponse create(CreateTaskRequest req) {
            Task task = mapper.toEntity(req);
            return mapper.toResponse(repo.save(task));
        }
    
        @Transactional
        public Optional update(Long id, UpdateTaskRequest req) {
            return repo.findById(id).map(task -> {
                mapper.updateEntityFromRequest(req, task);
                return mapper.toResponse(repo.save(task));
            });
        }
    
        @Transactional
        public boolean delete(Long id) {
            if (!repo.existsById(id)) return false;
            repo.deleteById(id);
            return true;
        }
    }
    
  5. 5
    Обновляем TaskController
    
    @RestController
    @RequestMapping("/api/tasks")
    public class TaskController {
        private final TaskService service;
        public TaskController(TaskService service) { this.service = service; }
    
        @GetMapping
        public List findAll() { return service.findAll(); }
    
        @GetMapping("/{id}")
        public ResponseEntity findById(@PathVariable Long id) {
            return service.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
        }
    
        @PostMapping
        public ResponseEntity create(@Valid @RequestBody CreateTaskRequest req) {
            TaskResponse created = service.create(req);
            URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(created.id()).toUri();
            return ResponseEntity.created(location).body(created);
        }
    
        @PutMapping("/{id}")
        public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody UpdateTaskRequest req) {
            return service.update(id, req)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
        }
    
        @DeleteMapping("/{id}")
        public ResponseEntity delete(@PathVariable Long id) {
            return service.delete(id) ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
        }
    }
    

    ---

🎯

Зачем это на собесе

После урока ты должен уметь ответить на:

Безопасность: пароль/tокен остаются в Entity, никогда не уходят в API.
Производительность: TaskSummaryResponse без description — на 60% меньше payload для списка.
Версионирование: добавили поле в Task? API-контракт не сломан.
MapStruct на этапе компиляции — нет «маппинг сломался в рантайме».

5 вопросов на углубление

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
Почему record хорош для DTO, но плох для JPA Entity?

Разбор внутри: immutable DTO против требований JPA к сущностям.

2
MapStruct vs ModelMapper — в чём разница?

Разбор внутри: compile-time маппинг, скорость и ошибки на этапе сборки.

3
Почему DTO защищает от утечки лишних полей?

Разбор внутри: почему нельзя отдавать Entity напрямую наружу.

4
@JsonIgnore vs DTO — что выбрать?

Разбор внутри: быстрый патч против нормального API-контракта.

5
TaskSummaryResponse vs TaskResponse — зачем разные DTO?

Разбор внутри: список, детальная карточка, payload и безопасность.

Готов идти дальше?

Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.

🚀 Перейти к Уроку 8

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 6
🎉
Новый тир
Новый тир достигнут!