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Зависимости
dependencies { implementation("org.mapstruct:mapstruct:1.6.3") annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") } -
2DTO-классы (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 ) {} -
3MapStruct-маппер
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Обновляем
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 ListfindAll() { 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Обновляем
TaskController@RestController @RequestMapping("/api/tasks") public class TaskController { private final TaskService service; public TaskController(TaskService service) { this.service = service; } @GetMapping public ListfindAll() { 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(); } } ---
Зачем это на собесе
После урока ты должен уметь ответить на:
TaskSummaryResponse без description — на 60% меньше payload для списка.Task? API-контракт не сломан.5 вопросов на углубление
Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
Разбор внутри: immutable DTO против требований JPA к сущностям.
Разбор внутри: compile-time маппинг, скорость и ошибки на этапе сборки.
Разбор внутри: почему нельзя отдавать Entity напрямую наружу.
Разбор внутри: быстрый патч против нормального API-контракта.
Разбор внутри: список, детальная карточка, payload и безопасность.
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
🚀 Перейти к Уроку 8Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 6