DTO, VO и Entity — это не про «красивые классы». Это про то, чтобы данные не превращались в кашу.
Если вы хоть раз слышали на ревью «зачем тут DTO, клади Entity прямо в ответ контроллера» или, наоборот, «почему у тебя доменный объект утёк в REST-контракт» — вы в курсе, что спор про DTO, VO и Entity тянется годами. И почти всегда стороны говорят о разном, потому что не договорились о словах.
Три аббревиатуры, которые все употребляют и половина путает. Давайте расставим их по местам так, чтобы в следующий раз вы не просто кивали, а могли объяснить коллеге, чем OrderResponse отличается от Order и почему Money не должен быть double.
Если общий контекст моделирования домена пока плавает, загляните в разбор bounded context, aggregate и ubiquitous language — там база, на которую всё это опирается.
Почему эти три понятия всё время смешивают
Корень путаницы в том, что на первый взгляд все трое выглядят одинаково: обычный класс с полями. Класс Order с полями id, status, total — это Entity? Или DTO? Или вообще VO? Если не знать контекст, правильный ответ «зависит».
Второй источник хаоса — фреймворки. Hibernate, JPA, Entity Framework подсовывают слово «entity» для табличных объектов, а про «DTO» и «VO» вообще молчат. Разработчик растёт на ORM-примерах и честно считает, что сущность — это строка в базе данных. А потом в домене появляется настоящий Entity, и всё ломается.
Третий — перегиб в другую сторону. Команда заводит DTO на каждый чих, плодит двадцать конвертеров и на ревью начинает тонуть в OrderRequest, OrderRequestDto, OrderCommand, OrderDomain, OrderEntity, OrderResponse. Отсюда и фраза «мы потом перепишем».
Чтобы разобраться, нужно ответить на один вопрос про каждый объект: зачем он существует. Идентичность, перенос данных или удобство хранения — это три разные цели.
Entity: объект с паспортом
Entity (сущность) — это объект, у которого есть идентичность. Не важно, что у него внутри, — важно, что это «вот этот конкретный» объект, отличимый от любого другого.
Entity — объект, определяемый своей идентичностью, а не набором полей. Два заказа с одинаковой суммой и одинаковым статусом — всё равно два разных заказа, потому что у них разные
id.
Сравните: два заказа по 1500 рублей с одинаковым статусом — это разные заказы. Заказ #12345 и заказ #12346. А вот два адреса «Москва, Тверская, 1» — это, по сути, один и тот же адрес. Чувствуете разницу? Первое — про «кто», второе — про «что».
Entity почти всегда обладает жизненным циклом. Он создаётся, меняет статус, обрастает событиями, когда-нибудь архивируется. Сущность — это то, что вы можете попросить «обновить», и система поймёт, о чём речь, по идентификатору.
Признаки Entity
- Есть поле-идентификатор (
id,orderId,customerId), которое не меняется на протяжении жизни объекта. - Объект мутируем — его состояние меняется со временем (
statusсCREATEDнаSHIPPED). - Два объекта с одинаковыми полями, но разными
id— разные объекты. - В DDD сущности часто становятся агрегатами или их корнями.
В классическом DDD агрегат — это частный случай Entity с более строгими правилами: у него есть корень, через который проходят все изменения. Сущность — понятие шире, агрегат — дисциплина поверх неё.
Value Object: важны значения, а не личность
Value Object (объект-значение) — полная противоположность. Ему всё равно, «кто» он, важно только «что» внутри. Два объекта-значения с одинаковыми полями считаются равными, и это нормально.
Value Object — неизменяемый объект без собственной идентичности, который сравнивается по значениям полей, а не по ссылке или
id.
Типичные примеры: Money, Address, Email, PhoneNumber, DateRange. Сумма в 100 рублей и ещё одна сумма в 100 рублей — это один и тот же Money. Вам не нужны два разных «объекта ста рублей», чтобы посчитать итог по корзине.
Ключевое свойство VO — неизменяемость (immutability). Его не «меняют», а заменяют на новый. Добавили 50 рублей к сотне — получили новый Money(150), старый Money(100) никуда не делся. Это даёт колоссальное упрощение: объект-значение можно свободно передавать между потоками, кешировать и сравнивать без страха, что кто-то его подпортит.
Признаки Value Object
- Нет идентификатора — или, точнее, идентификатором является весь набор полей.
- Неизменяем: вместо мутации создаём новый экземпляр.
- Равенство определяется по содержимому (
equals/hashCodeпереопределены). - Несёт полезную доменную логику:
Money.add(),Email.isValid(),DateRange.overlaps().
Примечательно, что VO — это именно доменное понятие. Если у вас просто «структура с двумя полями, чтобы не таскать их по отдельности», но без инвариантов и без смысла — это ещё не Value Object, а просто удобная группировка. Разница в намерении.
DTO: курьер, который таскает данные между слоями
DTO (Data Transfer Object) — это объект, единственная задача которого — переносить данные из одного места в другое. Никакой бизнес-логики, никаких инвариантов. Просто мешок полей.
DTO — объект без поведения, предназначенный для передачи данных через границы: между процессом и сетью, между слоями приложения, между сервисами.
Термин придумал Мартин Фаулер ещё в начале нулевых: тогда передача «настоящих» объектов по сети (через RMI, EJB) была дорогой, и появилась идея гонять по проводу лёгкие структуры с данными. С тех пор суть не изменилась — DTO существует на границах.
Главное свойство DTO: у него нет поведения. Нет методов с логикой, нет инвариантов. Он может иметь геттеры/сеттеры (или быть простым record’ом), но не должен уметь «рассчитать скидку» или «проверить корректность». Всё это — работа домена.
Чаще всего DTO живут на стыке: входной CreateOrderRequest, который прилетает из REST-контроллера, и выходной OrderResponse, который улетает клиенту в JSON. Между ними — доменные объекты. DTO — это упаковка, а не содержимое.
Где что живёт: слои архитектуры
Теперь самое важное — карта. Вопрос «куда класть» отвечает архитектура слоёв. Каждому типу объекта — свой слой, и перепутать их — значит устроить протечку.
На схеме виден полный цикл. DTO (жёлтые) живут только на границе — это CreateOrderRequest на входе и OrderResponse на выходе. Entity и VO (зелёные и фиолетовые) — внутри домена, там и происходит вся работа. ORM-сущность (серая) — отдельный технический класс, который знает, как лечь в таблицу. Конвертер (серый) таскает данные между мирами, но сам ничего не решает.
Обратите внимание на двойную границу: доменный Order никогда не уходит в контроллер напрямую и никогда не трогает базу. Снаружи от него — DTO, снизу — ORM-маппинг. Так домен остаётся чистым: ему всё равно, пришёл запрос из REST, gRPC или очереди, и всё равно, хранится он в PostgreSQL или в MongoDB.
Entity vs VO vs DTO: таблица сравнения
Чтобы окончательно расставить точки, соберём всё в одну таблицу.
| Критерий | Entity | Value Object | DTO |
|---|---|---|---|
| Идентичность | Есть, по id | Нет, важны значения | Нет, это просто контейнер |
| Изменяемость | Мутируем | Неизменяем | Мутируем, но без логики |
| Сравнение | По идентификатору | По значениям полей | Обычно не сравнивается |
| Поведение | Бизнес-логика | Инварианты и доменные операции | Только поля и геттеры/сеттеры |
| Где живёт | Доменный слой | Доменный слой | Границы слоёв и сети |
| Пример | Order, Customer | Money, Address, Email | CreateOrderRequest, OrderResponse |
| Связь с БД | Через репозиторий | Обычно вложен в Entity | Никакой |
Тут же удобно увидеть и главную ловушку: Entity и VO — доменные понятия, они описывают предметную область. DTO — техническое, оно описывает транспорт. Смешивать их так же странно, как перевозить книги в холодильнике: вроде довезёт, но зачем.
Вот наглядная иллюстрация разницы в логике сравнения:
Два заказа с одинаковыми полями — разные сущности, потому что id различаются. Два адреса с одинаковыми значениями — один и тот же VO. А два DTO вообще не сравнивают: они существуют, чтобы донести данные, а не чтобы участвовать в логике.
Примеры на Java, Kotlin и TypeScript
Теория без кода не закрепляется. Пройдёмся по одному сценарию в трёх языках.
Entity и Value Object на Java
Современная Java с records сильно упростила VO:
// Value Object — record даёт неизменяемость и equals/hashCode из коробкиpublic record Money(BigDecimal amount, Currency currency) { public Money add(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("Валюты не совпадают"); } return new Money(amount.add(other.amount), currency); }}
// Entity — обычный класс с идентификатором и жизненным цикломpublic class Order { private final Long id; private OrderStatus status; private Money total;
public void applyDiscount(Money discount) { // инвариант живёт в домене this.total = this.total.add(discount.negate()); }}Обратите внимание: Money сам знает, как себя складывать и что валюты должны совпадать. Order — мутируемая сущность с id. Никто из них не знает про JSON и базу данных.
Value Object на Kotlin
Kotlin делает VO почти бесплатным:
data class Address( val city: String, val street: String, val zip: String) // data class = equals по полям + copy() для замены
data class Money(val amount: BigDecimal, val currency: String) { operator fun plus(other: Money): Money { require(currency == other.currency) { "Валюты не совпадают" } return Money(amount + other.amount, currency) }}data class автоматически даёт сравнение по значениям — ровно то, что нужно VO. А copy() подчёркивает неизменяемость: не «измени поле», а «создай новый с другим полем».
DTO на TypeScript
А вот DTO в TypeScript — это максимум интерфейс с аннотациями для валидации:
export interface CreateOrderRequest { customerId: string; items: Array<{ productId: string; quantity: number }>; deliveryAddress: AddressDto;}
export interface OrderResponse { orderId: string; status: 'CREATED' | 'PAID' | 'SHIPPED'; total: number;}Никаких методов, никакой логики. DTO описывает форму данных на границе — и всё. Если в OrderResponse появится метод calculateSomething(), вы делаете что-то не то.
Как не превратить DTO в объект-монстр
Это самая частая и самая грустная ошибка. Начинается невинно: «зачем плодить классы, вернём Order прямо из контроллера». Через полгода у Order появляется поле createdAtFormatted, чтобы угодить одному вью, потом аннотация @JsonIgnore на паре полей, потом ленивые связи, которые ломаются на сериализации. Домен начинает обслуживать транспорт.
Вот типичные грабли и что с ними делать.
1. Entity в REST-ответе
Вытаскивать доменный объект наружу — это как отдавать клиенту ключ от квартиры вместе с заказом пиццы. Домен начинает зависеть от формата ответа, а любое изменение API тянет за собой изменение модели.
Правило простое: домен наружу не ходит. На границе — всегда DTO. Домен живёт внутри, DTO — снаружи, а конвертер в прикладном слое перекладывает данные туда и обратно.
2. Один DTO на все случаи жизни
Противоположная крайность — один UserDto на вход, выход, список и карточку. Для списка нужны три поля, для карточки — двадцать, и все тащат один и тот же класс. Выходит «швейцарский нож», в котором половина полей вечно пустая.
Лучше заводить DTO под конкретный сценарий: UserListItem, UserCard, UpdateUserRequest. Да, классов больше. Зато каждый понятен и не тянет лишнего.
3. DTO с бизнес-логикой
Если в DTO завелись методы, которые «считают» или «проверяют», — это уже не DTO. Логика должна быть в домене. DTO может валидировать форму данных (поле не пустое), но не решать бизнес-задачи (скидка не превышает 30%). Первое — граница, второе — домен.
4. ORM-сущность как доменный Entity
Самая коварная ловушка, особенно в JPA/Hibernate. Табличная сущность с аннотациями @Entity, @Table, @ManyToOne — это не доменный Entity, это отражение структуры базы. Если вы натягиваете её на домен, домен становится заложником схемы БД: каждое изменение таблицы ломает бизнес-логику.
Правильный подход — разделять. Доменный Order со своей логикой и отдельный OrderTable (или даже OrderJpaEntity), который репозиторий маппит туда и обратно. Да, маппинг — это лишний код. Но он покупает вам свободу менять схему хранения, не трогая домен, и наоборот.
Когда всё это лишнее
Честность требует признать: не каждому проекту нужны три слоя объектов. Если вы пишете одностраничный прототип или простой CRUD-бэкенд для внутренней админки, заводить отдельные DTO, VO и конвертеры — значит платить налог сложностью за то, что вам не нужно.
Простое правило отсечки: если доменной логики нет, доменного слоя тоже нет. Нет инвариантов, нет правил, данные просто пишутся в базу и читаются обратно — тогда Entity, VO и DTO сливаются в один класс, и это нормально. Проблемы начинаются тогда, когда логика появляется, а разделение — нет.
Похожий разговор про чтение и запись затеян в статье про CQRS — там идея «разделяй модели под разные задачи» доведена до уровня архитектуры.
Заключение
DTO, VO и Entity — это не синонимы и не «вкусовщина». Это три разных ответа на три разных вопроса. Entity отвечает на «кто это» и живёт по идентификатору. Value Object отвечает на «что это» и живёт по значению. DTO отвечает на «как передать» и живёт на границе.
Запомнить проще через метафору. Entity — это человек с паспортом: два тёзки с одинаковым именем — всё равно два человека. Value Object — это купюра: две купюры по сто рублей взаимозаменяемы, никому не важно, какая именно у вас в кармане. DTO — это курьерская коробка: её содержимое имеет значение, а сама коробка — лишь способ доставки.
И держите в голове простое правило порядка: DTO — на границе, Entity и VO — в домене, ORM-маппинг — под доменом. Как только какой-то объект начинает ходить не в свой слой — ищите будущий баг.
P.S. Если после прочтения вам захотелось пойти и переименовать все *Dto классы в проекте — сначала прочитайте ещё раз раздел «Когда всё это лишнее». Возможно, ваш проект — как раз тот случай, где один класс честно справляется со всеми тремя ролями, и переписывание под «правильную архитектуру» принесёт больше вреда, чем пользы.