Logo
Overview

DTO, VO, Entity: слои данных в архитектуре — что куда класть и почему

August 17, 2026
11 min read

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 — это упаковка, а не содержимое.

Где что живёт: слои архитектуры

Теперь самое важное — карта. Вопрос «куда класть» отвечает архитектура слоёв. Каждому типу объекта — свой слой, и перепутать их — значит устроить протечку.

100%
flowchart TD
  subgraph PRES["Слой представления / Controller"]
      REST["REST Controller"]
      IN["Входной DTO: CreateOrderRequest"]
      OUT["Выходной DTO: OrderResponse"]
  end

  subgraph APP["Слой приложения / Service"]
      SVC["OrderService"]
      MAPPER["Конвертер DTO ↔ Domain"]
  end

  subgraph DOM["Слой домена / Domain"]
      AGG["Entity: Order (Aggregate Root)"]
      VO_MONEY["VO: Money (amount + currency)"]
      VO_ADDR["VO: Address (city + street + zip)"]
      DOM_SVC["Domain Service: PricingService"]
  end

  subgraph INFRA["Слой инфраструктуры / Persistence"]
      REPO["Repository"]
      ORM["ORM Entity: OrderTable"]
      DB[("База данных")]
  end

  REST -->|"1. CreateOrderRequest (DTO)"| SVC
  SVC -->|"2. DTO → Domain"| MAPPER
  MAPPER -->|"3. new Order(...)"| AGG
  AGG -->|"содержит"| VO_MONEY
  AGG -->|"содержит"| VO_ADDR
  AGG -->|"вызывает"| DOM_SVC
  SVC -->|"4. save(Order)"| REPO
  REPO -->|"5. Domain → ORM Entity"| ORM
  ORM --> DB
  DB -->|"6. загрузка"| ORM
  ORM -->|"7. ORM → Domain"| AGG
  SVC -->|"8. Domain → DTO"| MAPPER
  MAPPER -->|"9. OrderResponse (DTO)"| OUT
  OUT -->|"10. JSON"| REST

  style REST fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style IN fill:#f0a500,stroke:#c88400,color:#fff
  style OUT fill:#f0a500,stroke:#c88400,color:#fff
  style SVC fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style MAPPER fill:#e0e0e0,stroke:#999,color:#333
  style AGG fill:#50c878,stroke:#3a9a5c,color:#fff
  style VO_MONEY fill:#7b68ee,stroke:#5a4db2,color:#fff
  style VO_ADDR fill:#7b68ee,stroke:#5a4db2,color:#fff
  style DOM_SVC fill:#50c878,stroke:#3a9a5c,color:#fff
  style REPO fill:#e0e0e0,stroke:#999,color:#333
  style ORM fill:#e0e0e0,stroke:#999,color:#333
  style DB fill:#7b68ee,stroke:#5a4db2,color:#fff

На схеме виден полный цикл. DTO (жёлтые) живут только на границе — это CreateOrderRequest на входе и OrderResponse на выходе. Entity и VO (зелёные и фиолетовые) — внутри домена, там и происходит вся работа. ORM-сущность (серая) — отдельный технический класс, который знает, как лечь в таблицу. Конвертер (серый) таскает данные между мирами, но сам ничего не решает.

Обратите внимание на двойную границу: доменный Order никогда не уходит в контроллер напрямую и никогда не трогает базу. Снаружи от него — DTO, снизу — ORM-маппинг. Так домен остаётся чистым: ему всё равно, пришёл запрос из REST, gRPC или очереди, и всё равно, хранится он в PostgreSQL или в MongoDB.

Entity vs VO vs DTO: таблица сравнения

Чтобы окончательно расставить точки, соберём всё в одну таблицу.

КритерийEntityValue ObjectDTO
ИдентичностьЕсть, по idНет, важны значенияНет, это просто контейнер
ИзменяемостьМутируемНеизменяемМутируем, но без логики
СравнениеПо идентификаторуПо значениям полейОбычно не сравнивается
ПоведениеБизнес-логикаИнварианты и доменные операцииТолько поля и геттеры/сеттеры
Где живётДоменный слойДоменный слойГраницы слоёв и сети
ПримерOrder, CustomerMoney, Address, EmailCreateOrderRequest, OrderResponse
Связь с БДЧерез репозиторийОбычно вложен в EntityНикакой

Тут же удобно увидеть и главную ловушку: Entity и VO — доменные понятия, они описывают предметную область. DTO — техническое, оно описывает транспорт. Смешивать их так же странно, как перевозить книги в холодильнике: вроде довезёт, но зачем.

Вот наглядная иллюстрация разницы в логике сравнения:

100%
flowchart LR
  subgraph ENTITY["Entity (Сущность)"]
      E1["Order #12345
id: 12345, status: CREATED"]
      E2["Order #12346
id: 12346, status: SHIPPED"]
  end

  subgraph VO["Value Object (Объект-значение)"]
      V1["Address
Москва, Тверская 1"]
      V2["Address
Москва, Тверская 1"]
  end

  subgraph DTO_D["DTO (Объект передачи данных)"]
      D1["CreateOrderRequest
customerId + items"]
      D2["OrderResponse
orderId + status + total"]
  end

  E1 -->|"id разные, значит объекты разные"| E2
  V1 -->|"значения совпали, объекты равны"| V2
  D1 -->|"ни с чем не сравнивается"| D2

  style E1 fill:#50c878,stroke:#3a9a5c,color:#fff
  style E2 fill:#e0e0e0,stroke:#999,color:#333
  style V1 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style V2 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style D1 fill:#f0a500,stroke:#c88400,color:#fff
  style D2 fill:#f0a500,stroke:#c88400,color:#fff

Два заказа с одинаковыми полями — разные сущности, потому что 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 классы в проекте — сначала прочитайте ещё раз раздел «Когда всё это лишнее». Возможно, ваш проект — как раз тот случай, где один класс честно справляется со всеми тремя ролями, и переписывание под «правильную архитектуру» принесёт больше вреда, чем пользы.