В чем разница и когда что использовать? Это был один из вопросов, на которые я пытался получить ответ.

Попытаюсь тут описать ту практику, которую считаю не плохой. С примерами на PHP. Постараюсь описывать на простом языке - без использования сложной терминологии.

Лучше всего это два подхода понимаются в слоистой архитектуре(слой презентации, доменный, инфраструктура, приложение - те, которые на текущий момент я использую в итоговом примере).

DTO(Data Transfer Object)

DTO - это объект класса необходим для передачи структурированной информации из одного места(метода, функции, слоя) в другое.

Важный момент передавать стараться только скаляры(не объекты), может быть исключения, но лучше попытаться все же не использовать объекты.

  1. Передача данных, где много параметров на примере отправки письма:

// у нас есть функция отправки заказного письма - внутри который мы эмитируем выбор города

// Передача через параметры
// тут мы вызываем ее и передаем набор данных
// Проблемы: много параметров,
function sendMail1(
    string $name,
    string $family,
    string $country,
    string $city,
    string $street,
    int $numberHome,
    ?int $room = null
) {
    selectCity($city);
}

sendMail1('Иван', 'Иванов', 'Россия', 'Ставрополь', 'ул.Мира', 2, 186);


// Передача через ассоциативный массив
// Проблемы:
// нужно точно знать название ключа (подсказок совсем нет)
// проверять на существование ключа (может быть sity или вообще не быть)
// проверять соответствие типу данных (придет null где не надо или строка вместо числа)
// может содержать неконтролируемый поток информации(например при передаче request()) и без дебаггера вообще не разобраться что находится внутри

function sendMail2(array $client)
{
    selectCity($client['city']);
}

$client = [
    'name'       => 'Иван',
    'family'     => 'Иванов',
    'country'    => 'Россия',
    'city'       => 'Ставрополь',
    'street'     => 'ул.Мира',
    'numberHome' => 2,
    'room'       => 186
];

sendMail2($client);


// Использование DTO
// Должен быть максимально простой и без возможности изменения (readonly)

function sendMail3(ClientMailDTO $client)
{
    selectCity($client->city);
}

final readonly class ClientMailDTO
{
    public function __construct(
        public string $name,
        public string $family,
        public string $country,
        public string $city,
        public string $street,
        public int $numberHome,
        public ?int $room = null
    ) {}
}


sendMail3(new ClientMailDTO(
    'Иван',
    'Иванов',
    'Россия',
    'Ставрополь',
    'ул.Мира',
    2,
    186
));

Из плюсов DTO - это больше удобство, которое обычно выражается при использовании "слоистых" приложений или сервисов, чтоб выглядел более лаконично.

Что же касается практической пользы то это:

  1. Не изменяемость значений, после их передачи

  2. более удобный способ обращений к коллекции принимаемых данных, так как IDE подсвечивает возможные варианты. Особенно удобно, когда много входящих данных и есть подобно названные внутренние переменные.

    Подсказка phpstorm
    Подсказка phpstorm
  3. Если нам необходимо получить из какого то метода эти данные, то тут - однозначно только DTO (причину по которой не подходит массив - описано выше)

    // Возвращаем данные для отправки письма в виде DTO
    function getMailData(): ClientMailDTO
    {
        ...
        return new ClientMailDTO(
            'Иван',
            'Иванов',
        // и т.д.
        )
    }

    Минусы - увеличивает объем кода и усложняет проект(опять же, зависит от контекста).

php < 8

Если у Вас нет readonly код будет немного длиннее, но в целом тоже можно использовать

 class ClientMailPhp7DTO
{
    public function __construct(
        private string $name,
        private string $family,
        private string $country,
        private string $city,
        private string $street,
        private int $numberHome,
        private ?int $room = null
    ) {}

     public function getName(): string
     {
         return $this->name;
     }

     public function getFamily(): string
     {
         return $this->family;
     }
     
     // ... дальше по аналогии
 }

Value object (объект значение)

VO - задача передать уже не просто данные, а так сказать валидированные на сколько это возможно.

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

class NumberHomeVO{
    public function __construct(
        readonly public int $value
    ) {
        // Номер дома должен быть больше 0
        if ($this->value<0){
            throw new Exception('Не корректный номер дома');
        }
    }
}

function repairHome(NumberHomeVO $home)
{
    // Оправляем рабочего на этот номер дома))
    SendWorker($home->value);// тут мы точно знаем, что значение корректное(на сколько это возможно для дальнейшей обработки(поиска по бд или создании в бд))
}

VO Так же могут быть и составными, когда корректность данных зависит ни от одного элемента, а от нескольких


class ManyVO
{
    /**
     * @throws Exception
     */
    public function __construct(
        readonly public int $summa,
        readonly public string $currency
    ) {
        if ($this->currency !== 'USD' || $this->currency !== 'RUB') {
            throw new Exception('Не корректная сумма денег');
        }
    }
}

То есть на выходе мы получаем корректный объект данных, со стороны бизнес требований(определенная валюта)

Проверки могут быть сложнее и их количество может быть гораздо больше

Пакет для упрощения проверки на валидность

Для проверок можно так же использовать пакет `Webmozart\Assert;`

use Webmozart\Assert\Assert;

/**
 * Проверка email на валидность
 */
final readonly class Email
{
    public function __construct(
        public string $value,
    ) {
        Assert::notEmpty($this->value);
        Assert::email($this->value);
    }
}

Реализация VO

Используем объекты реализованные на основе классов VO для передачи в конструктор, таким образом внутри класса Post мы получаем валидные данные, с которыми уже можем работать - не переживая о некорректном содержании.


final class Post
{
    public function __construct(
        public TitleVO $name,
        public EmailVO $emailVO,
        //... и т.д.
    ) {}
}

Теперь работа с DTO и VO на примере

Пример старался написать максимально простым, что хотелось показать:

  1. DTO - для передачи данных из слоя PRESENTATION в слой APPLICATION

  2. VO - для валидации и преобразования данных в какие то бизнес сущности

  3. Обработка идет так же по всем слоям, то есть уровень понимания исключения на каждом слое свой, как и обработка его. Например

    1. Презентация - что то не так

    2. Приложение - записать в лог ошибку, что б дальше дебажить

// PRESENTATION
// передаем в сервис только простые данные из приходящих откуда-то(например по API)

//Controller
class PostController
{
    public function create(Request $request)
    {
        try {
            PostService::create(new PostCreateDTO(
                $request->get('title'),
                $request->get('text'),
            ));
            // ответ 201 - все хорошо
        } catch (Exception $e) {
            // ответ 400 - не корректный запрос
        }
    }
}

// APPLICATION
// В приложении описываем - с какими данными будем работать
class PostCreateDTO
{
    public function __construct(
        readonly public string $title,
        readonly public string $text,
    ) {}
}

class PostService
{
    /**
     * @throws Exception
     */
    static function create(PostCreateDTO $DTO): void
    {
        // преобразуем простые данные в уже логически корректные данные и создаем (бизнес корректную, валидную) Post сущность
        try {
            $post = new PostModel(
                new TitleVO($DTO->title),
                new ContentVO($DTO->text)
            );
        } catch (Exception $e) {
            // тут выбираем действия, если создать не вышло
            Log::error($e->getMessage());
            throw new Exception('Проверьте корректность данных');
        }

        // ... как то дальше сохраняем пост или же что-то еще делаем
    }
}


// DOMAIN
// тут описываем какие у нас будут правила по этому объекту
// у VO выкидываем исключения, что б дальше обработать их
class TitleVO
{
    /**
     * @throws Exception
     */
    public function __construct(
        readonly public string $value,
    ) {
        if (strlen($this->value) < 2) {
            throw new Exception('Не корректное название');
        }
    }
}

class ContentVO
{
    /**
     * @throws Exception
     */
    public function __construct(
        readonly public string $value,
    ) {
        if (empty($this->value) || strlen($this->value) < 40) {
            throw new Exception('Не корректное содержимое');
        }
    }
}

final class PostModel
{
    public function __construct(
        TitleVO $title,
        ContentVO $content,
    ) {}
}

Код написал больше для понимания использования DTO и VO, максимально упрощенный. Так же не писал про инфраструктуру, счет ее излишней в примере.

Что же касается SOLID принципов, DDD, чистой архитектуры и т.д. старался не освещать в текущей статье, что бы порог вхождения(понимание) было как можно более комфортным.

Комментарии (9)


  1. fo_otman
    08.06.2025 08:52

    Это разве не чистая архитектура? Контроллер - Use Case - Сервис - Сущность.


    1. Ivan6463 Автор
      08.06.2025 08:52

      У DDD подобный подход, и у луковой архитектуры тоже. То есть вышеперечисленные используют подобный общий подход. Думаю Ваше суждение верное, как и если бы сказали, что это DDD - то тоже было бы верно


  1. TsarS
    08.06.2025 08:52

    Интересней в примере получение данный в DTO - например из сервиса/queryBus в DTO


  1. FanatPHP
    08.06.2025 08:52

    Спасибо за статью. Но не удержусь от пары замечаний.

    • Ошибку валидации вряд ли нужно логировать. Это, по сути, не ошибка, а совершенно ожидаемое поведение. Да, там останется только перевыброс исключения. И как раз на этом стоит остановиться поподробнее

    • Код не станет сложнее, если Exception поменять на ServiceValidationException и DomainValidationException, но зато станет куда более логичным и приближённым к реальности. А сейчас смотришь, и не понимаешь, почему ошибка, к примеру, соединения базы данных возвращает 400, а не 500 статус

    • И вот тут как раз и объяснить, почему вы меняем DomainValidationException на ServiceValidationException

    Ну и совсем уж по мелочи

    • ошибку "Не корректная сумма денег" надо заменить на "Некорректная валюта" (и в целом надо бы вычитать на грамматические ошибки и опечатки)

    • проверка на empty($this->value) не имеет смысла, её стоит убрать


    1. Ivan6463 Автор
      08.06.2025 08:52

      Насчет замечания по поводу исключений - такой подход так же имеет место быть. Я же хотел - как можно максимальнее сосредоточить статью на DTO и VO. Что бы все внимание досталось именно им.

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

      'empty' поправил(убрал) - спасибо, Вы правы)


  1. Rsa97
    08.06.2025 08:52

    Проблемы: много параметров,

    Это не такая уж и проблема, особенно учитывая то, что для создания DTO вам нужно использовать точно такое же количество параметров. При этом, параметры функции SendMail1 точно так же отображаются в подсказках IDE, как и подсказки функции __construct класса ClientMailDTO.

    if ($this->currency !== 'USD' || $this->currency !== 'RUB') {
    

    А здесь есть другое решение - Enum с нужными валютами. При этом IDE будет подсказывать, какие варианты допустимы в качестве параметра функции.


  1. vanxant
    08.06.2025 08:52

    ValueObject - неудачное название. Под этим часто понимают класс с единственным полем value (часто даже публичным), смысл которого - запретить сомнительные операции. Например, id часто представляет собой целое число, но это число - вещь в себе. Ни умножать ни складывать его ни с чем нельзя, но при этом можно сравнивать (т.е. вычитать с отбрасыванием результата). Вот здесь и нужен тип IntValue, который при этом умеет сериализовываться / десериализовываться в число.


    1. Ivan6463 Автор
      08.06.2025 08:52

      Насчет название и смысла - так может показаться, если прийти к ValueObject через интуицию(то есть не прибегая к специализированному материалу).
      Например логика может быть такая: переводится как объект значение - ну тут все понятно)) Пойду кодить)))

      Что же касается смысла, причин использования и примеры на мой взгляд интересных реализаций - это можно подсмотреть в книге Вон Верон "Предметно-ориентированное проектирование". Там достаточно подробно это все описано.

      Опять же это мое видение.
      А что касается конкретного названия - смею предположить, что нужно было как то назвать этот подход и тут "Объект-значение" в контексте архитектурного подхода и в контексте синтаксиса выбранного языка программирование - может означать разный смысл.
      В первом случае(контекст подхода) - это осмысление текущего объекта со стороны бизнес логики
      Во втором случае(контекст синтаксиса ЯП) - можно предположить, как раз таки, что у объекта есть какое то значение и ни про какую бизнес логику или осмысление тут уже речь не идет.

      И закончу ответ фразой: "Отвергаешь - предлагай". То есть, какое название с Вашей точки зрения - было бы удачное?


      1. vanxant
        08.06.2025 08:52

        То есть, какое название с Вашей точки зрения - было бы удачное?

        Ну, эээ, собственно DTO. Ну если мы про PHP, то в симфони например создать невалидную дтошку из запроса можно, с целью например потом подтянуть какие-то значения ещё откуда-то, но вот пробросить по шине и обработать невалидную дто уже нельзя, шина не пропустит.

        Ну и надо различать валидацию (поле email содержит что-то, что выглядит как емейл) и верификацию (например, бизнес-логика требует, что этот емейл должен быть в БД, иначе сначала иди подтверди его через отправку письма с кодом).