Как стать автором
Обновить

Комментарии 219

Проектирование сущностей начинается с составления ER-модели. Предварительно желательно описать бизнес-процессы. Для увязки целостности процессов используется язык UML. Для этого существуют соответствующие CASE-системы (например BPWin, ERWin, Rational Rose, Oracle Designer).
UML-проектирование — это о другом. Вероятно не совсем удачно подобрано название статьи. Тут речь о проектировании классов представляющих в коде сущности предметной-области, собственно акцент на создание объектов, а поведение и отношения остаются за рамками.

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

UML-проектирование, это именно об этом.


https://ru.m.wikipedia.org/wiki/Диаграмма_классов


То что тема обширная — не оправдание тому, чтобы в статье хаотично все смешать в одну кашу. Есть проектирование и есть реализация. Проектирование — ERM/UML/IDEF/ARIS/итд., реализация — SQL/ORM/%language_name%/итд.


Если ваша статья про проектирование, то где диаграммы? Если про реализацию… то где диаграммы, которые вы реализуете?)) Не надо приучать новичков к плохому) Всегда начинайте любое проектирование с хоть какой-нибудь схемы)

Во-первых, иногда код программы сам по себе достаточно выразительный. Во-вторых, некоторые вещи на диаграмме не разглядеть (например, возвращаемое значение в сеттерах).

Вполне приемлемо проектирование вообще без визуальных образов, если этого достаточно для понимания архитектуры проекта всем участникам.
Диаграммы классов я не рисую, занятие в большинстве случаев бесполезное. Слишком трудоёмко их поддерживать в актуальном состоянии, а с применением DDD код сам по себе является прекрасной документацией. Обычно хватает use case diagram, activity diagram и/или упомянутой ER-модели. В общем тут по ситуации.
Но несомненно, всегда лучше начать с того, что бы немного порисовать, как я и сказал, к данному моменту вы «изучили предметную область, сформировали единый язык, выделили ограниченные контексты и определились с требованиями», написали проектную документацию, в соответствии с требованиями и принятым регламентом в вашей компании, почистили зубы и возможно много чего ещё сделали. Здесь речь о проектировании класса, это я делаю без схем.

Ну, и как я уже писал выше:
О диаграммах, схемах и документировании в книге Эванса так же есть отличная глава.
Рекомендую, там подробнее.

Проектирование — ERM/UML/IDEF/ARIS/итд.

Я к «и т.д.» отношу ещё и объектно-ориентированное проектирование, предметно-ориентированное проектирование, а SQL/ORM/%language_name% — это детали, ООП и DDD от них принципиально не зависят, хотя и приходится считаться с техническими ограничениями одно из которых в статье разобрано.

Так вот разработка предварительных проектных решений вместе с разработкой документации на информационную систему относится к стадии реализации проекта в соответствии с ГОСТом (если не ошибаюсь 34.601-90, могу не точно помнить) и относятся к этапу технического и рабочего проектирования. Это если уж совсем в формализм.
Не всегда.
Есть бизнес подход от обратного. Когда заранее не известно что должно быть.
Тогда мы применяем DDD.
В любой момент, при таком подходе, можно создать объект в неконсистентном состоянии или нарушить бизнес-логику

Состояние сущности может быть не валидно для одной цели, и валидно для другой. Тут мы упираемся в «Контекстуальную валидацию».

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

А что мешает программисту создать клиента с невалидным состоянием не вызывав пару методов Строителя:
$client = $builder->setId($id)
    ->setName($name)
    ->setGeneralManagerId($generalManager)
    ->buildClient();


Таким образом мы сначала добавляем соответствующую запись в базу данных, после чего получаем её идентификатор и создаем объект.

Как вариант, но что делать с этими созданными объектами в случае возникновения ошибки в системе? Может проще не использовать генерацию PK на стороне СУБД, а воспользоваться генераторами UUID?
Состояние сущности может быть не валидно для одной цели, и валидно для другой. Тут мы упираемся в «Контекстуальную валидацию».

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

Если это одна сущность, то её контексты зашиты у ней внутри. В рамках же разных ограниченных контекстов проектируются разные объекты. Один объект, являющийся в данном контексте сущностью, в другом даже может быть представлен как объект-значение. В общем это тема отдельная и достаточно масштабная, подробнее у Эванса, Вернона и Фаулера.

А что мешает программисту создать клиента с невалидным состоянием не вызвав пару методов Строителя

Во-первых, ваша реализация строителя может (и должна) выбросить RuntimeException или скорее LogicException
Во-вторых, если в Строителе вы забыли проверить, то ваш скрипт упадет с TypeError или ArgumentCountError.
В общем это уже ошибка разработчика и должна быть разрешена разработчиком. Строитель просто дает возможность создавать сложный объект более гибко и прозрачно.

Может проще не использовать генерацию PK на стороне СУБД, а воспользоваться генераторами UUID?

Это прекрасный вариант и когда это возможно я так делаю, мне нравится UUID, так же я запрашивал идентификаторы у последовательностей Postges, но речь о конкретном случае. Структура БД уже существует и изменить её невозможно, я нашел вот такое решение и оно неплохо вписалось.

А если появится возможность перейти на MySQL или использовать UUID, то без проблем я только заменю реализации Строителей.
Это не верно, сущность может быть либо валидна либо нет

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

Во-вторых, если в Строителе вы забыли проверить

Что проверить?
Что проверить?

Очевидно наличие необходимых для создания объекта параметров. А какого поведения вы ожидаете, например, от QueryBuilder'а, если не укажите таблицу? Он может вам построить кривой SQL без указания FROM и ваш скрипт будет падать при попытке его выполнить, либо в момент построения билдер проверит наличие обязательных параметров и выбросит исключение. Третьего я здесь не вижу.

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

В соответствии с DDD, бизнес-правила явным образом выражены в коде, есть явное бизнес-требование: «клиент не может быть без менеджера, при регистрации за ним обязательно закрепляется менеджер». Изменение бизнес требований требует внесения изменений в код.

А вот дальше мы затрагиваем тоже очень интересный вопрос, который я хотел осветить в статье, но посчитал уже излишним, возможно напрасно.

Именованные конструкторы. Более правильным решением будет не использовать дефолтный конструктор совсем, а пользоваться для этого именованными конструкторами, которые так же будут явным образом отображать единый язык и предметную область.
Client::register(...): Client;

Когда появляется новое бизнес-правило, которое требует регистрацию клиента без менеджера вы вводите ещё один именованный конструктор, который явно выражает это требование.
// имя должно характеризовать бизнес требование и соответствовать единому языку
Client::registerWithoutClient(...): Client;
А какого поведения вы ожидаете, например, от QueryBuilder'а, если не укажите таблицу?

А откуда Строителю известно, для каких целей я создаю Сущность? Другими словами, что если мне нужен SQL именно без FROM?
Когда появляется новое бизнес-правило, которое требует регистрацию клиента без менеджера вы вводите ещё один именованный конструктор, который явно выражает это требование

А если таких требований десятки, будет десяток конструкторов? А как же Строители? А как пользователь узнает о проблемах, приведших к отказу в оформлении заказа на данного Клиента (без GeneralManager)?

Давайте я приведу другой пример: у вас есть сущность Клиента, и для оформления Заказа этому клиенту необходимо наличие у него как Адреса, так и ОтветственногоМенеджера. При этом сохранить клиента можно без этих сведений, а вот для отгрузки товара по Заказу достаточно знать только Адрес Клиента. Вы предлагаете создать 3 конструктора вида: register, registerWithoutManager, registerFull — или как?
Билдер не знает для каких целей, его это не касается. Он знает как построить и знает, что если у него нет цемента, то он не сможет построить кирпичную кладку. А как будет использоваться постройка — дело заказчика, строителю без разницы. Всё это уже похоже на софистику, просто оставим.

А если таких требований десятки, будет десяток конструкторов?

Разумеется, если есть требование, то оно должно быть выражено в коде, в этом и смысл. Правда десятки бизнес-правил создания сущности мне представить сложно. Я поделился конкретным опытом и вполне возможна ситуация где он окажется неэффективным или вовсе неприемлемым.

Ну, вот я тут вижу три размытых бизнес-требования. И при чем здесь конструкторы мне не понятно. Вы собираетесь в конструкторе осуществлять «оформления Заказа» и «отгрузки товара»?

1. «сохранить клиента можно без этих сведений»
Значит Client::register(...): Client; будет без менеджера и адреса или они будут необязательными.

2. «для оформления Заказа этому клиенту необходимо наличие у него как Адреса, так и ОтветственногоМенеджера»
3. «для отгрузки товара по Заказу достаточно знать только Адрес Клиента»

А вот эти два бизнес-правила к регистрации клиента (а значит и к конструкторам) уже не имеют никакого отношения.
Речь не о регистрации Клиента, а о валидации состояния сущности. Для регистрации Клиента требуется одна валидация, для создания Заказа требуется другая валидация (возможно связанная с первой). Я об этом сказал здесь:
Состояние сущности может быть не валидно для одной цели, и валидно для другой. Тут мы упираемся в «Контекстуальную валидацию».


Далее я повел рассуждения относительно вот этого вашего комментария:
Это не верно, сущность может быть либо валидна либо нет, если под валидностью подразумевать непротиворечивость её состояния с точки зрения бизнес-требований

Будет ли сущность Клиент валидна, без Адреса и ОтветственногоМенеджера? Для сохранения вполне, но для создания Заказа — нет. Отталкиваясь от ваших рассуждений, такая ситуация невозможна. Парадокс )
Да вы вообще не о том сейчас.
Для регистрации Клиента требуется одна валидация, для создания Заказа требуется другая валидация

Это два разных бизнес-процесса связанных с одной сущностью.

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

Есть конкретное бизнес-требование "клиент может быть зарегистрирован без менеджера". Не важно, что я могу его сохранить в БД и вообще что храню в БД, в бизнесе нет никаких «сохранений» и «баз данных». Есть «регистрация клиента» — конкретное оговоренное документированное требование, которое позволяет регистрировать клиентов без менеджера.

Есть другое бизнес-требование "для оформления Заказа у Клиента должен быть Менеджер", вот там вы и будете проверять наличие менеджера и обрабатывать эту ситуацию так как оговорено в соответствии с данным бизнес-требованием.
Видимо я плохо излагаю свою идею. Ну ладно, не может так не может )
Вы просто валите всё в кучу.
// первое бизнес-правило - регистрация клиента (тут менеджер не обязателен)
Client:register($id, ..., $manager = null);

// второе бизнес-правило - оформление заказа (менеджер обязателен)
Order::checkout($client, ...);

class Order {
    function checkout($client, ...)
    {
        if (!$client->hasManager()) {
            throw new ClientHasntManagerException();
        }
    }
}
А как ваше решение справится с кодом:
<?php
$client = new Client();
$client->setManager(new Manager));

Order::checkout($client, ...);

Что значит «справится»? Оно противоречит моему решению.
В соответствии с моим решением оно будет выглядеть:

$client = $clienBuilder->build();


$manager = $managerBuilder->build();
// или скорее
$manager = $managerRepository->get();


// метод выражает бизнес-действие "смена менеджера клиента"
$client->changeManager($manager));


Order::checkout($client, ...);


Только это всё уже несколько за рамками статьи.
Что значит «справится»? Оно противоречит моему решению

Вы предлагаете для каждого возможного варианта инстанциации класса реализовывать по одному статичному конструктору. Они должны, по вашей логике, возвращать экземпляры класса в определенном, как вы это называете, консистентном состоянии. Как вы предлагаете валидировать созданную таким образом сущность далее? К примеру:
<?php
$clientA = Client::register2($id, $name, $address);
$clientB = Client::register2($id, $name);

Order::checkout($clientA); // Допустимо
Order::checkout($clientB); // недопустимо

Как в данном случае вы реализуете Order::checkout?
Вы предлагаете для каждого возможного варианта инстанциации класса реализовывать по одному статичному конструктору.

Это вы уже нафантазировали.
Именованные конструкторы. Более правильным решением будет не использовать дефолтный конструктор совсем, а пользоваться для этого именованными конструкторами, которые так же будут явным образом отображать единый язык и предметную область.

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

Правда?
Правда. Давайте по порядку, какое бизнес-правило выражает ваш метод «Client::register2»?

Как проверять, соответствует ли клиент бизнес-требованию «оформить заказ можно только для Клиента у которого есть Менеджер» я показал выше:
class Order {
    function checkout($client, ...)
    {
        if (!$client->hasManager()) {
            throw new ClientHasntManagerException();
        }
    }
}

Создание инстансов и тема статьи тут вообще не при чем.
Давайте по порядку, какое бизнес-правило выражает ваш метод «Client::register2»?

Ну, предположим, первый регистратор регистрирует клиента с адресом (правило «клиент может быть зарегистрирован с адресом»), а второй регистрирует без адреса (правило «клиент может быть зарегистрирован без адреса»). Метод Order::checkout должен проверять возможность оформления заказа клиенту, но только с адресом.
Ну, а в чём проблема проверить наличие адреса?

class Order {
    function checkout($client, ...)
    {
        if (!$client->hasAddress()) {
            throw new ClientHasntAddressException();
        }

        // ... оформляем Заказ
    }
}
Ну как минимум в том, что требуется дублирование валидатора.
Что же вы все меня футболите? У вас своего мнения нет по этому поводу? )
А какого поведения вы ожидаете, например, от QueryBuilder'а, если не укажите таблицу?

Вот смотрите: я не указываю таблицу и...? Это валидный sql запрос для определнных СУБД: SELECT 1 на PostgreSql отрабатывает отлично.

Я соглашусь с Delphinum — валидность / невалидность объекта определяет (возможно должна определять) бизнес логика, хотя в рамках конкретных задач бывает и так, что и сам объект успешно справляется с этой задачей, например, на уровне деклараций.
валидность / невалидность объекта определяет (возможно должна определять) бизнес логика

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

Реализуется это достаточно просто:
<?php
class Client{
  ...

  /**
   * @return InvalidCollection
   */
  public function isValidForPersist(){...}


  /**
   * @return InvalidCollection
   */
  public function isValidForCreateOrder(){...}
}
С Вами соглашусь.
Я не работал с php, не знаю как принято в проектах на нем, но зачастую даже сама логика валидации может жить в отдельном классе по объекту и вызываться непосредственно перед требуемой задачей. С этой точки зрения объект — он как контейнер данных, хранит ровно то, что в него положили, а достаточно это или нет решает, как правильно подмечено, сам 'контекст'.
Я не работал с php, не знаю как принято в проектах на нем

Я работаю с php и, к сожалению, не встречал еще ни одного проекта, в котором применялась бы контекстуальная валидация. Да даже на github не нашел ничего, что заинтересовало бы в этом вопросе. Пришлось писать свое )
НЛО прилетело и опубликовало эту надпись здесь
Мне решение symfony показалось очень усложненным, так как предлагается смешивать механизм контекстной валидации с валидацией данных как таковой, я предпочитаю разделять это на пакеты. Думаю у Yii аналогичная проблема.
НЛО прилетело и опубликовало эту надпись здесь
Вы видимо плохо прочитали мой комментарий. Я не утверждал, что решения нету, я сказал, что не встречал еще ни одного проекта с ней и не нашел ничего, что меня бы заинтересовало в качестве ее реализации.
НЛО прилетело и опубликовало эту надпись здесь
Я не работаю с симфони, а на гитхабе не встречал, если есть ссылка на готовый проект с использованием контекстной валидации, то буду благодарен.
НЛО прилетело и опубликовало эту надпись здесь
НЛО прилетело и опубликовало эту надпись здесь
но приятно иметь библиотеку готовых валидаторов мыла, минимальной/максимальной длины и прочее-прочее-прочее

Приятно конечно. Мое решение позволяет подключить любой набор валидаторов, будь то symfony, zend или yii инкапсулировав их в предлагаемую мной структуру.

компонент симфони позволяет не замусоривать код валидируемых объектов?

Ну организовать фабрику, которая будет генерировать валидатор на основании yml/xml не проблема, если это необходимо в проекте.
НЛО прилетело и опубликовало эту надпись здесь
Ну так я нисколько не настаиваю, код привел в качестве примера решения контекстуальной валидации.

В целом я с вами согласен, но вот методы я бы назвал по-другому. isValidForPersist — это, в моем понимании, просто isValid. Потому что объект, который нельзя сохранить в базу — заведомо непригоден ни для чего другого.


А isValidForCreateOrder я бы сократил до canCreateOrder.

И чем это отличается от задумки автора? и у него и у вас есть валидация объекта. Разница в том, что вы запилили валидацию в сам обобьет, что само по себе не очень хороший вариант, потому как Клиент у вас теперь знает что-то про окружающий мир. Вы говорили, что проблема автора в копировании валидатора, это не проблема. Вынесите ваши 2 метода в отдельный сервис, и проводите валидацию в любом месте вашего приложение используя валидатор. Таким образом у вас валидация обьекта будет в зависимости от контекста. Я бы сказал, что вы с автором говорите об одном и том же, но используете разную архитектуру.

И чем это отличается от задумки автора?

Этим

"Валидность для сохранения в базу" — это в целом бесполезная метрика. Любой объект который соблюдает свои инварианты по умолчанию "валиден для сохранения в базу". За соблюдением инвариантов должен следить сам объект.


Что до примера с hasManager и подобными — абсолютно согласен, все это надо изолировать в сущности и сделать метод canCreateOrder или вообще возложить на юзера ответственность за формирования ордера.


И да, сделав методы hasManager мы тем самым ломаем инкапсуляцию.

это в целом бесполезная метрика

Это одна из самых важных метрик. Вопрос скорее в том, следует ли ее выделять в отдельный валидатор или использовать метод вида canPersist, но валидировать однозначно стоит все с тем же механизмом формирования причин, по которым это самое сохранение не возможно.
Он важна только если инварианты сущности позволяют её существование в виде недопустимом для записи. И если такое бывает по бизнес-процессам, то это просто ещё одна контекстная валидация со всеми вытекающими.
Совершенно верно. Отсюда можно сделать простой вывод — инвариант это частный случай контекстной валидации.
Не так. Инвариант ограничен (по хорошему) сверху пересечением всех контекстных валидаций.
Согласен. Только вот имеет ли смысл это ограничение, ведь при добавлении или изменении всего множества валидаций, пересечение может изменяться. Не проще ли отказаться от инварианта как запрограммированной валидации?
Это уже от аналитиков зависит прежде всего.

У меня есть мысль (правда не уверен насколько правильная), что все свойства бизнес-сущности должны быть доступны снаружи на чтение. Потому что мы их определили при анализе предметной области, при взгляде со стороны. Если бы это были детали реализации, мы бы их не обнаружили. Детали реализации — это, скажем, когда секретарша подписывает документы за шефа, потому что он ей разрешил. Официально это бумага, подписанная шефом. Мы об этом не узнаем, если только кто-нибудь из них сам не скажет.
Другое дело, что иногда одной сущности необязательно знать, что у другой сущности есть менеджер. Но это уже требования бизнес-логики для конкретного случая, а не требования архитектуры.

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

Сделайте read model и там используйте read-only публичные поля. Не вопрос. Ну там сериализации всякие и подобное. Хотя и это под вопросом (для этого придумали DTO). А для остальных случаев вам в целом нет смысла делать публичными поля — к ним никто не должен даже хотеть получать доступ. Ну то есть не нужны ни геттеры ни публичные поля. Стэйт остается в том объекте где он нужен. Если вам нужен стэйт другого объекта — значит что-то при декомпозиции пошло не так.

Так я как раз не про сериализацию, а про бизнес-логику.
Если у товара есть характеристика "Цвет корпуса: черный", то он доступен всем — и клиенту, и продавцу, и начальнику, который видит, что черные лучше продаются. Причем именно в виде бизнес-свойства бизнес-сущности "Товар". А как мы его храним — в виде EAV или обычного столбца в таблице — это уже детали реализации.
А потом начальник вводит правило — при покупке белого начислять покупателю 100 бонусов. Как-то нелогично передавать в оформление заказа DTO, а не сущность.

Как раз в заказ и различные документы логично. Скорее даже не DTO, а value object. Поскольку сущность на то и сущность, что изменяет состояние, а в документах, как правило, нужно состояние сущности на момент формирования. Вы же не хотите, чтобы при изменении, например, цены во всех электронных представлениях документов (у которых есть часто бумажные представления с подписями и мокрыми печатями) поменялись цены и суммы? Или при изменении фамилии у заказчика, чтобы в документах она поменялась?
Я, конечно, имею в виду ситуацию, когда состояние объекта не расшарено по разным процессам. То есть, во время процесса с сущностью извне произойти ничего не может.
Если надо сохранять в заказе текущее состояние сущности на момент заказа, то ничего не мешает это состояние прочитать. Поэтому и нужен публичный доступ на чтение. Мы передаем сущность, из нее берутся необходимые данные, причем часть может дергаться по связям (название организации), другая часть из вычисляемых полей (возраст/инициалы). В DTO придется явно указывать все что надо, и при изменении процедуры оформления заказа править все это во всех местах вызова.
Так мы получаем более сильную связанность сущностей заказа и клиента, особенно если из заказа дергать связи клиента. Из-за изменений в клиенте надо будет править заказы. Используя DTO или другие способы ограничения доступа извне к сущности (например вместо Customer в Order передавать PublicCustomerInterface, в котором только некоторые геттеры Customer перечислены, а то и реализуя в Customer OrderCustomerInterface описанный рядом с Order) мы снижаем связанность, при изменениях Customer ничего не придётся переписывать в Order.
А потом начальник вводит правило — при покупке белого начислять покупателю 100 бонусов. Как-то нелогично передавать в оформление заказа DTO, а не сущность.

Попробуйте реализовать такое вот требование в рамках магазинчика. И сделать это без геттеров и вообще попыток добраться до стэйта. Если надо достать стэйт — значит либо логика должна быть в этом же объекте, либо мы должны объекту дать сервис какой-то которому объект делегирует логику и передаст нужные данные.


Все остальное — нарушение инкапсуляции. И всегда можно ее сохранять если достаточно подумать.


p.s. короч свойства объекта, как не крути, это детали объекта. Они должны быть спрятаны. То есть у доменных объектов уж точно не должно быть публичных пропертей.

А как быть с, например, персональными данными клиента, которые в принципе бизнес не интересуют, но которые должны быть в представлениях, прежде всего печатных формах по требованию госорганов? Да и вообще много может быть свойств у различных сущностей в текущих процессах предназначенных исключительно для представления. Потом, может быть, какого-то аналитика осенит, и эти свойства будут влиять на процессы, а пока просто «чтобы было доступно для показа и редактирования»
но которые должны быть в представлениях

Я уже говорил про представление. В них сущности пихать не нужно.


прежде всего печатных формах по требованию госорганов?

$report->print($pinter);

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

Read Model, DTO. В целом нужно смотреть с позиции SRP. Ну и да, иногда проще фигануть геттеров пачку для представления.

Пускай не пихать сущности, а пихать DTO, но конструктор или фабрика DTO должны иметь доступ к свойствам сущности, чтобы передать их в представление. Есть, конечно, отражения, с которыми можно залезть, но как-то… А friendly модификатора в PHP нет.

Если мы посмотрим например на java а не на php, то там мы бы ассемблер DTO ложили бы в тот же пакет что и сущность. И тогда у ассемблера появился бы доступ к состоянию объекта. Еще как вариант — friend классы (для PHP есть RFC но когда ее примут и примут ли я не знаю). То есть смысл в том что если и экспоузить состояние, то явно не для всех а только для "доверенных лиц" скажем так.


То что в PHP это нельзя сделать нормально — ну печаль беда. Потому мне больше нравится идея делать выборки из базы на чтение прямо в DTO минуя мэппинги сущности и unit-of-work.

Если в PHP делать выборки из базы на чтение прямо в отдельные DTO, то обычно получается много тупой работы, а пользы не сильно больше чем от массивов. Как оптимизация запросов — вариант. Закладывать сразу в архитектуру — как и с любой оптимизацией :)
В PHP возможно воспользоваться Reflection для получения доступа к состоянию сущности.

Суть в чём. Если мы делаем изоляцию домена от приложения (контроллера/вью) с помощью private/protected без геттеров, а в домене/фасаде обращаемся к ним через Reflection, когда нам надо, то разработчик контроллера/вью сможет делать то же самое. Административный запрет "не использовать Reflection нигде кроме домена" равносилен "не использовать геттеры сущностей нигде кроме домена" — если будут нарушать второй, то вряд ли будут соблюдать первый. А если будут соблюдать первый, то можно ограничится вторым, чтобы не усложнять домен доступом через отражения.

то разработчик контроллера/вью сможет делать то же самое

Почему это? Нет, разработчик конечно может делать то же самое, тут уж ничего не попишешь, остается только бить по рукам, но можно ведь все это организовать кошернее.

Приведу пример:
image
С помощью класса Region за юзером закрепляется некая тарифная сетка для региона, в котором он находится. Далее нам нужно сохранить информацию о регионе где либо в сессии. Лично я предпочитаю уровень сессии считать инфраструктурным, потому реализую его так:
image
Работа с сессией на уровне контроллера не осуществляется, а выглядит как то так:
image
Если UserSessionInterface реализовать через Reflection, то контроллеру или вью вообще не нужно будет получать состояние доменной сущности (по аналогии с работой пракически любой ORM).

Кажется, не очень понимаю задачу, которую вы решаете. Как в вашей схеме показать пользователю регион, иными словами как передать его во вью?

Через User::getRegion конечно, но это лично я так предпочитаю делать, а в контексте обсуждаемой здесь темы, можно через UserViewFactory::createDTO которая работает с Reflection.

А у нас дискуссия собственно о том, должен ли быть метод User::getRegion в сущности в принципе и доступным в контроллере/вью в частности. С моей точки зрения в большинстве случаев затраты на возврат в контроллер+вью DTO, да ещё собранного с помощью Reflection, не стоят получаемых при этом плюсов. По крайней мере если писать весь этот код вручную.

Ну вот я и предлагаю использовать Reflection, так как можно организовать некий автоматический механизм генерации DTO для всех сущностей проекта на основе метаданных (так же, как это делается при маппинге сущностей на РСУБД).

В остальном считаю, что DTO вообще бесполезно при передаче во вью в большинстве случаев (разве что как некая обертка, аккумулирующая пачку тех же сущностей и еще каких то данных).
С моей точки зрения в большинстве случаев затраты на возврат в контроллер+вью DTO, да ещё собранного с помощью Reflection, не стоят получаемых при этом плюсов.

И вот тут я с вами полностью согласен. Именно по этой причине для view я либо сразу делаю выборки в DTO (через доктрину например) либо же делаю геттеры.


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


С другой стороны есть операции записи. И в этом случае наличие геттеров будет бить по coheasion моделей. То есть "а че париться, ну и ладно что я не так разделил стэйт, тут геттер заюзаю и тут его заберу". Другой разработчик посмотрит на это и подумаем "ага, значит надо геттер написать и делать проверку извне… ну ок". И так со временем разрастается нарушение инкапсуляции, объекты перестают быть кохисив, повышается связанность и очень часто начинает дублироваться логика.


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


Из этого я прихожу пока к мысли полного разделения модели записи и модели чтения. То есть есть некая сущность и мы пишем ее в базу через репозиторий к примеру или через DAO, не столь важно (к слову в этом случае скорее всего вы все же будете рефлексии юзать для конвертации представления стэйта, но это я так, просто накидываю еще на порассуждать). Пишется это все скажем в postgresql. И все чудно. А для чтения мы просто… юзаем другой компонент который будет использовать ту же базу данных, к примеру (или другую если у нас есть денормализация) и выборку мэпим сразу на DTO.


К примеру у нас есть метод API и мы хотим забрать список юзеров, в котором должны быть:


  • айдишка пользователя
  • имя пользователя
  • количество сессий которые у него сейчас есть

$dql = 'select new App\Users\ReadModel\UserSessionsInfo(
    u.id, u.name, COUNT(s)
) FROM App\Users\Model\User u INNER JOIN u.sessions s';

return $em->createQuery($dql)->getResult();

namespace App\Users\ReadModel\UserSessionsInfo;

class UserSession
{
    public $id;
    public $name;
    public $numberOfSessions;

    public function __construct(int $id, string $name, int $numberOfSessions)
    {
        $this->id = $id;
        $this->name = $name;
        $this->numberOfSessions = $numberOfSessions;
    }
}

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

То есть вы считаете, что товар никому не должен сообщать свой цвет? Вот вы бы как реализовали это требование без геттеров и публичных свойств?
Изменилась процедура оформления заказа — добавилось новое правило. В товарах и заказах ничего не поменялось. Если свойства доступны, изменения в коде будут повторять изменения в реальности. Добавляем в процедуру оформления заказа новое правило с проверкой (хардкодом или отдельным классом в цепочке правил, не суть), и на этом всё. А со скрытым состоянием придется его открывать, причем специально для конкретного правила, то есть основываясь на деталях реализации другого объекта.
Я наверно соглашусь, что можно передавать не саму сущность, а прокси, который содержит только методы для чтения свойств. Но чтобы вообще всё скрывать, мне как-то сложно это представить.
Вот вы бы как реализовали это требование без геттеров и публичных свойств?

$orderLine = $product->order($quantity, $bonusCalculator);

это если влоб. А уже метод order возьмет свой стэйт, который нужен, и даст его калькулятору бонусов.


Словом не зря же закон Деметры придумали.


Добавляем в процедуру оформления заказа новое правило с проверкой

ключевое слово — процедура. Если мы говорим в терминах старого доброго процедурного программирования, а не объектов и сообщений, то да, нужен доступ к стэйту. Вот только есть проблема. Частенько "приоткрывая" стэйт мы тем самым порождаем простор для дублирования логики.


А со скрытым состоянием придется его открывать

В том то и дело, что не должно быть такой необходимости. Ну то есть суть не в том что бы делать все свойства приватными по умолчанию а потом "открывать доступ". А в том что бы этого доступа не нужно было давать. Либо объект свой стэйт сам дает своим зависимостям, либо это не нужно.


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

ну и т.д.


Но чтобы вообще всё скрывать, мне как-то сложно это представить.

То что сложно представить — это я согласен. Мне года полтора понадобилось что бы привыкнуть к этой мысли и начать так думать по умолчанию. Я все еще часто ломают инкапсуляцию, использую геттеры чтобы что-то быстрее сделать. Так же есть задачи представления где просто нужна модель на чтение где проще юзать публичные свойства и геттеры. Но это ж отдельная модель. А если надо по быстрому что-то сделать — проще сделать выборку в массивчик минуя сущность.

Вот вы бы как реализовали это требование без геттеров и публичных свойств?

Подумал чуть дольше. Мой предыдущий вариант не ок поскольку он вносит связанность между модулем подсчета бонусов, каталогом товаров и заказами. Это не ок.


Потому попробуем порассуждать.


У нас есть определенные бонусы на определенные продукты. То есть если продукт заказанный удовлетворяет определенной спецификации (это рубашка, например, она белая и определенного бренда) то мы начислям определенное количество бонусов. Это то как мы задаем бонусы для определенных продуктов.


Когда мы делаем заказ, мы можем либо кинуть доменный ивент что "такой-то заказ был сделан" либо явно дернуть модуль бонусов (смотря какой уровень связанности вас больше устраивает, вдруг у вас микросервисы всякие).


Модуль бонусов берет список продуктов из заказа и проверяет что продукты подпадают под спецификацию:


if ($product->matches($specification)) {
    // добавить бонусов
}

По такому же принципу можно реализовать например какие-то хитрые скидки. Профит:


  • мы более явно задали способ описания правил начисления бонусов
  • держим связанность под контролем
  • не нарушаем инкапсуляции и не закона Деметры
  • спецификации можно реюзать для поиска по каталогу например.
Состояние сущности может быть не валидно для одной цели, и валидно для другой. Тут мы упираемся в «Контекстуальную валидацию».

Речь о валидности, точнее консистенции самой сущности, о соблюдении её (и только её) инвариантов. Если для каких-то целей нужно более строгое состояние сущности чем задано её инвариантами, то есть два варианта:
— наследование (теоретически идеально подходит, на практике многие не рекомендуют применять)
— дополнительная валидация в сущности (в широком смысле слова, в DDD это может быть, например, доменный сервис) ответственной за достижение целеи или выполнение какого-то этапа её достижения, например, с помощью спецификаций или банальных гетеров/иззеров/хэзеров в целевой сущности и их вызова в ответственном месте.
Например, когда инварианты сущности клиента разрешают её создание без адреса, а для целей оформления заказа он обязтелен, то пишем код типа
class Client
{
    private $id;
    private $address;

    public function __construct(string $id, string $address = null)
    {
        $this->id = $id;
        $this->adrress = $address;
    }

    public function hasAddress()
    {
        return $this->address !== null;
    }
}

class CreateOrderService
{
    public function execute(Client $client)
    {
        if (!$client->hasAddress()) {
            throw new LogicException("Client hasn't address");
        }
        // Order creation
    }
}



Может проще не использовать генерацию PK на стороне СУБД, а воспользоваться генераторами UUID?

UX против, UUID сложно передавать по, например, телефону. Хотя всё чаще думаю о создании в таких случаях двух ключей — первичного UUID и обычного INT/BIGINT для UX целей. Или наоборот. Второй вариант подкупает простотой реализации автоинкремента (поддерживается либо средствами СУБД, либо с их помощью ORM), первый — простотой работы с только что созданными сущностями в рамках ORM, но реализация монотонно увеличивающихся локальных идентификаторов не тривиальна, особенно в случае СУБД без сиквенсов (читай — MySQL) даже если разрешено допускать пропуски.
Например, когда инварианты сущности клиента разрешают её создание без адреса, а для целей оформления заказа он обязтелен, то пишем код типа

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

Почему все так любят кидаться исключениями при валидации сущности?

Хотя всё чаще думаю о создании в таких случаях двух ключей — первичного UUID и обычного INT/BIGINT для UX целей. Или наоборот

Правильное решение. Наоборот не надо.
реализация монотонно увеличивающихся локальных идентификаторов не тривиальна

Не понял, где вы в UUID нашли монотонно увеличивающиеся локальные идентификаторы?
Почему все так любят кидаться исключениями при валидации сущности?

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

В более сложных ситуациях можно применить спецификацию, о ней тоже есть у Эванса, Фаулера и Вернона.

В начале статьи указано:
Для понимания материала понадобится базовое представление о предметно-ориентированном проектировании.

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

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

Нет, спецификация это уже из другой оперы.
В конце статьи список материалов по теме, начните оттуда

Если вы догмат теории, тогда рекомендую следующие ссылки для понимая того, о чем я говорю:
Первоисточник шаблона
Мнение Фаулера по теме
Я не «догмат» и даже не догматик, просто вы пишите вещи напрямую не связанные с узким вопросом рассмотренным в статье, все эти вопросы значительно шире.

А спецификация именно об этом (Э. Эванс «Предметно-ориентированное проектирование (DDD): структуризация сложных программных систем», ИД «Вильямс» — 2011 г., стр. 205) или я не понял о чём вы вообще.

И почему выполнение бизнес-требований должно ограничиваться «валидацией до первой ошибки»?
А какая разница какое там количество ошибок? Первая — это уже не выполнение требуемых инвариантов.

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

На самом деле связанные, но да, охватывают немного другие темы, разве это плохо?
А спецификация именно об этом

Не совсем. Спецификация это только часть решения. Кстати да, тоже очень интересная тема.
А какая разница какое там количество ошибок?

Очень большая разница. Полная валидация сущности позволит:
1. Залогировать всю информацию о причинах отказа в обслуживании
2. Сообщить клиенту о всех причинах отказа в обслуживании

Более того, я думаю, что описанный мной построение объекта-сущности никак ей не противоречит

На самом деле контекстуальная валидация это альтернатива инвариантной валидации.

Напишите хорошую статью о контекстной валидации, это будет очень интересно

Писал уже, может скопирну на хабр когда нибудь.
1. Залогировать всю информацию о причинах отказа в обслуживании
2. Сообщить клиенту о всех причинах отказа в обслуживании

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

Если операция в отдельном объекте — то да. Но не всегда имеет смысл выносить операцию в отдельный объект.

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

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

Можно и так, если конструкции типа
if ($entity->canDoIt()) {
  try { 
    $entity->doIt();
  } catch (Exception $e) {
    // handle fatal
  }
} else {
  //.. do smth else
}

нравятся больше чем
try { 
  $entity->doIt();
} catch (EntityCantDoItException $e) {
  //.. do smth else
} catch (Exception $e) {
  // handle fatal
}


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


Скажем, если у пользователя нету прав на удаление записи — ему надо вовсе не показывать кнопку "удалить". А если он ее нажал не имея на то прав — значит, где-то в программе есть ошибка.

то ничто не мешает

Мешает выброс Exception при валидации на первой же ошибке. Зачем этот выброс там нужен, я не понимаю. Исключительная ситуация это та, которой не должно произойти, а если выполняется валидация, то не валидное состояние сущности это не исключительная, а вполне нормальная ситуация, которая как то должна обрабатываться при возврате методом валидации false, и try/catch это не подходящий обработчик для этой ситуации, так как подменяется операция ветвления if/else на try/catch, что делать нежелательно.
Можно накопить все ошибки и потом выбросить. Можно вообще не выбрасывать и возвращать ошибку как-то иначе. Но я придерживаюсь мнение, что попытка приведения сущности в невалидное состояние, нарушение её инвариантов — исключительная ситуация. Это не ошибка пользовательского ввода, ведь они отсеивается задолго до попадания в сущность, на уровне фронтенда, контроллеров бэкенда, сервисов приложения, доменных сервисов и т. д., это ошибка логики, раз запрос на такое приведение дошёл до сердца системы — сущности.
попытка приведения сущности в невалидное состояние, нарушение её инвариантов — исключительная ситуация

То есть вы считаете, что сообщить пользователю лучше: «ошибка, заполняйте форму сначала» — чем: «неверно введен адрес электронной почты и телефон»?

ведь они отсеивается задолго до попадания в сущность, на уровне фронтенда, контроллеров бэкенда, сервисов приложения, доменных сервисов и т. д.,

Зачем, когда можно обеспечить защиту на уровне фронтенда, а если там она не сработает (к примеру логика фронтенда отключена пользователем), то на уровне самой сущности?
То есть вы считаете, что сообщить пользователю лучше: «ошибка, заполняйте форму сначала» — чем: «неверно введен адрес электронной почты и телефон»?

Лучше сообщить пользователю 500 Internal Server Error, если он свою невалидную форму умудрился пропихнуть через фильтры фронтенда, контроллера и сервиса приложения.
Понятно. Ну я предпочитаю не размывать валидацию безнес-модели на уровне контроллеров и сервиса (если сервис вообще реализуется), а держать ее на уровне домена.
На уровне модели предметной области — выполнение ограничений предметной области (domain model). Валидация введенных пользователем данных — это задачи слоя отображения (presentation layer) и прикладного слой (application layer).

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

Не согласен. Я не валидирую введенные пользователем данные, я валидирую состояние сущностей перед их использованием, а вот за установку сущности в конкретное состояние может отвечать как пользователь, так и некая часть системы, это не важно.

Другими словами, я вполне допускаю наличие сущностей с невалидным состоянием, но я допускаю использование этой сущности только с валидным состоянием (чувствуете разницу?).

Ваша валидация и красивости отображения ошибок к домену ни каким боком не относятся

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

Грубо, правило валидации пользовательского ввода: поле 'name' веб-формы регистрации заявки не должно быть пустым, а правило валидации бизнес-модели: создаваемое в ходе процесса регистрации заявки значение $application->$person->$personalData->$name не должно быть пустым.
А это не размывание валидации бизнес-модели, а отдельные валидации пользовательского ввода и валидация бизнес-модели

Что сразу создает дублирование, так как требует валидации как на уровне контроллера, так и на уровне бизнес-модели. Добавление еще одного потока ввода (к примеру из сторонней системы или микросервиса) потребует дополнительного дублирования.

Грубо, правило валидации пользовательского ввода: поле 'name' веб-формы регистрации заявки не должно быть пустым, а правило валидации бизнес-модели: создаваемое в ходе процесса регистрации заявки значение $application->$person->$personalData->$name не должно быть пустым

А зачем это дублирование?
НЛО прилетело и опубликовало эту надпись здесь
решается это валидацией команды

Это так же решается валидацией состояния сущности перед использованием. Получили вы от пользователя данные формы для регистрации нового Клиента, выполнили метод isValidPersist, если ошибок нет, то создаете новую сущность, если ошибки есть, обрабатываете их каким либо образом. На мой взгляд это намного проще и понятнее, нежели ввод самого шаблона «Команда», только для валидации входных данных.
НЛО прилетело и опубликовало эту надпись здесь
Не заметил простоты.

Это гораздо проще чем 100500 isValidFor*

Вас смущает решение или 100500 методов? Если второе, то можно вынести все валидаторы в один большой «божественный объект», и использовать его )
НЛО прилетело и опубликовало эту надпись здесь
Нет, не обязательно выносить в сущность все методы для валидации ее состояния для всех юзкейсов, вы вполне можете оформить валидаторы в виде отдельных классов, как это было предложено уже автором статьи в виде, к примеру, спецификаций. Это было бы очень удобно, на мой взгляд. Если очень постараться (решения в интернете не нашел), то эти же спецификации можно использовать не только для валидации сущностей перед операциями над ними, но и для запроса сущностей с валидным состоянием прямо из базы, к примеру:
// Клиенты, для которых можно создать заказ
$clients = $clientRepository->fetchAll(new CanMakeOrder());
Исключительная ситуация это та, которой не должно произойти

Именно так, это она и есть.
То есть пользователь в принципе не должен вводить неверные данные в формы? )

С другой стороны, зачем тогда вообще формировать метод isValid, который либо срабатывает, либо выбрасывает исключение, может лучше сразу перехватывать ошибки уровня языка?
А что это за метод и где вы его собираетесь «формировать»?
Так он в вашем примере (название правда другое, но это не важно):
class Order {
    function checkout($client, ...)
    {
        if (!$client->hasManager()) {
            throw new ClientHasntManagerException();
        }
    }
}

То есть вы создаете метод, который должен (как я понимаю) проверить возможность выписки счета, но при этом метод либо сообщает о возможности этой выписки, либо выбрасывает исключение. На мой взгляд это совсем неправильное использование исключений. Это как если бы вы выбрасывали исключение из функции count при проверке длины массива, если бы эта длина была равна нулю. То есть, метод checkout (isValid) должен нам сообщать валидно или не валидно это состояние, а мы уже должны как то на это реагировать, но никак не выбрасывать исключение.
Нет, этот метод явным образом в коде описывает бизнес-требования, к валидации он никакого отношения не имеет. Есть требование сформулированное бизнесом «нельзя оформить заказ на клиента без менеджера» (вот просто нельзя никогда, бизнесу не интересно как ты технически это реализуешь, ему важно что бы правило выполнялось) и оно в представленном коде легко прочитывается.

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

В предметной области есть Клиент и Заказ, заказ может быть «Оформлен», Клиент может быть «Переименован» и т.д., поведение явно отражает бизнес-логику и единый язык. В этом основной смысл проектирования по модели — предметно-ориентированного проектирования. Ни о каких setSomething() и isValid() бизнес не знает и в проектируемой модели их быть не должно (иногда, конечно, технические ограничения требуют введения служебных методов в классы модели).
Нет, этот метод явным образом в коде описывает бизнес-требования, к валидации он никакого отношения не имеет

То есть валидирующий состояние сущности метод не имеет к валидации никакого отношения? )

Вы вынесли в сущность Заказ метод, который проверяет стороннюю сущность и называется checkout (проверять, контролировать), но при этом говорите, что в модели не должно быть технических валидаторов ))

Ну ок, назовите метод не isValidPersist, а canBePersist чтобы он соответствовал единому языку и отражал бизнес-правило: нельзя регистрировать Клиента без Адреса. Так будет лучше?

ЁПРСТ ИКЛМН! "checkout" переводится не как "проверять, контролировать", а как "выписывать, оформлять"!


В данном случае, речь идет об оформлении заказа, а не о его проверке!

Значит метод оформления заказа отвечает за контроль состояния объекта, дабы он отвечал бизнес требованию и за, собственно, оформление этого самого заказа? )

Нет. Метод оформления заказа отвечает за оформление заказа. Он должен либо оформить заказ, либо сообщить о том, что это невозможно.

Но метод не сообщает о том что это невозможно, метод падает с исключением. Или для вас это одно и то же?

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


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

Он должен либо оформить заказ, либо сообщить о том, что это невозможно

Значит правильнее сказать, что метод либо оформляет заказ, либо падает исключением. Метод не сообщает о том, что это невозможно, так как исключение это не сообщение.

Я же предлагаю вообще не пытаться вызывать метод оформления заказа, который помимо этого оформления должен еще «знать» о бизнес-условиях этого действия и выполнять валидацию всех сущностей, с которыми работает, а вынести эту логику в отдельный валидатор, оформленный в виде метода или класса. Плюсы в том, что:
1. Валидация выделяется из метода, который этой валидацией заниматься не должен
2. Валидация может использоваться повторно и комбинироваться
3. Вы получается подробную информацию о всех причинах отказа при выполнении операции
Да, есть такая вещь, но что нам мешает оспаривать ее эффективность и/или приводить альтернативные решения?

Кстати да, еще одна польза контекстной валидации: вы можете использовать ее вместе с защитным программированием, просто выполняя еще одну валидацию внутри метода, от нее зависящего. Если валидация не выполняется, то выбрасывается уже ваше любимое исключение. Выгода тут в том, что вы не дублируете код валидатора, а оформляете его в виде бизнес-требований и используете повторно перед вызовом метода и внутри метода (если вдруг, каким то чудом первая проверка пропустила невалидную сущность, но это уже из области 1С и излишне).

В таком случае я не понимаю о чем вы вообще спорите.

Эмм… Я удивляюсь выбросу исключений при валидации бизнес моделей и предлагаю обратить внимание на контекстную валидацию.

Задача метода checkout — не в валидации. Исключения он должен выбрасывать согласно принципу fail fast. Контекстная валидация — опциональна, ее лучше делать, но можно и пропустить если ее логика слишком простая (в примере с checkout — проверяется только 1 свойство).

Задача метода checkout — не в валидации… Контекстная валидация — опциональна

Согласно DDD вы как хотите в коде оформить проверку бизнес-требований, если в задачу метода checkout она не входит?

Чем кроме названия метод hasManager не подходит в качестве метода контекстной валидации? :)

1. Контекстная валидация, по моему мнению, должна предоставлять вам информацию о причинах невалидности сущности
2. Контекстная валидация позволила бы нам вообще отказаться от метода hasManager, инкапсулируя его в логике валидатора, а не вынося в публичный API класса
3. Если бизнес-требования изменятся, то, возможно, проверки одного только hasManager станет недостаточно, и тогда придется искать весь продублированный код по проекту вместо того, чтобы изменить один только валидатор
4. Валидатор с именем canMakeOrder более информативен и больше соответствует единому языку (на мой взгляд), чем выброс исключений и их обработка через try/catch
Автор прав, идеально добиться состояние, когда сущность после создания всегда находится в валидном состоянии, т.е. инварианты всегда соблюдаются.
Есть давняя статья Грега Янга (создателя термина CQRS как паттерна построения приложений) — http://codebetter.com/gregyoung/2009/05/22/always-valid/
Основная идея там: «it is not that an object must always be valid for everything, there are lots of context based validations… Always valid means that you are not breaking your invariants.»

Т.е. есть контекстные проверки по которым сущность будет не валидна, но все инварианты должны выполняться.
Например, у вас вашего объекта Client есть поле email
По контекстной проверке (проверка на то, что имейл должен быть корпоративным либо принадлежать какому-то специфичному домену) объект может не проходить.
Но то, то что формат имейла правильный — это invariant который должен выполняться.
валидном состоянии

Валидном для чего?
Мне кажется, вы пришли к конечным автоматам. Посмотрите на компонент workflow в symfony
Таки пришел не я, контекстуальная валидация это не моя идея. В остальном да, если состояния сущности могут быть представлены конечным автоматом (а как правило, это именно так), то перед попыткой смены состояния (операцией), предлагается выполнять проверку, действительно ли автомат находится в состоянии, при котором эта смена осуществима.

Автор идет по другому пути, при котором переход в конкретное состояние невозможен в принципе, и не важно, почему.

Symphony workflow позволяет определить причину невозможности перехода?
По хорошему, контекстуальная валидация сущности должна производиться контекстом, а не самой сущностью. Сущность о контекстах её использования знать ничего не должна. Например, при добавлении заказа в коллекцию заказов сущности клиента, сущность не должна проверять заполнено ли у неё поле менеджер, это ответственность процесса заказа, какого-то менеджера, максимум что сущность может сообщить ему — заполнено у неё это поле ли нет. Сущность не отвечает за процессы, она объект процессов.
Я бы даже сказал, что контекстуальная валидация сущности должна производиться специальны валидатором, который вызывается контекстом, но знающие люди говорят, что знания о контекстах использования можно хранить на уровне сущности.
Пока контекст один — можно без проблем. Собственно концептуальная валидация будет являться и инвариантом модели. Но чем дальше, чем больше контекстов, тем больше их будет и в сущности, она станет центром всех контекстов. Одних только методов типа isValidFor может стать сотни. И за все контексты отвечать будет сущность — это очень далеко от SRP.
Если валидатор реализовать в виде класса-спецификации, то можно будет использовать его в контроллере перед вызовом операции.
Не важно как реализовать, можно в контроллере дергать метод hasManager(), можно натравливать спецификацию ReadyToOrder, которая будет его дергать, главное что проверка состояния сущности на валидность для какого-то узкого контекста будет проводиться снаружи сущности, пускай и на основе её состояния.
Тут важно не местонахождение валидатора, а момент валидации. Инвариант запрещает существование сущности в некорректном состоянии, а контекстная валидация запрещает переход сущности в некорректное состояние. В этом то и разница.
Контекстная валидация немного не про то, по моему, она запрещает операции в которых участвует сущность в некорректном состоянии, состоянии самой сущности может не изменяться при этом. Как в примере с заказами — создание заказа не изменяет сущность клиента (допустим в модели связь заказ-клиент односторонняя), но сама операция создания заказа запрещена, если у клиента нет связи с менеджером. И даже если изменяет, то изменять она его может корректно с точки зрения самой сущности, её основного контекста, некорректно это будет лишь для данной конкретной операции.
Как в примере с заказами — создание заказа не изменяет сущность клиента

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

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

Что так: суть статьи и ее посыл понятны + делитесь опытом — за это спасибо.

Что не так для меня:
1. ClientBuilder — лишний контракт.
2. MySqlClientBuilder размывает границы доступа к БД в приложении и усложняет процесс его понимания

Как бы сделал я:
1. Сделал объект Client настолько простым, насколько это возможно, в данном случае список полей и get/set методы к ним
2. Объект доступа к БД, ClientDao с операциями saveClient, loadClients, updateClient и т.д.
3. Объект проверки сущности Client в ClientValidator — проверка заполнености всех данных, а также их прикладная согласованность.
4. В слое бизнес логики перед сохранением проверка Client с помощью ClientValidator

Итого имеем: строго формализованные слои приложения:
  • Слой 'Бизнес Объект' — Client
  • Слой 'Объект доступа к данным' — ClientDao
  • Слой 'Проверки объектов' — ClientValidator
  • Слой 'Бизнес логики' — где происходит манипуляция остальными вышеперечисленными слоями.
Слой 'Бизнес Объект'

Это не объект, а так, структура из Си или запись из Паскаля. В PHP вообще массив может быть. DTO в лучшем случае. Собственно геттеры и сеттеры в вашем подходе не нужны, достаточно публичных свойств. Суть бизнес-объекта (сущности) именно в том, что он инкапсулирует относящуюся к нему (и только к нему) бизнес-логику. В общем случае у него не должно быть сеттеров, да и геттеры не на все свойства обычно нужны. И он должен быть максимально сложным (в рамках SRP), а не максимально простым, чтобы максимально использовать преимущества ООП. А у вас ООП вырождается в процедурное программирование с неймспейсами.

Ваш подход плохо подходит для моделирования сложных областей, потому что, как минимум, слой бизнес-логики становится зависимым от слоя доступа к данными. Что-то меняем в системе хранения и надо переписывать бизнес-логику. Это уже не бизнес-логика получается, а управление логикой хранения. В идеале логика хранения вообще должна быть сбоку (или хотя бы сверху) от бизнес-логики. Бизнес-логика, включая бизнес-объекты в идеале ничего не должны знать о системе хранения, о фреймворке, вообще о среде исполнения.
По-моему, обычно, для создания сущности с обязательными параметрами, билдер — оверинженеринг. Обязательные параметры в конструкторе, пускай их с десяток (несвязанных друг с другом!) — норма. Билдеры хорошо подходят когда есть необязательные параметры — первым вызовом создаём обязательные, а дальше если нужно в данном кейсе заполняем необязательные, в том числе инкапсулируя создание объектов-значений.

Спасибо за статью! всё шикорно! только одна проблемка, как бы пользователт этой системы не начали передавать сам обьект билдера (недосозданный)


$halfBakedClient = $builder->setId($id)
    ->setName($name)

ничего же не мешает это передать в какую-нибудь другую функцию, которая что то там ещё запонить должна. мне кажется стоит остановться на шаге втором. Такой конструктор — это немного перебор (по-моему). Ну или нужно как то запретить существование "полу-созданных" объектов!

А что плохого в передаче полу заполненного Строителя?

Полузаполненный строитель со временем вытесняет использование того объекта, который предполагалось им строить. Особенно когда код отдается фрилансерам в условиях постоянно меняющихся требований.

Полузаполненный строитель со временем вытесняет использование того объекта, который предполагалось им строить

Такого в принципе быть не может, так как у Строителя одна задача, а у создаваемого им объекта совершенно другая. Другими словами у них разная семантика, чтобы такое проворачивать. Конечно если наговнокодить, то можно все, но делать это не надо и проблема тут не в передаче полузаполненного Строителя.
Наоборот, это очень крутая возможность передавать строитель в различные функции, чтобы они его дозаполняли по необходимости. Главная функция заполнения может даже не знать о том, что и как заполняют и заполняют ли вызываемые функции и не заполнять ничего сама. А «полусозданные» объекты запрещаются на самом последнем этапе работе билдера, на собственно создании объекта. Если данных недостаточно, то объект не создаётся.

НЛО прилетело и опубликовало эту надпись здесь
Паттерн как паттерн, для случая, который описывает автор — фабрика тоже подойдет. Тут не это главное в статье автора.
Спасибо за статью, но
во-первых:
$client = $builder->setId($id)
    ->setName($name)
    ->setGeneralManagerId($generalManager)
    ->setCorporateForm($corporateForm)
    ->setAddress($address)
    ->buildClient();

зачем? почему? есть много способов использовать это не правильно. Почему не
$client = $builder->buildClient($id,
    $name,
    $generalManager,
    $corporateForm,
    $address);

ну или именованный конструктор.

во-вторых: Скорее всего Менеджер — это отдельная сущность, Адрес хоть и Value object, но хранится в отдельной табличке. Не дорого ли, ради DDD? клиентов, к примеру, вы можете восстанавливать из базы в 20 разных местах бизнесс-логики, но Менеджер используется в одном, Адресс используется в другом, и в итоге в 18 местах вы восстанавливаете из хранилища «консистентного» клиента с 2мя дополнительными запросами (ну или джойнами). Это мне никак не понять в DDD, как-будто это несущественные затраты, но нам, например, при отдаче REST запроса, оч важно сэкономить и 10ms (есть требование, чтобы ответы были в пределах 100ms) и вот эти лишние данные в клиенте в 80% случаях не нужны, но по DDD сущность нужно восстанавливать из хранилица тоже консистентной, даже если вы не пользуетесь всей сущностью.
Наверно в моем случае правильнее использовать CQRS :)
Но всеравно есть агрегаты, которые тяжело восстанавливаются (Ну например, зачем восстанавливать аггрегат заказа с 1000 строк в заказе? везде и всегда)
CQRS и DDD в общем случае дополняют друг друга. Например, одни сервисы приложения реализуют команды, а другие запросы, не смешивая их, команды возвращают максимум true/false, а запросы ничего не меняют (кроме разве что логов технического аудита).

Есть ленивая загрузка графов объектов из базы. В контексте PHP Doctrine это умеет более-менее. А Value object в отдельной табличке обычно костыль, чтобы применить ту схему данных, которая нравится, а не которое предлагается той же Doctrine.
идея в том, что для операций чтения, которых в большинстве приложений сильно больше чем записи вы можете иметь отдельную упрощенную структуру данных, т.е. Query операции вам возвращают просто массив данных key=>value или массив DTO. А в Command у вас полноценные модели, с процеркой всех валидацией и тяжелыми связями для обеспечения консистентности

В CQRS подходе DDD у вас живет только в коммандах, квери должны быть очень упрощенными и быстрыми.
Как правило с отдельным индексом и без тяжелого ORMа
Как вариант упрощенной структуры данных может быть вытягивание в те же классы сущностей DDD без всех их связей, без отслеживания изменений в объектах после вытягивания и т. п. Это позволяет, с одной стороны, хранить иметь только одну модель и только один маппинг на базу, не заморачиваясь с синхронизацией при каждом чихе, а, с другой, позволяет выполнять запросы заметно быстрее чем команды.
то что вы говорите — не применимо и не правильно.
у вас в квери сценарии будут какие-то недоинициализированные сущности, с нерабочими интерфейсамми, которые кидают эксепшены на вызовах определенных методов.
В этом и фишка CQRS — у вас две архитектуры, для чтения и записи. И масштабируете вы их тоже отдельно. Это не недостаток, а достоинство подхода. А вы хотите это нивелировать, пытаясь это уложить на одну кодобазу.
Применимо. И масштабирую отдельно — чтение с рид-онли реплик БД.
то что вы написали, это реализация фабрики. Билдер это как раз про цепочки сеттеров.

А по поводу вопросов дороговизны. Если ваши сущности иммутабельны, т.е. не изменяемы, то вы вполне можете их шарить по всему приложению, так как никто не сможет их поломать изменив глобальный стейт. И у вас есть некий Object Manager — глобальный контейнер в который кладутся уже созданные сущности и который отвечает за создание новых, которые требуются параметрами конструкторной dependency injection. То это сильно сократит стоимость.

Но если у вас очень нагруженное приложение и требование по перформансу очень высоки, то DDD может быть действительно не для вас.
Ну, сам процесс создание объектов-сущностей не большая проблема. И даже большое кол-во зависимостей может быть не проблемой (хотя большое кол-во зависимостей это «smell» в коде, который намекает, что ваш объект берет на себя много обязанностей и вероятно нарушает принцип единой ответственности, что всегда плохо в ООП) если у вас есть Object Manager (Entity Manager/DI Container) который проинстанциирует все внешние зависимости сущности.

Но конкретно в вашем примере нельзя Client называть Enitity. У вас это классический Data Transfer Object, т.к. вы показали что он хранит только данные и аксессоры к этим данным. И никаких методов бизнесс логики
Нет, это конечно же не DTO и я начинаю с того, что бы как раз сделать шаг от анемичной модели к rich и превратить DTO в Entity. Но да, мы уже обсудили это в другом месте, моя ошибка. Для упрощения примера и акцента на теме создания объектов не стал упоминать о поведении, но из-за этого пример выглядит неубедительно. Хотя бы вкратце это оговорить нужно было.
DTO который содержит пачку геннеров и сеттеров — это не всегда плохо, точней если он используется для межпроцессной коммуникации (например в web api), то это даже хорошо.
Поэтому может просто имеет смысл выделить отдельную сущность — доменную модель. А DTO оставить как DTO. Сериализируемый объект, который всегда можно передать по сети.
Лучше выделить DTO (если он вообще нужен, особенно в PHP, где вокруг динамических ассоциативных массивов чуть ли не бОльшая половина экосистемы крутится) из сущности прозрачно замапленной на БД, чем делать ещё один слой сохранения данных поверх доктрины.

В теории вроде разницы нет, но на практике DTO между доменной моделью и слоем хранения данных сильно замедляет разработку, не давая особых плюсов, пока возможностей используемой ORM не ограничивают (или ограничивают но не сильно) доменную модель. А когда ограничивают, то чаще всего это уже просачиваются через ORM ограничения РСУБД, а не самой ORM. Ситуации когда четко видишь как разработанная модель красиво ложится на SQL БД, но при этом ORM не даёт так положить — редки.
Конечно лучше, но в данной статье я ничего о DTO и их применении не говорил, тут речь только о сущностных. В модели предметной области DTO могут использоваться для реализации событий предметной области, в остальных ситуациях DTO не место в слое модели.
ну если речь о сущностях, тогда в вашей реализации проблемы, так как ваши сущности отдают наружу доступ ко всем смоим данным и тем самым нарушают инкапсуляцию.
Контракт сущности (entity) не должен состоять из getter/setter к данным из которых эта сущность состоит
Вообще говоря не задача сущности изолировать свои данные от внешнего (по отношению к домену) мира. Это задача уровня сервисов приложения. Они обращаются к сущностям и сервисам модели как часть домена и, если нужно, изолируют домен от остального приложения, в частности возвращая вместо сущностей DTO, часто заточенный под конкретный запрос приложения.
Пару замечаний.

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

А по поводу шаблона проектирования «строитель», я думаю, для вашего примера он не очень подходит, обычно его используется для сложных объектов, где каждый параметр в конструкторе не обязателен и может быть, например, нулом.

Есть хороший туториал от создателей doctrine.
Для идентификации сущностей лучше использовать именно UUID

Нет однозначного ответа, что лучше. UUID лучше с точки зрения простоты разработки и безопасности, но хуже с точки зрения юзабилити (ссылку по телефону затруднительно передать будет, например или отсортировать объекты в порядке создания без создания дополнительных полей) и, обычно, потребляемых ресурсов, особенно если бинарное представление недоступно и используется какое-то из символьных.
Не рекомендуется использовать ключи идентификации для целей, отличных от идентификационных, таких как — сортировка, юзабилити и т.д. У каждого элемента должна быть минимальная ответственность.
Для сортировки есть UUID, сгенерированные на основе timestamp, а для ссылок лучше использовать отдельное поле с «красивым» значением, авто инкременты лучше не показывать пользователям с точки зрения безопасности и конфиденциальности бизнеса.
Если с красивым (номер документа, например), то с точки зрения безопасности и конфиденциальности бизнеса разницы нет между автоинкрементным первичным ключом и генерируемым последовательно номером.
Пример «красивого» идентификатора без номера youtube.com/watch?v=ysM-Z_lLcEU
Чем он красивый? Как его продиктовать?
Так же как и цифровой, да, сложнее, но безопаснее. Вообще кейс странный, не припомню когда мне приходилось надиктовывать ссылки. Можно скинуть, например, в мессенджер.
Вполне нормальный кейс когда речь идёт о корпоративном софте, в частности об удобстве топ-менеджеров, которые не хотя добавлять в свои мессенджеры рядовых работников.
Не понимаю почему диктовать ссылку удобнее чем переслать по той же корпоративной почте.
Лишние движения для топ-менеджера.
Да ладно, пару кликов.
Еще кнопочки нажимать. Да и вообще когда гендир звонит в саппорт, можно и премии лишиться, если сказать «а вы мне письмо напишите»"
Такие компании проходят мимо :)
Можно скинуть, например, в мессенджер

Вы это скажите тёте-секретарю 50 лет, которая забивает документы в СЭД и периодически звонит в УФССП для уточнения чего либо по определенному делу. Нет, пользоваться скайпом/мылом/аськой и т.д. она не умеет, с трудом научили вбивать данные в поля и нажимать «Сохранить».
О чем вообще речь? Я выше писал, что для публичных ссылок лучше не использовать инкременты, вы сейчас говорите о внутренних процессах тети-секретаря. В первом случае ссылки могут содержать UUID, во втором — «красивые» номера для внутреннего пользования тетями-секретарями. Причем можно иметь сразу два идентификатора для каждой сущности.
авто инкременты лучше не показывать пользователям с точки зрения безопасности и конфиденциальности бизнеса.

Где слово «публичные»?
Ясно, разошлись с самого начала, все что я писал выше о безопасности, относится к случаям, когда идентификаторы могут видеть люди, не обремененные всякими NDA.
Причем можно иметь сразу два идентификатора для каждой сущности

Значит я вас не правильно понял, прошу простить. Полностью согласен, для человека лучше применять отдельный формат нумерации, а не идентификатор сущности.
Еще одно преимущество использования UUID — масштабирование, когда у вас «распределенная» модель базы данных, можно быть уверенным что идентификаторы не будут иметь дубликатов на различных серверах.
Никто не мешает иметь два ключа )) Вопрос какой из них первичный.

Не согласен с тобой относительно билдеров.


У меня тут очень простая позиция. У методов не должно быть более 3-х аргументов. То есть если у сущности есть 5 обязательных параметров, вместо того что бы делать конструктор с 5-ю аргументами проще сделать билдер. Это будет и читабельнее, и удобнее в использовании.


В целом для сущностей я обычно по умолчанию билдеры юзаю. Для VO уже намного реже. Иногда вместо билдера еще фабрики можно делать + dto какое-то… это не столь важно. Важно чтобы все данные передавались одним пакетом и связанность по данным была ниже.


Что до туториалов: вот еще на эту тему

Все зависит от конкретного случая, 5 аргументов нормальная ситуация если они скаляры. Для сложных сущностей, где куча аргументов VO, я использую фабрики, имхо лучше подходят нежели билдеры.
5 аргументов нормальная ситуация если они скаляры.

А в чем разница можешь сказать? Ну вот у тебя есть класс. По твоей логике если я беру класс у которого 5 аргументов в конструкторе, все хорошо пока они скаляры. А если я эти скаляры оберну в свой тип (Email, Password, etc) то уже не ок что-ли? Почему раньше тогда было ок?

Кейс выдуманный для примера

new User(
new Name(new FirstName('John'), new LastName('Doe')),
new Age(18),
new Username('johndoe'),
new Email('john.doe@gmail.com'),
new Password('qwerty')
)

vs

new User('John Doe', 18, 'johndoe22', 'johh.doe@gmail.com', 'qwerty')

В первом случае для создания сущности придется писать много кода, особенно в тестах, поэтому можно написать builder/factrory.

по этой причине я использую билдеры/фабрики)

Ну так я это и имел ввиду, все зависит от кейса, я считаю что для второго кейса (описал выше) писать билдер/фабрику излишне.

Вообще-то наоборот. 5 аргументов — это нормально если они не скаляры. Потому что скаляры не могут сами себя документировать, особенно если они константы.

Проблема со связанностью по данным, а скаляры это или нет не имеет никакой разницы. Потому есть всякие правила:


  • идеальный метод имеет 0 аргументов (тут только message coupling, самый низкий вид связности к которому нужно стремиться)
  • 1 аргумент норм (но уже начинается data coupling)
  • 2 аргумента ну так, сойдет (data coupling выше)
  • 3 аргумента — край. (еще выше).

data coupling это конечно не content coupling но все же уровень этого вида связности нужно держать под контролем.

Это уже высокие материи, различие пять скаляров от пяти объектов — в другом.


Если есть вызов вида foo(5, 3.0, false, "bar", null) — то проблема не в data coupling, а в том, что чтобы понять что эта строчка делает надо несколько раз "прыгнуть" между этой строчкой и определением метода foo. А если у параметров еще и типы одинаковые — то все вовсе печально.


При вызове же метода с передачей ему пяти объектов такой проблемы не возникает. И тут уже можно начинать рассуждать о том что data coupling — это плохо :)

то проблема не в data coupling, а в том, что чтобы понять что эта строчка делает надо несколько раз "прыгнуть" между этой строчкой и определением метода foo.

вменяемые IDE решают эту проблему запросто. А вот проблему data coupling не решают.


В целом же даже если брать ваш вариант — либо у вас код будет выглядеть как-то так:


foo(new Foo(5), new Bar(3.0), State::invalid(), new Baz('bar'));

либо вы не решили проблему и нам всеравно нужны подсказки и хинтинги от IDE.

В IDE код пишется. А вот читаться он может в форме дифов в гите. Или через веб-интерфейс...

В любом случае разницы не будет. Что так не удобно что эдак. С билдерами же будет явно и читабельно.

в том, что чтобы понять что эта строчка делает надо несколько раз «прыгнуть» между этой строчкой и определением метода foo

Даже если IDE не подскажет, то всегда можно сделать что-то типа:
$orderCountLimit = 5;
$discontPercent = 3.0;
$delivery = false;
$customerComment = "bar";
$customerEmail = null;
foo($orderCountLimit, $discontPercent, $delivery, $customerComment, $customerEmail);

Ничем не хуже:
$orderCountLimit = new OrderCountLimit(5);
$discontPercent = new Discont(3.0); // предполагаем, что конструктор устанавливает свойство percent
$delivery = new Delivery(); // false значение по умолчанию
$customerComment = new Comment("bar"); 
$customerEmail = new NullEmail();
foo($orderCountLimit, $discontPercent, $delivery, $customerComment, $customerEmail);

А то и лучше, ведь от скаляров никуда не делись.
Оборачивать скаляры в объекты ради документации кода? Так себе идея, тем более есть php7 и доктайпы в IDE.
А внутри билдер вызывает конструктор с пятью аргументами или конструктор с тремя и два сеттера потом?
Все зависит от доменной модели, если сущность может существовать без какого-то параметра, можно сделать сеттер, вообще сущность должна быть валидной всегда.

сеттеры это плохо.


сущность должна быть валидной всегда.

именно так.

Серега, никто не спорит что сеттеры хорошо, это был синтетический кейс.
public function build(): User
{
    return new User($this);
}

public function __construct(UserBuilder $builder)
{
    $this->email = $builder->email();
    $this->credentials = new Credentials($this, $builder->email(), $builder->password());
    $this->profile = $builder->profile();
    $this->registeredAt = new \DateTime;
}

$user = User::builder()
    ->setPassword($password, $passwordEncoder)
    ->setEmail($email)
    ->setProfile(Profile::builder()
        ->setName($name)
        ->setBirthday($birthDay)
        ->setPicture($picture) 
        ->build()
    )
    ->build();

как-то так обычно.

И твоя сущность зависит не от домена, а от билдера, который по сути выступает в роли хелпера. И чем это лучше такого кода:

$user = new User(
$email,
$password,
$passwordEncoder,
new Profile($name, $birthDay, $picture)
);
  1. больше простора для дальнейших действий
  2. меньше связанность по данным (не так больно добавлять новый обязательный параметр)
  3. проще хэндлить валидацию
  4. в тестах можно реюзать билдеры:

$customer = UserFixtures::customerBuilder()->build();
$referral = UserFixtures::customerBuilder()->setIntroducer($customer)->build();

выходит весьма и весьма удобно.

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

Есть такой вид связности — связность по данным.


ты отвязываешь сущность от доменных объектов и привязываешься к хелперу-билдеру

сущность и есть доменный объект. Я просто делаю что-то типа объекта-сообщения который содержит все необходимые сущности данные (вместо полотнища аргументов которые не читабельный и неудобны при расширении логики).


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


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

Под доменными объектами я имел ввиду всякие VO в аргументах сущностей, хотя если DTO часть домена, то и билдеры норм, вопрос вкуса, мне нравятся простые фабрики.

Простые фабрики тоже ничего.

Интересно и по теме, хотя и не совсем непосредственно ответ. Команды (DTO) и их обработчики я отношу к прикладному слою, а не к домену, и скармливаю их командной шине. Статья не о том в целом, вы скорее развиваете вопрос. Любопытно, спасибо за ответ и за DDD!

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

> DTO это про передачу данных.
Да, я и говорю — «команды», сообщения CQRS (Запросы, Команды, События) и есть обычные DTOшки. Я применял Гексагональную архитектуру (порты и адаптеры) + CQRS. Очень эффективно и изящно получается, всё буквально само по местам становится. Рекомендую, изучите вопрос и опробовать. До этого я всячески пытался разделять слои, какие-то модули выделять, всё равно всё смазывалось.

Тут на Хабре тоже есть несколько неплохих статей и в сети, ну и у Э. Эванса не в деталях но достаточно описано. Вот, что под рукой есть:
https://msdn.microsoft.com/ru-ru/magazine/mt238399.aspx
https://msdn.microsoft.com/magazine/mt147237
https://habrahabr.ru/post/314536/

У Sufir "команды" это DTO для передачи данных между прикладным слоем и UI слоем (например HTTP). На этом слое у него, как я понимаю, реализация юзкейсов. Высокоуровневая логика, задающая последовательность действий и дергающая элементы слоя предметной области.


У меня к примеру "билдеры" для всяких сущностей действуют как DTO. То есть я их объявляю в слое предметной области и использую преимущественно в прикладном слое. И все что они делают — это декларируют наполнение данными таким образом, что бы я чего не забыл сделать.

У Sufir «команды» это DTO для передачи данных между прикладным слоем и UI слоем (например HTTP). На этом слое у него, как я понимаю, реализация юзкейсов. Высокоуровневая логика, задающая последовательность действий и дергающая элементы слоя предметной области.
Совершенно верно.

У меня к примеру «билдеры» для всяких сущностей действуют как DTO. То есть я их объявляю в слое предметной области и использую преимущественно в прикладном слое. И все что они делают — это декларируют наполнение данными таким образом, что бы я чего не забыл сделать.
Ну, приблизительно так и я сделал в описанном случае. В домене объявлены интерфейсы билдеров, а реализация — это конечно уже инфраструктура, вызываются они у меня только в прикладном (пока больше нигде не понадобились). Попробовал в качестве эксперимента, получилось неплохо.
В домене объявлены интерфейсы билдеров

А можете привести пример такого интерфейса? Ибо мне что-то кажется что мы под термином "билдер" понимаем несколько разные вещи.

Может и так, он же в статье есть:

interface ClientBuilder
{
    public function buildClient(): Client;

    public function setId(ClientIdentity $id): ClientBuilder;

    public function setCorporateForm($corporateForm): ClientBuilder;

    public function setName($name): ClientBuilder;

    public function setGeneralManager(Manager $generalManager): ClientBuilder;

    public function setAddress(Address $address): ClientBuilder;
}


А вы о чём?

Если говорить про web+cli приложения на базе Symfony+Doctrine, то хорошо себя показала такая организация:


  • /Vendor/Project/Client — ограниченный контекст Client (Domain из постов похоже)
  • /Vendor/Project/Client/Domain — слой чистой бизнес-логики: сущности, объекты-значения, чистые бизнес-сервисы, интерфейсы инфраструктурных сервисов (в том числе репозиториев), а также других ограниченных контекстов
  • /Vendor/Project/Client/Infrastructure — слой реализации интерфейсов из слоя бизнес-логики, маппинг сущностей на БД (никаких Doctrine аннотаций в самих сущностях), использует различные Symfony components как параметры конструкторов, но о фреймворке и обычно его DI-контейнере ничего не знает
  • /Vendor/Project/Client/App — слой взаимодействия Symfony (AppBundle) и ограниченного контекста
  • /AppBundle/{Controller,Command,Security, ...} — веб-контроллеры, cli-команды, аутентификация и авторизация, в общем вся специфика UI
  • /app/config — конфигурация для (из домена) слоя взаимодействия и инфраструктуры

Слой бизнес-логики не зависит вообще ни от чего, кроме стандартной библиотеки и подобных ей типа \Doctrine\Common или библиотек предметной области, но требует конкретных инстансов своих интерфейсов из слоя инфраструктуры.


Слой инфраструктуры реализует последние и формально обычно от фреймворка не зависит (но обычно зависит от сторонних библиотек, включая Symfony Components).


Слой взаимодействия принимает в качестве параметров методов описанные в нём DTO типа ClientRegisterRequest или тупо ассоциативные массивы как результат json_decode и т. п., содержащую всю информацию и инстансы инфраструктурных сервисов и возвращает, если надо, либо скаляры, либо объекты модели, либо, если требуется сильная изоляция, DTO типа ClientRegisterResponse или массивы. Эти параметры он преобразует в скаляры или доменные объекты, создавая их при необходимости (обычным new Client() или Clinet::register, сюда же билдеры), вызывает их методы, преобразует если надо результаты.


Контроллеры, команды и т. д. Symfony принимают Request или подобные параметры UI, преобразуют его (явно или с помощью параметров FrameworkExtra FOSRestBundle и т. п., в DTO слоя взаимодействия, достают из DI-контейнера нужный сервис слоя взаимодействия (инициализируется по конфигу параметрами слоя инфраструктуры), дергают его, вызывают ObjectManager::flush() и возвращают клиенту результат.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации