← Назад к статьям
Архитектура PHP и паттерны проектирования

DTO vs Value Object: разбираем путаницу в PHP

Понятное руководство о различиях между DTO и Value Objects в PHP: их назначение, поведение, валидация и когда использовать каждый из них в чистой архитектуре.

Доступные языки
✦
Рекомендуемая статья ↗

DTO vs Value Object: разбираем путаницу в PHP

Если вы провели хоть какое-то время в современных кругах PHP, вы слышали оба термина: DTO и Value Object. Они звучат похоже, оба являются простыми классами, и оба хранят данные. Разработчики часто используют их как взаимозаменяемые — и это ошибка, которая приводит к запутанной архитектуре.

В этой статье мы раз и навсегда разберём эту путаницу. Вы узнаете, для чего предназначен каждый паттерн, чем они отличаются и — что самое важное — как решить, какой из них вам действительно нужен в конкретной ситуации.

Корень путаницы

И DTO, и Value Objects имеют несколько характеристик, которые делают их легко смешиваемыми:

  • Оба являются небольшими классами.
  • Оба хранят данные.
  • Оба часто иммутабельны.
  • В простых случаях они могут выглядеть почти идентично.

Но они существуют по совершенно разным причинам. Путать их — всё равно что путать коробку для доставки с монетой — оба являются объектами, но их назначение, правила и жизненный цикл совершенно разные.

Что такое DTO?

Data Transfer Object — это класс, чья задача — переносить данные между слоями или процессами. И всё. У него нет поведения, кроме получения и установки значений (или предоставления readonly-свойств).

Типичные случаи использования:

  • Возврат данных из контроллера в представление.
  • Передача входных данных из HTTP-запроса в сервис.
  • Сериализация данных в JSON для ответа API.
  • Преобразование строк базы данных в удобную для приложения структуру.

Простой DTO выглядит так:

<?php declare(strict_types=1); final class CreateUserDTO { public function __construct( public readonly string $name, public readonly string $email, public readonly int $age, ) { } }

Обратите внимание, чего здесь нет: валидации, бизнес-правил, доменного смысла. Это контейнер. Не более того.

Что такое Value Object?

Value Object — это класс, который представляет концепцию из вашего домена. Он полностью определяется своими значениями, не имеет идентичности и — что критически важно — обеспечивает свою собственную валидность и инкапсулирует поведение, связанное с этими значениями.

Типичные случаи использования:

  • Представление Email, Money, UserId, PhoneNumber или DateRange.
  • Гарантия того, что значение всегда валидно везде, где оно используется.
  • Инкапсуляция доменных правил, таких как сопоставление валют или пересечение дат.
  • Устранение одержимости примитивами в вашей доменной модели.

Простой Value Object выглядит так:

<?php declare(strict_types=1); final class Email { private function __construct( private readonly string $value, ) { } public static function fromString(string $value): self { $normalized = mb_strtolower(trim($value)); if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException( sprintf('"%s" is not a valid email address.', $value) ); } return new self($normalized); } public function toString(): string { return $this->value; } public function equals(self $other): bool { return $this->value === $other->value; } }

Обратите внимание, что здесь присутствует: валидация, нормализация, равенство и намерение. Объект не просто хранит данные — он является концепцией.

Сравнение бок о бок

  • Назначение: DTO — перенос данных; VO — представление доменной концепции.
  • Поведение: DTO — отсутствует (только геттеры/сеттеры или readonly-свойства); VO — может содержать доменное поведение.
  • Валидация: DTO — валидируется извне (например, в form request); VO — валидирует себя при создании.
  • Идентичность: DTO — не имеет значения; VO — определяется значениями, без идентичности.
  • Равенство: DTO — обычно не реализуется; VO — равенство на основе значений, часто через equals().
  • Иммутабельность: DTO — опциональна; VO — обязательна.
  • Слой: DTO — слой приложения (границы); VO — доменный слой.
  • Источник истины: DTO — внешние данные; VO — доменные правила.

Конкретный пример

Представьте, что вы создаёте функцию регистрации пользователя. Вы получаете HTTP-запрос с сырыми данными. Здесь блистает DTO:

<?php final class RegisterUserRequest { public function __construct( public readonly string $name, public readonly string $email, public readonly int $age, ) { } public static function fromArray(array $data): self { return new self( name: $data['name'], email: $data['email'], age: (int) $data['age'], ); } }

После успешной валидации вы преобразуете DTO в доменный объект, использующий Value Objects:

<?php final class User { public function __construct( public readonly UserId $id, public readonly Name $name, public readonly Email $email, public readonly Age $age, ) { } } $user = new User( id: UserId::generate(), name: Name::fromString($dto->name), email: Email::fromString($dto->email), age: Age::fromInt($dto->age), );

Видите поток? DTO перенёс сырые данные через границу приложения. Value Objects защитили домен от невалидного состояния. Каждый паттерн выполнил свою задачу — и ни один не мог заменить другой.

Где люди ошибаются

Путаница между DTO и VO обычно приводит к одной из двух ошибок:

  1. Использование DTO как Value Object. Вы получаете логику валидации, разбросанную по сервисам, потому что DTO не валидирует. Домен остаётся незащищённым.
  2. Использование Value Object как DTO. Вы заставляете доменные концепции выполнять транспортные задачи — добавляя методы сериализации, «toArray()» или дополнительные конструкторы — загрязняя ваш домен инфраструктурной логикой.

Обе ошибки разрушают границы, на которых держится чистая архитектура.

Как решить: простая эвристика

Спросите себя: представляет ли этот класс концепцию с правилами, или он просто переносит данные?

  • Если он представляет доменную концепцию (email, money, order id) → Value Object.
  • Если он переносит данные между слоями (ввод формы, полезная нагрузка API, результат запроса) → DTO.

Другой способ думать об этом:

  • DTO — это форма — он определяет, какие поля существуют.
  • Value Object — это смысл — он определяет, чем значение является и что оно может делать.

Может ли DTO содержать Value Objects?

Да, и это часто хорошая идея. DTO может переносить Value Objects вместо примитивов, когда этого требует граница:

<?php final class UpdateUserProfileDTO { public function __construct( public readonly UserId $userId, public readonly ?Name $name, public readonly ?Email $email, ) { } }

Это нормально, пока DTO не добавляет поведение, а Value Objects продолжают обеспечивать свои правила. DTO по-прежнему переносит; VO по-прежнему защищают.

Можно ли использовать Value Object напрямую в сериализации?

Во многих приложениях Value Objects сериализуются в JSON через кастомные нормализаторы или путём предоставления метода toString() или toNative(). Это допустимо — но избегайте смешивания забот персистентности или транспорта с основными обязанностями VO. Если логика сериализации становится тяжёлой, поместите её в отдельный сериализатор, а не внутрь самого VO.

Лучшие практики

  • Делайте DTO скучными. Никакой валидации, никаких бизнес-правил, никакой скрытой магии. Только данные и конструкторы.
  • Делайте Value Objects строгими. Валидируйте при создании, обеспечивайте инварианты, предоставляйте полезное поведение.
  • Не размывайте границы. Преобразуйте DTO в доменные объекты на краю вашего приложения.
  • Выбирайте иммутабельность везде. Оба паттерна выигрывают от readonly и продвижения свойств в конструкторе в PHP 8.
  • Позвольте домену говорить. Везде, где примитив может нести смысл, замените его на Value Object.

Заключение

DTO и Value Objects — оба простые, мощные и часто путаемые. Различие не академическое — оно архитектурное. DTO — это курьер; Value Object — это гражданин вашего домена. Один перемещает данные, другой обеспечивает смысл.

Используйте DTO на границах вашего приложения, чтобы переносить данные внутрь и наружу. Используйте Value Objects внутри вашего домена, чтобы сделать невалидные состояния невозможными и чётко выразить бизнес-правила. Когда вы держите эти роли раздельно, ваша кодовая база становится проще для тестирования, проще для рассуждения и гораздо сложнее для поломки.

Не относитесь к ним как к взаимозаменяемым. Относитесь к ним как к взаимодополняющим — и ваша архитектура скажет вам спасибо.

Технологии и темы

Теги статьи

Нет статей, соответствующих этим фильтрам.

Есть проект или идея для обсуждения?

Давайте обсудим ↗