Комментарии 219
Изучение предметной области, формирование единого языка, выделение ограниченных контекстов, документирование, проектирование, описание и формализация в виде диаграмм или каким-то ещё способом отношений и бизнес-процессов и ещё много чего, всё это невозможно объять одной статьей, даже одной книгой если только в общем виде. О диаграммах, схемах и документировании в книге Эванса так же есть отличная глава. Как я указал вначале, всё это выходит за рамки данной статьи. Тут я просто поделился решением пары конкретных проблем, с которыми сам столкнулся в своём небольшом опыте.
UML-проектирование, это именно об этом.
https://ru.m.wikipedia.org/wiki/Диаграмма_классов
То что тема обширная — не оправдание тому, чтобы в статье хаотично все смешать в одну кашу. Есть проектирование и есть реализация. Проектирование — ERM/UML/IDEF/ARIS/итд., реализация — SQL/ORM/%language_name%/итд.
Если ваша статья про проектирование, то где диаграммы? Если про реализацию… то где диаграммы, которые вы реализуете?)) Не надо приучать новичков к плохому) Всегда начинайте любое проектирование с хоть какой-нибудь схемы)
Во-первых, иногда код программы сам по себе достаточно выразительный. Во-вторых, некоторые вещи на диаграмме не разглядеть (например, возвращаемое значение в сеттерах).
Но несомненно, всегда лучше начать с того, что бы немного порисовать, как я и сказал, к данному моменту вы «изучили предметную область, сформировали единый язык, выделили ограниченные контексты и определились с требованиями», написали проектную документацию, в соответствии с требованиями и принятым регламентом в вашей компании, почистили зубы и возможно много чего ещё сделали. Здесь речь о проектировании класса, это я делаю без схем.
Ну, и как я уже писал выше:
О диаграммах, схемах и документировании в книге Эванса так же есть отличная глава.Рекомендую, там подробнее.
Проектирование — 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?
Вы предлагаете для каждого возможного варианта инстанциации класса реализовывать по одному статичному конструктору.
Это вы уже нафантазировали.
Именованные конструкторы. Более правильным решением будет не использовать дефолтный конструктор совсем, а пользоваться для этого именованными конструкторами, которые так же будут явным образом отображать единый язык и предметную область.
Когда появляется новое бизнес-правило, которое требует регистрацию клиента без менеджера вы вводите ещё один именованный конструктор, который явно выражает это требование.
Правда?
Как проверять, соответствует ли клиент бизнес-требованию «оформить заказ можно только для Клиента у которого есть Менеджер» я показал выше:
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();
}
// ... оформляем Заказ
}
}
http://gorodinski.com/blog/2012/05/19/validation-in-domain-driven-design-ddd/
http://verraes.net/2015/02/form-command-model-validation/
А какого поведения вы ожидаете, например, от QueryBuilder'а, если не укажите таблицу?
Вот смотрите: я не указываю таблицу и...? Это валидный sql запрос для определнных СУБД: SELECT 1 на PostgreSql отрабатывает отлично.
Я соглашусь с Delphinum — валидность / невалидность объекта определяет (возможно должна определять) бизнес логика, хотя в рамках конкретных задач бывает и так, что и сам объект успешно справляется с этой задачей, например, на уровне деклараций.
валидность / невалидность объекта определяет (возможно должна определять) бизнес логика
Я сторонник мнения, что валидироваться объекты должны на основании контекста их использования. Так, состояние сущности Клиент может быть валидно для сохранения в базу, но не валидно для оформления заказа. Валидность так же может быть вложенной, на пример оформление заказа требует как валидности для сохранения в базу, так и дополнительных проверок.
Реализуется это достаточно просто:
<?php
class Client{
...
/**
* @return InvalidCollection
*/
public function isValidForPersist(){...}
/**
* @return InvalidCollection
*/
public function isValidForCreateOrder(){...}
}
Я не работал с php, не знаю как принято в проектах на нем, но зачастую даже сама логика валидации может жить в отдельном классе по объекту и вызываться непосредственно перед требуемой задачей. С этой точки зрения объект — он как контейнер данных, хранит ровно то, что в него положили, а достаточно это или нет решает, как правильно подмечено, сам 'контекст'.
Я не работал с php, не знаю как принято в проектах на нем
Я работаю с php и, к сожалению, не встречал еще ни одного проекта, в котором применялась бы контекстуальная валидация. Да даже на github не нашел ничего, что заинтересовало бы в этом вопросе. Пришлось писать свое )
но приятно иметь библиотеку готовых валидаторов мыла, минимальной/максимальной длины и прочее-прочее-прочее
Приятно конечно. Мое решение позволяет подключить любой набор валидаторов, будь то symfony, zend или yii инкапсулировав их в предлагаемую мной структуру.
компонент симфони позволяет не замусоривать код валидируемых объектов?
Ну организовать фабрику, которая будет генерировать валидатор на основании yml/xml не проблема, если это необходимо в проекте.
В целом я с вами согласен, но вот методы я бы назвал по-другому. isValidForPersist
— это, в моем понимании, просто isValid
. Потому что объект, который нельзя сохранить в базу — заведомо непригоден ни для чего другого.
А isValidForCreateOrder
я бы сократил до canCreateOrder
.
И чем это отличается от задумки автора? и у него и у вас есть валидация объекта. Разница в том, что вы запилили валидацию в сам обобьет, что само по себе не очень хороший вариант, потому как Клиент у вас теперь знает что-то про окружающий мир. Вы говорили, что проблема автора в копировании валидатора, это не проблема. Вынесите ваши 2 метода в отдельный сервис, и проводите валидацию в любом месте вашего приложение используя валидатор. Таким образом у вас валидация обьекта будет в зависимости от контекста. Я бы сказал, что вы с автором говорите об одном и том же, но используете разную архитектуру.
"Валидность для сохранения в базу" — это в целом бесполезная метрика. Любой объект который соблюдает свои инварианты по умолчанию "валиден для сохранения в базу". За соблюдением инвариантов должен следить сам объект.
Что до примера с hasManager
и подобными — абсолютно согласен, все это надо изолировать в сущности и сделать метод canCreateOrder
или вообще возложить на юзера ответственность за формирования ордера.
И да, сделав методы hasManager
мы тем самым ломаем инкапсуляцию.
это в целом бесполезная метрика
Это одна из самых важных метрик. Вопрос скорее в том, следует ли ее выделять в отдельный валидатор или использовать метод вида canPersist, но валидировать однозначно стоит все с тем же механизмом формирования причин, по которым это самое сохранение не возможно.
У меня есть мысль (правда не уверен насколько правильная), что все свойства бизнес-сущности должны быть доступны снаружи на чтение. Потому что мы их определили при анализе предметной области, при взгляде со стороны. Если бы это были детали реализации, мы бы их не обнаружили. Детали реализации — это, скажем, когда секретарша подписывает документы за шефа, потому что он ей разрешил. Официально это бумага, подписанная шефом. Мы об этом не узнаем, если только кто-нибудь из них сам не скажет.
Другое дело, что иногда одной сущности необязательно знать, что у другой сущности есть менеджер. Но это уже требования бизнес-логики для конкретного случая, а не требования архитектуры.
Потому что мы их определили при анализе предметной области, при взгляде со стороны.
Сделайте read model и там используйте read-only публичные поля. Не вопрос. Ну там сериализации всякие и подобное. Хотя и это под вопросом (для этого придумали DTO). А для остальных случаев вам в целом нет смысла делать публичными поля — к ним никто не должен даже хотеть получать доступ. Ну то есть не нужны ни геттеры ни публичные поля. Стэйт остается в том объекте где он нужен. Если вам нужен стэйт другого объекта — значит что-то при декомпозиции пошло не так.
Так я как раз не про сериализацию, а про бизнес-логику.
Если у товара есть характеристика "Цвет корпуса: черный", то он доступен всем — и клиенту, и продавцу, и начальнику, который видит, что черные лучше продаются. Причем именно в виде бизнес-свойства бизнес-сущности "Товар". А как мы его храним — в виде EAV или обычного столбца в таблице — это уже детали реализации.
А потом начальник вводит правило — при покупке белого начислять покупателю 100 бонусов. Как-то нелогично передавать в оформление заказа DTO, а не сущность.
Если надо сохранять в заказе текущее состояние сущности на момент заказа, то ничего не мешает это состояние прочитать. Поэтому и нужен публичный доступ на чтение. Мы передаем сущность, из нее берутся необходимые данные, причем часть может дергаться по связям (название организации), другая часть из вычисляемых полей (возраст/инициалы). В DTO придется явно указывать все что надо, и при изменении процедуры оформления заказа править все это во всех местах вызова.
А потом начальник вводит правило — при покупке белого начислять покупателю 100 бонусов. Как-то нелогично передавать в оформление заказа DTO, а не сущность.
Попробуйте реализовать такое вот требование в рамках магазинчика. И сделать это без геттеров и вообще попыток добраться до стэйта. Если надо достать стэйт — значит либо логика должна быть в этом же объекте, либо мы должны объекту дать сервис какой-то которому объект делегирует логику и передаст нужные данные.
Все остальное — нарушение инкапсуляции. И всегда можно ее сохранять если достаточно подумать.
p.s. короч свойства объекта, как не крути, это детали объекта. Они должны быть спрятаны. То есть у доменных объектов уж точно не должно быть публичных пропертей.
но которые должны быть в представлениях
Я уже говорил про представление. В них сущности пихать не нужно.
прежде всего печатных формах по требованию госорганов?
$report->print($pinter);
Да и вообще много может быть свойств у различных сущностей в текущих процессах предназначенных исключительно для представления.
Read Model, DTO. В целом нужно смотреть с позиции SRP. Ну и да, иногда проще фигануть геттеров пачку для представления.
Если мы посмотрим например на java а не на php, то там мы бы ассемблер DTO ложили бы в тот же пакет что и сущность. И тогда у ассемблера появился бы доступ к состоянию объекта. Еще как вариант — friend классы (для PHP есть RFC но когда ее примут и примут ли я не знаю). То есть смысл в том что если и экспоузить состояние, то явно не для всех а только для "доверенных лиц" скажем так.
То что в PHP это нельзя сделать нормально — ну печаль беда. Потому мне больше нравится идея делать выборки из базы на чтение прямо в DTO минуя мэппинги сущности и unit-of-work.
Суть в чём. Если мы делаем изоляцию домена от приложения (контроллера/вью) с помощью private/protected без геттеров, а в домене/фасаде обращаемся к ним через Reflection, когда нам надо, то разработчик контроллера/вью сможет делать то же самое. Административный запрет "не использовать Reflection нигде кроме домена" равносилен "не использовать геттеры сущностей нигде кроме домена" — если будут нарушать второй, то вряд ли будут соблюдать первый. А если будут соблюдать первый, то можно ограничится вторым, чтобы не усложнять домен доступом через отражения.
то разработчик контроллера/вью сможет делать то же самое
Почему это? Нет, разработчик конечно может делать то же самое, тут уж ничего не попишешь, остается только бить по рукам, но можно ведь все это организовать кошернее.
Приведу пример:

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

Работа с сессией на уровне контроллера не осуществляется, а выглядит как то так:

Если UserSessionInterface реализовать через Reflection, то контроллеру или вью вообще не нужно будет получать состояние доменной сущности (по аналогии с работой пракически любой ORM).
Кажется, не очень понимаю задачу, которую вы решаете. Как в вашей схеме показать пользователю регион, иными словами как передать его во вью?
А у нас дискуссия собственно о том, должен ли быть метод User::getRegion в сущности в принципе и доступным в контроллере/вью в частности. С моей точки зрения в большинстве случаев затраты на возврат в контроллер+вью DTO, да ещё собранного с помощью Reflection, не стоят получаемых при этом плюсов. По крайней мере если писать весь этот код вручную.
В остальном считаю, что 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, если он свою невалидную форму умудрился пропихнуть через фильтры фронтенда, контроллера и сервиса приложения.
Вы предпочитаете так, ваше дело, я предпочитаю не смешивать слои, а разделяю ответственности в соответствии с 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
не подходит в качестве метода контекстной валидации? :)
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 который должен выполняться.
валидном состоянии
Валидном для чего?
Автор идет по другому пути, при котором переход в конкретное состояние невозможен в принципе, и не важно, почему.
Symphony workflow позволяет определить причину невозможности перехода?
Как в примере с заказами — создание заказа не изменяет сущность клиента
Но изменяет состояние системы. Если рассматривать проблему с этой точки зрения, то контекстная валидация позволяет проверять валидность операции изменения состояния всей системы или ее частей. В случае инварианта, предполагается, что раз сущность имеется, значит операция позволена. На мой взгляд и опыт это ошибочное суждение.
В случае инварианта предполагается лишь, что сущность всегда находится в корректном состоянии и не допустит приведение себя в некорректное в рамках выполнения одной своей операции, просто не даст своим клиентам выполнить такую операцию, которая приведёт её в некорректное состояние. Клиенты не должны делать предположений о допустимости той или иной операции и всегда ожидать, что сущность откажется её выполнять.
Буду благодарен за отзывы и конструктивные замечания.
Что так: суть статьи и ее посыл понятны + делитесь опытом — за это спасибо.
Что не так для меня:
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 сущность нужно восстанавливать из хранилица тоже консистентной, даже если вы не пользуетесь всей сущностью.
Но всеравно есть агрегаты, которые тяжело восстанавливаются (Ну например, зачем восстанавливать аггрегат заказа с 1000 строк в заказе? везде и всегда)
Есть ленивая загрузка графов объектов из базы. В контексте PHP Doctrine это умеет более-менее. А Value object в отдельной табличке обычно костыль, чтобы применить ту схему данных, которая нравится, а не которое предлагается той же Doctrine.
В CQRS подходе DDD у вас живет только в коммандах, квери должны быть очень упрощенными и быстрыми.
Как правило с отдельным индексом и без тяжелого ORMа
у вас в квери сценарии будут какие-то недоинициализированные сущности, с нерабочими интерфейсамми, которые кидают эксепшены на вызовах определенных методов.
В этом и фишка CQRS — у вас две архитектуры, для чтения и записи. И масштабируете вы их тоже отдельно. Это не недостаток, а достоинство подхода. А вы хотите это нивелировать, пытаясь это уложить на одну кодобазу.
А по поводу вопросов дороговизны. Если ваши сущности иммутабельны, т.е. не изменяемы, то вы вполне можете их шарить по всему приложению, так как никто не сможет их поломать изменив глобальный стейт. И у вас есть некий Object Manager — глобальный контейнер в который кладутся уже созданные сущности и который отвечает за создание новых, которые требуются параметрами конструкторной dependency injection. То это сильно сократит стоимость.
Но если у вас очень нагруженное приложение и требование по перформансу очень высоки, то DDD может быть действительно не для вас.
Но конкретно в вашем примере нельзя Client называть Enitity. У вас это классический Data Transfer Object, т.к. вы показали что он хранит только данные и аксессоры к этим данным. И никаких методов бизнесс логики
Поэтому может просто имеет смысл выделить отдельную сущность — доменную модель. А DTO оставить как DTO. Сериализируемый объект, который всегда можно передать по сети.
В теории вроде разницы нет, но на практике DTO между доменной моделью и слоем хранения данных сильно замедляет разработку, не давая особых плюсов, пока возможностей используемой ORM не ограничивают (или ограничивают но не сильно) доменную модель. А когда ограничивают, то чаще всего это уже просачиваются через ORM ограничения РСУБД, а не самой ORM. Ситуации когда четко видишь как разработанная модель красиво ложится на SQL БД, но при этом ORM не даёт так положить — редки.
Контракт сущности (entity) не должен состоять из getter/setter к данным из которых эта сущность состоит
Для идентификации сущностей лучше использовать именно UUID, это лучше и с точки зрения безопасности, ведь часто авто инкременты фигурируют в URL, откуда третьи лица могут, например, сделав дифф двух значений, узнать сколько у вас было транзакций в день, что может являться бизнес тайной.
А по поводу шаблона проектирования «строитель», я думаю, для вашего примера он не очень подходит, обычно его используется для сложных объектов, где каждый параметр в конструкторе не обязателен и может быть, например, нулом.
Есть хороший туториал от создателей doctrine.
Для идентификации сущностей лучше использовать именно UUID
Нет однозначного ответа, что лучше. UUID лучше с точки зрения простоты разработки и безопасности, но хуже с точки зрения юзабилити (ссылку по телефону затруднительно передать будет, например или отсортировать объекты в порядке создания без создания дополнительных полей) и, обычно, потребляемых ресурсов, особенно если бинарное представление недоступно и используется какое-то из символьных.
Можно скинуть, например, в мессенджер
Вы это скажите тёте-секретарю 50 лет, которая забивает документы в СЭД и периодически звонит в УФССП для уточнения чего либо по определенному делу. Нет, пользоваться скайпом/мылом/аськой и т.д. она не умеет, с трудом научили вбивать данные в поля и нажимать «Сохранить».
авто инкременты лучше не показывать пользователям с точки зрения безопасности и конфиденциальности бизнеса.
Где слово «публичные»?
Причем можно иметь сразу два идентификатора для каждой сущности
Значит я вас не правильно понял, прошу простить. Полностью согласен, для человека лучше применять отдельный формат нумерации, а не идентификатор сущности.
Не согласен с тобой относительно билдеров.
У меня тут очень простая позиция. У методов не должно быть более 3-х аргументов. То есть если у сущности есть 5 обязательных параметров, вместо того что бы делать конструктор с 5-ю аргументами проще сделать билдер. Это будет и читабельнее, и удобнее в использовании.
В целом для сущностей я обычно по умолчанию билдеры юзаю. Для VO уже намного реже. Иногда вместо билдера еще фабрики можно делать + dto какое-то… это не столь важно. Важно чтобы все данные передавались одним пакетом и связанность по данным была ниже.
Что до туториалов: вот еще на эту тему
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.
в том, что чтобы понять что эта строчка делает надо несколько раз «прыгнуть» между этой строчкой и определением метода 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);
А то и лучше, ведь от скаляров никуда не делись.
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)
);
- больше простора для дальнейших действий
- меньше связанность по данным (не так больно добавлять новый обязательный параметр)
- проще хэндлить валидацию
- в тестах можно реюзать билдеры:
$customer = UserFixtures::customerBuilder()->build();
$referral = UserFixtures::customerBuilder()->setIntroducer($customer)->build();
выходит весьма и весьма удобно.
Не понимаю насчет связанности
Есть такой вид связности — связность по данным.
ты отвязываешь сущность от доменных объектов и привязываешься к хелперу-билдеру
сущность и есть доменный объект. Я просто делаю что-то типа объекта-сообщения который содержит все необходимые сущности данные (вместо полотнища аргументов которые не читабельный и неудобны при расширении логики).
И это не "хелпер-билдер", это часть предметной области. Те данные которые необходимы для регистрации пользователя. DTO если хочешь.
Если для создания сущности или каких-то значений нужно что-то больше чем делегация сервисов в методы билдера, то тогда выгодно использовать отдельную фабрику которая будет делать юзера через все те же билдеры.
Мой комментарий получился немного великоват и я оформил его в виде отдельной статьи:
https://habrahabr.ru/post/321892/
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() и возвращают клиенту результат.
Введение в проектирование сущностей, проблемы создания объектов