JavaRush /Курсы /Spring Data JPA /Entity и DTO: роли и границы

Entity и DTO: роли и границы

Spring Data JPA
6 уровень , 3 лекция
Открыта

1. Разделение entity и DTO

Почти каждый новичок в какой-то момент предлагает гениальную оптимизацию: “Давайте не будем плодить классы. Вот есть Product — будем и в базу его сохранять, и между слоями передавать, и наружу отдавать. Один класс — одна любовь”. Звучит красиво, как “одна кнопка — деплой в прод”. Но именно это “упрощение” часто превращает проект в хрупкий ком слоёв, где любое изменение начинает ломать всё сразу.

Чтобы увидеть проблему, представим две потребности, которые выглядят похожими, но на самом деле разные.

Первая потребность: нам нужно хранить товар как устойчивую запись данных. Это про таблицу, колонки, ограничения, идентичность, правила JPA, жизненный цикл объекта относительно persistence-контекста. Вторая потребность: нам нужно передать данные о товаре куда-то дальше — например, в другой слой приложения, в тест, в лог, в будущий web-адаптер или просто “в ответ” некоторой операции. Это уже про форму данных для передачи: какие поля нужны, в каком виде, с какими гарантиями (immutable/mutable), и что можно безопасно показывать.

Если эти две потребности смешать в одном классе, вы получаете класс, который одновременно должен удовлетворять требованиям JPA и требованиям передачи данных. А эти требования… скажем так, не очень дружат. Это как пытаться сделать один инструмент, который одновременно и молоток, и микроскоп. Забивать гвозди микроскопом можно, но потом удивляться трещинам в линзах — тоже можно.

2. Entity: модель хранения, не DTO

Entity в JPA — это не просто “класс, который можно сохранить”. Это объект, который живёт по правилам persistence-модели: у него есть идентичность, он связан со схемой БД, он подчиняется ограничениям ORM и может находиться в разных состояниях (вспоминаем transient/managed/detached/removed). Поэтому у entity появляются требования и “сюрпризы”, которые очень неудобно таскать по всему приложению, если вы относитесь к ней как к обычному переносчику данных.

Чтобы почувствовать разницу, достаточно сравнить, как мы думаем о Product как об entity и как о DTO.

Entity — это “объект, представляющий запись в БД”. В нём нас интересует, что и как хранится, какие поля обязательны, какие уникальны, какие типы колонок мы выбираем, как выглядит идентичность и какой бизнес-ключ стабилен. DTO — это “сообщение”, которое мы передаём: оно не обязано повторять схему БД и не обязано быть пригодным для сохранения.

Вот маленький (не полный) кусочек entity Product, просто чтобы напомнить её характер:

package com.example.shopdatajpa.catalog.entity;

import java.math.BigDecimal;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity // Помечаем класс как JPA-entity: это уже "модель хранения", а не просто POJO.
public class Product {
    @Id // Идентичность сущности в терминах ORM/БД.
    private Long id;

    // Поля ниже — это то, что обычно маппится на колонки таблицы.
    // Геттеры/сеттеры и остальные аннотации намеренно опущены: это фрагмент для демонстрации роли.
    private String sku;
    private String name;
    private BigDecimal price;
}

Даже в таком коротком фрагменте видно: entity “помнит” про JPA (@Entity, @Id). И это не декоративные наклейки. Это сигнал: “со мной работает ORM, у меня есть правила”.

Теперь представьте, что вы начинаете использовать этот же класс как DTO и тащите его “наружу” (в другие слои). В какой-то момент вам захочется добавить поле “красивое отображение цены”, например priceLabel = "1999.00 USD" или “короткое описание для каталога”. И вот тут начинается медленное, но верное размывание границ: в entity начинают попадать поля не про хранение, а про представление. Вроде мелочь, но именно из таких мелочей вырастает “DTO-монстр”, который и entity уже плохая, и DTO плохое.

Отдельно важно помнить: мы уже знаем, что entity бывают в разных состояниях. DTO как идея “просто данные” не имеет состояния managed/detached. Он либо есть, либо нет. А entity может быть “под наблюдением” ORM или уже нет. Если вы отдаёте entity туда, где про это не думают, вы резко повышаете шанс получить неожиданные эффекты: объект начинают менять “просто так”, не понимая, что это может быть не просто объект, а часть persistence-механики.

3. DTO: транспортные данные и record

DTO (Data Transfer Object) — это честный “контейнер для передачи данных”. Он не обязан быть связан с таблицей. Он не обязан иметь @Entity. Он не обязан иметь бизнес-ключ или следовать правилам идентичности entity. Его цель проще: выразить форму данных, которая нужна на границе между слоями (или между модулем и “внешним миром”), и сделать это максимально явно.

Очень часто DTO удобно делать immutable, чтобы его нельзя было случайно “подправить где-нибудь по дороге”. И здесь Java records — почти подарок (простите за каламбур): они короткие, читаемые и прямо говорят: “это данные”.

Например, DTO для показа товара “наружу” (не важно, наружу в web или наружу в другой слой) может выглядеть так:

package com.example.shopdatajpa.catalog.dto;

import java.math.BigDecimal;

public record ProductDto(
        Long id,         // Идентификатор: нужен для ссылок/операций, но не обязан быть в каждом DTO.
        String sku,      // Артикул/код товара — то, что хотим передавать между слоями.
        String name,     // Имя для отображения/логики use case.
        BigDecimal price // Цена в "чистом" виде, без JPA-деталей и без форматирования под UI.
) {
    // Record по умолчанию immutable: меньше шансов "случайно поменять" DTO по дороге.
}

Обратите внимание на две вещи.

Во‑первых, DTO может включать только те поля, которые нам нужны в конкретном сценарии. Например, если в каком-то месте нам не нужен id (скажем, при создании товара), мы делаем другой DTO, без id. Это нормально.

Во‑вторых, DTO не тащит за собой JPA-роль. Здесь нет аннотаций ORM, нет требований к конструктору “для Hibernate”, нет тонкой семантики идентичности. Это просто данные.

Теперь сравним это с entity ещё раз не кодом, а смыслом:

Entity отвечает за корректность persistence-модели. DTO отвечает за удобную передачу данных. Эти роли разные, и пытаться “склеить” их — всё равно что заставить повара быть ещё и официантом, и бухгалтером. Бывают маленькие кафе, где так и происходит, но потом не удивляйтесь, что суп солёный, счёт неправильный, а настроение у повара загадочное.

4. Граница: маппинг и конвертация

Слова “entity не должна быть DTO” звучат красиво, но начинаются вопросы в духе “Окей. А как жить-то?”. Ответ скучный, но рабочий: нужен явный маппинг. То есть кто-то в коде должен взять entity и превратить её в DTO, и наоборот. И этот “кто-то” должен находиться в месте, где граница действительно проходит, а не размазываться “везде понемножку”.

Самая частая ошибка новичка — сделать конвертацию “где пришлось”. В одном месте toDto живёт в entity, в другом — в сервисе, в третьем — в контроллере (которого у нас сейчас вообще нет), а в четвёртом — в тесте копипастой. Потом вы меняете поле (например, переименовываете name в title) и идёте собирать урожай компиляционных ошибок по всему проекту.

В учебном проекте лучше выбрать простой и понятный стиль: отдельный маппер-класс рядом с feature-пакетом. Например, ProductMapper в catalog-контуре. И пусть он делает две операции: toDto(Product) и fromRequest(CreateProductRequest) (или аналогично). Без фреймворков, без магии, просто Java-код.

Минимальный маппер может выглядеть так:

package com.example.shopdatajpa.catalog.dto;

import com.example.shopdatajpa.catalog.entity.Product;

public final class ProductMapper {

    private ProductMapper() {
        // Запрещаем создание экземпляров: это утилитный класс со статическими методами.
    }

    public static ProductDto toDto(Product product) {
        // Явная точка границы: из entity (модель хранения) делаем DTO (модель передачи).
        // Здесь удобно контролировать, какие поля уходят "наружу".
        return new ProductDto(
                product.getId(),
                product.getSku(),
                product.getName(),
                product.getPrice()
        );
    }
}

Код короткий и прямолинейный. Именно это нам сейчас и нужно: чтобы граница была явной и видимой. Когда граница видима, архитектура перестаёт быть “договорённостью в головах” и становится частью кода.

А вот обратное направление — DTO “на вход” (например, запрос на создание товара). Мы можем сделать отдельный DTO, который отражает именно форму входных данных. Например:

package com.example.shopdatajpa.catalog.dto;

import java.math.BigDecimal;
import com.example.shopdatajpa.catalog.entity.ProductStatus;

public record CreateProductRequest(
        String sku,          // То, что вводит внешний мир (клиент/тест/адаптер).
        String name,         // Входные данные для создания, не "снимок сущности".
        BigDecimal price,    // Входное значение цены (дальше его может проверять доменная логика).
        ProductStatus status // Пример поля, которое может быть частью бизнес-сценария создания.
) {
    // Важно: здесь нет id — он обычно генерируется на нашей стороне (БД/приложение).
}

И конвертацию в entity:

package com.example.shopdatajpa.catalog.dto;

import com.example.shopdatajpa.catalog.entity.Product;

public final class ProductMapper {

    public static Product fromRequest(CreateProductRequest req) {
        // Конвертация "внутрь": внешний DTO превращаем в entity для сохранения.
        // Здесь удобно делать нормализацию/подготовку данных (в реальном проекте часто +валидация).
        Product p = new Product();
        p.setSku(req.sku());
        p.setName(req.name());
        p.setPrice(req.price());
        p.setStatus(req.status());
        return p;
    }
}

Заметьте важный психологический эффект: CreateProductRequest вообще не пытается быть “почти entity”. У него нет id. И это правильно: id не должен приходить “снаружи” как обязательное поле при создании, если мы сами генерируем его в базе. То есть DTO помогает нам удержать модель от нелепостей.

5. DTO в mini-shop вокруг Product

Если смотреть на mini-shop как на живой проект, то почти сразу выяснится, что “DTO один на всё” тоже не работает. Но это уже хороший знак: значит, мы начали мыслить use case-ами. Для разных сценариев нужны разные формы данных.

Для создания товара нужен один набор полей, где нет id, зато есть поля, необходимые для создания. Для “показа товара в списке каталога” нужен другой набор: возможно, короткое имя, цена и статус. Для “детальной карточки товара” (которую мы скоро усилим через ProductDetails) нужна более богатая форма, где есть описание, производитель, гарантия и так далее.

И вот здесь очень важная дисциплина: не пытайтесь решить это добавлением новых полей в entity “чтобы всем было удобно”. Потому что так вы снова превращаете entity в универсальный объект. Правильный ход — создавать DTO под конкретные границы.

Например, “строка каталога” может быть такой:

package com.example.shopdatajpa.catalog.dto;

import java.math.BigDecimal;

public record ProductCatalogRow(
        Long id,         // Нужен, чтобы открыть карточку/выполнить действие над конкретным товаром.
        String sku,      // Обычно показывается в списке, помогает искать/идентифицировать товар.
        String name,     // Короткое имя для списка.
        BigDecimal price // Цена для списка (без деталей карточки).
) {
}

А “детальная карточка” может быть другой (пока без ProductDetails, просто идеей формы):

package com.example.shopdatajpa.catalog.dto;

import java.math.BigDecimal;

public record ProductCardDto(
        Long id,             // Идентификатор товара.
        String sku,          // Артикул/код.
        String name,         // Полное имя для карточки.
        BigDecimal price,    // Цена.
        String description   // Детали карточки: это "форма данных", а не обязательная часть persistence-модели.
) {
}

Да, это “ещё классы”. Но они не плодятся бесконтрольно. Они отражают реальные границы: один use case — одна форма данных. И самое приятное: если завтра карточке нужно добавить поле, вы не обязаны менять entity. Вы меняете DTO и маппинг. Persistence-модель остаётся стабильной и не превращается в “конструктор для любых нужд UI”.

Теперь маленькая связка с сегодняшними темами про @Embeddable. Частая ловушка: “Раз DeliveryAddress — отдельный класс, значит, его можно и как DTO использовать!”. Формально, конечно, вы можете где-то его передать. Но концептуально это всё ещё часть persistence-модели. В нём могут появиться JPA-аннотации, правила колонок, ограничения. Поэтому безопаснее и яснее разделять: DeliveryAddress как часть entity — это одно, DeliveryAddressDto как транспортная форма — другое. Пусть иногда они совпадают по полям, но это совпадение должно быть осознанным, а не случайным.

И да, вы заметите, что мы постоянно говорим “наружу”, хотя полноценного web-слоя в нашем data-layer-first проекте сейчас нет. Это нормально. Мы строим привычку: не смешивать persistence-модель с моделью передачи данных. Даже если “наружу” пока только тесты, “наружу” всё равно существует. А в реальной работе “наружу” почти всегда есть.

6. Типичные ошибки при смешивании entity и DTO

Ошибка №1: “Один класс на все слои, и жизнь станет проще”.
Такое решение выглядит как экономия времени, но на практике это кредит под высокий процент. Entity начинает обрастать полями “для отображения”, “для формы”, “для удобной сериализации”. В итоге вы получаете класс, который и для БД неудобен, и для передачи данных непредсказуем. Обычно это заканчивается тем, что вы всё равно вводите DTO, но уже поздно и больно.

Ошибка №2: В DTO протаскивают JPA-аннотации “на всякий случай”.
Иногда кто-то думает: “Ну DTO же похож на entity, пусть он тоже будет @Entity, вдруг пригодится”. Это как носить каску в душе: может, вы и защищены, но странно и неудобно. DTO должен оставаться чистой транспортной формой. Если ему нужны аннотации, то чаще всего это аннотации сериализации или валидации, а не ORM. Но даже их стоит добавлять осторожно, чтобы не начать снова смешивать слои.

Ошибка №3: Entity выдают наружу, а потом удивляются “странным изменениям”.
Entity — это объект с жизненным циклом, и мы уже познакомились с идеей managed/detached. Когда entity уходит “наружу”, в слои, где про это не думают, её начинают менять “как обычный объект”. Потом возникает удивление: почему данные ведут себя не так, почему объект “то работает, то нет”. Даже без глубокого знания Hibernate здесь полезно правило гигиены: entity — внутренняя модель data-layer, не универсальный носитель данных.

Ошибка №4: Маппинг размазан по коду, и каждое место делает “как получится”.
Сегодня вы написали toDto в сервисе, завтра — в тесте, послезавтра — “быстренько в контроллере”. Через месяц вы добавляете поле и ловите десяток несовпадающих форматов. Лучше иметь одно-две явные точки конвертации (мапперы), пусть даже простые и ручные. Это скучно, но это то самое скучное, на котором держится поддерживаемость.

Ошибка №5: DTO делают “как entity, только без @Entity”, не задавая вопрос “зачем он вообще нужен?”.
DTO — это не ритуал. Он нужен, когда меняется роль объекта: хранение против передачи. Если DTO копирует entity один-в-один и больше ничего не делает, это не всегда плохо, но это должно быть осознанно. Если DTO появляется просто потому, что “так принято”, вы рискуете получить кучу классов без смысла. Хороший DTO всегда отвечает на вопрос: “какой use case его требует и какую границу он оформляет?”.

1
Задача
Spring Data JPA, 6 уровень, 3 лекция
Недоступна
Явный маппинг `Product -> ProductDto`
Явный маппинг `Product -> ProductDto`
1
Задача
Spring Data JPA, 6 уровень, 3 лекция
Недоступна
Плоский DTO из entity с embedded-адресом
Плоский DTO из entity с embedded-адресом
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ