DTO vs Value Object: разбираем путаницу в PHP
Если вы провели хоть какое-то время в современных кругах PHP, вы слышали оба термина: DTO и Value Object. Они звучат похоже, оба являются простыми классами, и оба хранят данные. Разработчики часто используют их как взаимозаменяемые — и это ошибка, которая приводит к запутанной архитектуре.
В этой статье мы раз и навсегда разберём эту путаницу. Вы узнаете, для чего предназначен каждый паттерн, чем они отличаются и — что самое важное — как решить, какой из них вам действительно нужен в конкретной ситуации.
Корень путаницы
И DTO, и Value Objects имеют несколько характеристик, которые делают их легко смешиваемыми:
- Оба являются небольшими классами.
- Оба хранят данные.
- Оба часто иммутабельны.
- В простых случаях они могут выглядеть почти идентично.
Но они существуют по совершенно разным причинам. Путать их — всё равно что путать коробку для доставки с монетой — оба являются объектами, но их назначение, правила и жизненный цикл совершенно разные.
Что такое DTO?
Data Transfer Object — это класс, чья задача — переносить данные между слоями или процессами. И всё. У него нет поведения, кроме получения и установки значений (или предоставления readonly-свойств).
Типичные случаи использования:
- Возврат данных из контроллера в представление.
- Передача входных данных из HTTP-запроса в сервис.
- Сериализация данных в JSON для ответа API.
- Преобразование строк базы данных в удобную для приложения структуру.
Простой DTO выглядит так:
Обратите внимание, чего здесь нет: валидации, бизнес-правил, доменного смысла. Это контейнер. Не более того.
Что такое Value Object?
Value Object — это класс, который представляет концепцию из вашего домена. Он полностью определяется своими значениями, не имеет идентичности и — что критически важно — обеспечивает свою собственную валидность и инкапсулирует поведение, связанное с этими значениями.
Типичные случаи использования:
- Представление
Email,Money,UserId,PhoneNumberилиDateRange. - Гарантия того, что значение всегда валидно везде, где оно используется.
- Инкапсуляция доменных правил, таких как сопоставление валют или пересечение дат.
- Устранение одержимости примитивами в вашей доменной модели.
Простой Value Object выглядит так:
Обратите внимание, что здесь присутствует: валидация, нормализация, равенство и намерение. Объект не просто хранит данные — он является концепцией.
Сравнение бок о бок
- Назначение: DTO — перенос данных; VO — представление доменной концепции.
- Поведение: DTO — отсутствует (только геттеры/сеттеры или readonly-свойства); VO — может содержать доменное поведение.
- Валидация: DTO — валидируется извне (например, в form request); VO — валидирует себя при создании.
- Идентичность: DTO — не имеет значения; VO — определяется значениями, без идентичности.
- Равенство: DTO — обычно не реализуется; VO — равенство на основе значений, часто через
equals(). - Иммутабельность: DTO — опциональна; VO — обязательна.
- Слой: DTO — слой приложения (границы); VO — доменный слой.
- Источник истины: DTO — внешние данные; VO — доменные правила.
Конкретный пример
Представьте, что вы создаёте функцию регистрации пользователя. Вы получаете HTTP-запрос с сырыми данными. Здесь блистает DTO:
После успешной валидации вы преобразуете DTO в доменный объект, использующий Value Objects:
Видите поток? DTO перенёс сырые данные через границу приложения. Value Objects защитили домен от невалидного состояния. Каждый паттерн выполнил свою задачу — и ни один не мог заменить другой.
Где люди ошибаются
Путаница между DTO и VO обычно приводит к одной из двух ошибок:
- Использование DTO как Value Object. Вы получаете логику валидации, разбросанную по сервисам, потому что DTO не валидирует. Домен остаётся незащищённым.
- Использование Value Object как DTO. Вы заставляете доменные концепции выполнять транспортные задачи — добавляя методы сериализации, «toArray()» или дополнительные конструкторы — загрязняя ваш домен инфраструктурной логикой.
Обе ошибки разрушают границы, на которых держится чистая архитектура.
Как решить: простая эвристика
Спросите себя: представляет ли этот класс концепцию с правилами, или он просто переносит данные?
- Если он представляет доменную концепцию (email, money, order id) → Value Object.
- Если он переносит данные между слоями (ввод формы, полезная нагрузка API, результат запроса) → DTO.
Другой способ думать об этом:
- DTO — это форма — он определяет, какие поля существуют.
- Value Object — это смысл — он определяет, чем значение является и что оно может делать.
Может ли DTO содержать Value Objects?
Да, и это часто хорошая идея. DTO может переносить Value Objects вместо примитивов, когда этого требует граница:
Это нормально, пока 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 внутри вашего домена, чтобы сделать невалидные состояния невозможными и чётко выразить бизнес-правила. Когда вы держите эти роли раздельно, ваша кодовая база становится проще для тестирования, проще для рассуждения и гораздо сложнее для поломки.
Не относитесь к ним как к взаимозаменяемым. Относитесь к ним как к взаимодополняющим — и ваша архитектура скажет вам спасибо.