В данной статье описываются две такие проблемы, и рассматривается способ их решения. Так же статья подойдет как введение в проектирование сущностей. Для понимания материала понадобится базовое представление о предметно-ориентированном проектировании.
Итак, мы изучили предметную область, сформировали единый язык, выделили ограниченные контексты и определились с требованиями [2]. Всё это выходит за рамки данной статьи, тут мы попробуем решить конкретные узкие проблемы:
- Создание и обеспечение консистентности сложных объектов-сущностей.
- Создание объектов-сущностей с генерацией идентификатора по автоинкрементному полю базы данных.
Введение
У нас есть клиент, который должен быть смоделирован как сущность (Entity) [2]. С точки зрения бизнеса у каждого клиента обязательно есть:
- имя или наименование;
- организационная форма (физ. лицо, ИП, ООО, АО и т.д.);
- главный менеджер (один из менеджеров, закрепляется за клиентом);
- информация о фактическом адресе;
- информация о юридическом адресе.
А так же может быть всевозможная дополнительная информация.
В простом случае класс, реализующий моделируемую сущность, может выглядеть следующим образом.
namespace Domain;
final class Client
{
public function getId(): int;
public function setId($id): void;
public function setCorporateForm($corporateForm): void;
public function setName($name): void;
public function setGeneralManager(Manager $manager): void;
public function setCountry($country): void;
public function setCity($city): void;
public function setStreet($street): void;
public function setSubway($subway): void;
}
Целый ряд свойств мы опустили, для упрощения примера, реальный класс может быть значительно больше.
Полученная модель анемичная, перегружена ничего не значащими сеттерами вместо методов описывающих поведение соответствующее бизнес-требованиям. В любой момент, при таком подходе, можно создать объект в неконсистентном состоянии или нарушить бизнес-логику, установив один из параметров например так.
$client = new Client();
// В данный момент клиент у нас уже находится в не консистентном состоянии
// Если мы хотим запросить его идентификатор, то получим ошибку, т.к. он ещё не установлен
$client->getId();
// Или мы можем сохранить (попытаться) не валидного клиента, у которого не установлены обязательные свойства
$repository->save($client);
Создание и обеспечение консистентности сложных объектов-сущностей.
Для начала попробуем решить проблему консистентности. Для этого уберем из класса сеттеры, а все обязательные параметры будем запрашивать в конструкторе [4]. Таким образом, объект будет всегда валиден, может использоваться сразу после создания и обеспечивается полноценная инкапсуляция предотвращающая возможность приведения клиента в неконсистентное состояние. Теперь наш класс выглядит следующим образом.
namespace Domain;
final class Client
{
public function __construct(
$id,
$corporateForm,
$name,
$generalManager,
$country,
$city,
$street,
$subway = null
);
public function getId(): int;
}
Проблему мы решили, но получилось не слишком изящно. 8 параметров у конструктора, и это не предел. Конечно, далеко не каждая сущность требует так много обязательных параметров, это, пожалуй, не совсем рядовая ситуация, но и не необычная.
Что можно с этим сделать? Простое и очевидное решение — сгруппировать логически связанные параметры в объектах-значениях (Value Object) [3].
namespace Domain;
final class Address
{
public function __construct($country, $city, $street, $subway = null);
}
namespace Domain;
final class Client
{
public function __construct(
int $id,
string $name,
Enum $corporateForm,
Manager $generalManager,
Address $address
);
public function getId(): int;
}
Выглядит гораздо лучше, но параметров всё ещё довольно много, особенно это не удобно, если часть из них скалярные. Решение — шаблон Строитель (Builder) [5].
namespace Application;
interface ClientBuilder
{
public function buildClient(): Client;
public function setId($id): ClientBuilder;
public function setCorporateForm($corporateForm): ClientBuilder;
public function setName($name): ClientBuilder;
public function setGeneralManager(Manager $generalManager): ClientBuilder;
public function setAddress(Address $address): ClientBuilder;
}
$client = $builder->setId($id)
->setName($name)
->setGeneralManagerId($generalManager)
->setCorporateForm($corporateForm)
->setAddress($address)
->buildClient();
Таким образом, моделируемая сущность всегда находится в консистентном состоянии и при этом может быть гибко и понятно построена, не зависимо от сложности создаваемого объекта и количества параметров.
Создание объектов-сущностей с генерацией идентификатора по автоинкрементному полю базы данных.
У проектируемого класса обязательно должен быть уникальный идентификатор, т.к. основной отличительной чертой сущностей является индивидуальность. Объект может значительно изменяться с течением времени, так что ни одно из его свойств не будет равным тому, что было вначале. В то же время все или большинство свойств объекта могут совпадать со свойствами другого объекта, но это будут разные объекты. Именно уникальный идентификатор дает возможность различать каждый объект не зависимо от его текущего состояния [1].
Существуют различные способы формирования идентификаторов, такие как пользовательский ввод, генерация в приложении по какому-либо алгоритму, может генерироваться базой данных.
Cгенерировать, например, UUID или запросить у базы данных очередное значение последовательности не составляет никаких трудностей. Но иногда возникает необходимость работать с уже существующей структурой БД, в которой таблица хранящая соответствующие данные имеет для идентификатора автоинкрементное поле.
В таком случае крайне затруднительно и ненадежно получать очередной уникальный идентификатор без сохранения сущности, а значит невозможно создать объект в полностью консистентном состоянии.
И снова нам поможет в этом шаблон Строитель, который мы можем реализовать следующим образом.
namespace Infrastructure;
final class MySqlClientBuilder implements ClientBuilder
{
private $connection;
public function __construct(Connection $connection);
public function buildClient()
{
$this->connection
->insert('clients_table', [
$this->name,
$this->corporateForm,
$this->generalManager->getId(),
$this->address
]);
$id = $this->connection->lastInsertId();
return new Client(
$id,
$this->name,
$this->corporateForm,
$this->generalManager,
$this->address
);
}
}
Таким образом мы сначала добавляем соответствующую запись в базу данных, после чего получаем её идентификатор и создаем объект. Клиенту об этом знать не обязательно и при изменении способа хранения или генерации идентификаторов вам понадобится только заменить реализацию Строителя в вашем контейнере зависимостей.
$builder = $container->get(ClientBuilder::class);
$client = $builder->setId($id)
->setName($name)
->setGeneralManagerId($generalManager)
->setCorporateForm($corporateForm)
->setAddress($address)
->buildClient();
$repository->save($client);
$client->getId();
Благодарю за внимание!
P.S.:
Прошу не судить слишком строго, «чукча не писатель, чукча читатель». Для более опытных разработчиков описываемые вещи могут показаться банальными, я же к описанному пришел не сразу, но когда применил описанный подход, он зарекомендовал себя отлично.
Опыт разработки у меня относительно не большой — четыре года, DDD применял пока только на одном проекте.
Буду благодарен за отзывы и конструктивные замечания.
Ссылки:
- Эванс Э., «Предметно-ориентированное проектирование (DDD). Структуризация сложных программных систем.»
- Вернон В., «Реализация методов предметно-ориентированного проектирования.»
- М. Фаулер, Value Object
- М. Фаулер, Constructor Initialization
- Э. Гамма, Р. Хелм, Р. Джонсон, Д. Влиссидс, «Приёмы объектно-ориентированного проектирования. Паттерны проектирования.»
Комментарии (204)
Delphinum
07.02.2017 16:53+1В любой момент, при таком подходе, можно создать объект в неконсистентном состоянии или нарушить бизнес-логику
Состояние сущности может быть не валидно для одной цели, и валидно для другой. Тут мы упираемся в «Контекстуальную валидацию».
Таким образом, моделируемая сущность всегда находится в консистентном состоянии и при этом может быть гибко и понятно построена, не зависимо от сложности создаваемого объекта и количества параметров
А что мешает программисту создать клиента с невалидным состоянием не вызывав пару методов Строителя:
$client = $builder->setId($id) ->setName($name) ->setGeneralManagerId($generalManager) ->buildClient();
Таким образом мы сначала добавляем соответствующую запись в базу данных, после чего получаем её идентификатор и создаем объект.
Как вариант, но что делать с этими созданными объектами в случае возникновения ошибки в системе? Может проще не использовать генерацию PK на стороне СУБД, а воспользоваться генераторами UUID?Sufir
07.02.2017 17:24+1Состояние сущности может быть не валидно для одной цели, и валидно для другой. Тут мы упираемся в «Контекстуальную валидацию».
Это не верно, сущность может быть либо валидна либо нет, если под валидностью подразумевать непротиворечивость её состояния с точки зрения бизнес-требований. А какие инварианты для неё доступны — это инкапсулировано в саму сущность, отсутствие сеттеров гарантирует, что эти инварианты нарушены не будут.
Если это одна сущность, то её контексты зашиты у ней внутри. В рамках же разных ограниченных контекстов проектируются разные объекты. Один объект, являющийся в данном контексте сущностью, в другом даже может быть представлен как объект-значение. В общем это тема отдельная и достаточно масштабная, подробнее у Эванса, Вернона и Фаулера.
А что мешает программисту создать клиента с невалидным состоянием не вызвав пару методов Строителя
Во-первых, ваша реализация строителя может (и должна) выбросить RuntimeException или скорее LogicException
Во-вторых, если в Строителе вы забыли проверить, то ваш скрипт упадет с TypeError или ArgumentCountError.
В общем это уже ошибка разработчика и должна быть разрешена разработчиком. Строитель просто дает возможность создавать сложный объект более гибко и прозрачно.
Может проще не использовать генерацию PK на стороне СУБД, а воспользоваться генераторами UUID?
Это прекрасный вариант и когда это возможно я так делаю, мне нравится UUID, так же я запрашивал идентификаторы у последовательностей Postges, но речь о конкретном случае. Структура БД уже существует и изменить её невозможно, я нашел вот такое решение и оно неплохо вписалось.
А если появится возможность перейти на MySQL или использовать UUID, то без проблем я только заменю реализации Строителей.Delphinum
07.02.2017 17:39Это не верно, сущность может быть либо валидна либо нет
Представим сущность Клиент с тем же набором свойств, что предлагаете вы. Вы всем пользователям отказываете в создании Клиента без указания GeneralManager. Через n времени пользователь обращается к вам с просьбой дать возможность создания Клиента без указания GeneralManager с целью регистрации всех Клиентов, но запретить оформлять на этого Клиента заказы, при отсутствии у него GeneralManager. Как вы будете решать эту задачу?
Во-вторых, если в Строителе вы забыли проверить
Что проверить?Sufir
07.02.2017 18:07Что проверить?
Очевидно наличие необходимых для создания объекта параметров. А какого поведения вы ожидаете, например, от QueryBuilder'а, если не укажите таблицу? Он может вам построить кривой SQL без указания FROM и ваш скрипт будет падать при попытке его выполнить, либо в момент построения билдер проверит наличие обязательных параметров и выбросит исключение. Третьего я здесь не вижу.
Представим сущность Клиент с тем же набором свойств, что предлагаете вы. Вы всем пользователям отказываете в создании Клиента без указания GeneralManager. Через n времени пользователь обращается к вам с просьбой дать возможность создания Клиента без указания GeneralManager с целью регистрации всех Клиентов, но запретить оформлять на этого Клиента заказы, при отсутствии у него GeneralManager. Как вы будете решать эту задачу?
В соответствии с DDD, бизнес-правила явным образом выражены в коде, есть явное бизнес-требование: «клиент не может быть без менеджера, при регистрации за ним обязательно закрепляется менеджер». Изменение бизнес требований требует внесения изменений в код.
А вот дальше мы затрагиваем тоже очень интересный вопрос, который я хотел осветить в статье, но посчитал уже излишним, возможно напрасно.
Именованные конструкторы. Более правильным решением будет не использовать дефолтный конструктор совсем, а пользоваться для этого именованными конструкторами, которые так же будут явным образом отображать единый язык и предметную область.
Client::register(...): Client;
Когда появляется новое бизнес-правило, которое требует регистрацию клиента без менеджера вы вводите ещё один именованный конструктор, который явно выражает это требование.
// имя должно характеризовать бизнес требование и соответствовать единому языку Client::registerWithoutClient(...): Client;
Delphinum
07.02.2017 18:17+1А какого поведения вы ожидаете, например, от QueryBuilder'а, если не укажите таблицу?
А откуда Строителю известно, для каких целей я создаю Сущность? Другими словами, что если мне нужен SQL именно без FROM?
Когда появляется новое бизнес-правило, которое требует регистрацию клиента без менеджера вы вводите ещё один именованный конструктор, который явно выражает это требование
А если таких требований десятки, будет десяток конструкторов? А как же Строители? А как пользователь узнает о проблемах, приведших к отказу в оформлении заказа на данного Клиента (без GeneralManager)?
Давайте я приведу другой пример: у вас есть сущность Клиента, и для оформления Заказа этому клиенту необходимо наличие у него как Адреса, так и ОтветственногоМенеджера. При этом сохранить клиента можно без этих сведений, а вот для отгрузки товара по Заказу достаточно знать только Адрес Клиента. Вы предлагаете создать 3 конструктора вида: register, registerWithoutManager, registerFull — или как?Sufir
07.02.2017 18:35Билдер не знает для каких целей, его это не касается. Он знает как построить и знает, что если у него нет цемента, то он не сможет построить кирпичную кладку. А как будет использоваться постройка — дело заказчика, строителю без разницы. Всё это уже похоже на софистику, просто оставим.
А если таких требований десятки, будет десяток конструкторов?
Разумеется, если есть требование, то оно должно быть выражено в коде, в этом и смысл. Правда десятки бизнес-правил создания сущности мне представить сложно. Я поделился конкретным опытом и вполне возможна ситуация где он окажется неэффективным или вовсе неприемлемым.
Ну, вот я тут вижу три размытых бизнес-требования. И при чем здесь конструкторы мне не понятно. Вы собираетесь в конструкторе осуществлять «оформления Заказа» и «отгрузки товара»?
1. «сохранить клиента можно без этих сведений»
Значит Client::register(...): Client; будет без менеджера и адреса или они будут необязательными.
2. «для оформления Заказа этому клиенту необходимо наличие у него как Адреса, так и ОтветственногоМенеджера»
3. «для отгрузки товара по Заказу достаточно знать только Адрес Клиента»
А вот эти два бизнес-правила к регистрации клиента (а значит и к конструкторам) уже не имеют никакого отношения.Delphinum
07.02.2017 18:39Речь не о регистрации Клиента, а о валидации состояния сущности. Для регистрации Клиента требуется одна валидация, для создания Заказа требуется другая валидация (возможно связанная с первой). Я об этом сказал здесь:
Состояние сущности может быть не валидно для одной цели, и валидно для другой. Тут мы упираемся в «Контекстуальную валидацию».
Далее я повел рассуждения относительно вот этого вашего комментария:
Это не верно, сущность может быть либо валидна либо нет, если под валидностью подразумевать непротиворечивость её состояния с точки зрения бизнес-требований
Будет ли сущность Клиент валидна, без Адреса и ОтветственногоМенеджера? Для сохранения вполне, но для создания Заказа — нет. Отталкиваясь от ваших рассуждений, такая ситуация невозможна. Парадокс )Sufir
07.02.2017 18:52Да вы вообще не о том сейчас.
Для регистрации Клиента требуется одна валидация, для создания Заказа требуется другая валидация
Это два разных бизнес-процесса связанных с одной сущностью.
Вот вы же сами цитируете:
сущность может быть либо валидна либо нет, если под валидностью подразумевать непротиворечивость её состояния с точки зрения бизнес-требований
Есть конкретное бизнес-требование "клиент может быть зарегистрирован без менеджера". Не важно, что я могу его сохранить в БД и вообще что храню в БД, в бизнесе нет никаких «сохранений» и «баз данных». Есть «регистрация клиента» — конкретное оговоренное документированное требование, которое позволяет регистрировать клиентов без менеджера.
Есть другое бизнес-требование "для оформления Заказа у Клиента должен быть Менеджер", вот там вы и будете проверять наличие менеджера и обрабатывать эту ситуацию так как оговорено в соответствии с данным бизнес-требованием.Delphinum
07.02.2017 18:58Видимо я плохо излагаю свою идею. Ну ладно, не может так не может )
Sufir
07.02.2017 19:10Вы просто валите всё в кучу.
// первое бизнес-правило - регистрация клиента (тут менеджер не обязателен) Client:register($id, ..., $manager = null); // второе бизнес-правило - оформление заказа (менеджер обязателен) Order::checkout($client, ...); class Order { function checkout($client, ...) { if (!$client->hasManager()) { throw new ClientHasntManagerException(); } } }
Delphinum
07.02.2017 19:13А как ваше решение справится с кодом:
<?php $client = new Client(); $client->setManager(new Manager)); Order::checkout($client, ...);
Sufir
07.02.2017 19:23Что значит «справится»? Оно противоречит моему решению.
В соответствии с моим решением оно будет выглядеть:
$client = $clienBuilder->build(); $manager = $managerBuilder->build(); // или скорее $manager = $managerRepository->get(); // метод выражает бизнес-действие "смена менеджера клиента" $client->changeManager($manager)); Order::checkout($client, ...);
Только это всё уже несколько за рамками статьи.Delphinum
07.02.2017 19:31Что значит «справится»? Оно противоречит моему решению
Вы предлагаете для каждого возможного варианта инстанциации класса реализовывать по одному статичному конструктору. Они должны, по вашей логике, возвращать экземпляры класса в определенном, как вы это называете, консистентном состоянии. Как вы предлагаете валидировать созданную таким образом сущность далее? К примеру:
<?php $clientA = Client::register2($id, $name, $address); $clientB = Client::register2($id, $name); Order::checkout($clientA); // Допустимо Order::checkout($clientB); // недопустимо
Как в данном случае вы реализуете Order::checkout?Sufir
07.02.2017 19:43Вы предлагаете для каждого возможного варианта инстанциации класса реализовывать по одному статичному конструктору.
Это вы уже нафантазировали.Delphinum
07.02.2017 19:45Именованные конструкторы. Более правильным решением будет не использовать дефолтный конструктор совсем, а пользоваться для этого именованными конструкторами, которые так же будут явным образом отображать единый язык и предметную область.
Когда появляется новое бизнес-правило, которое требует регистрацию клиента без менеджера вы вводите ещё один именованный конструктор, который явно выражает это требование.
Правда?Sufir
07.02.2017 19:52Правда. Давайте по порядку, какое бизнес-правило выражает ваш метод «Client::register2»?
Как проверять, соответствует ли клиент бизнес-требованию «оформить заказ можно только для Клиента у которого есть Менеджер» я показал выше:
class Order { function checkout($client, ...) { if (!$client->hasManager()) { throw new ClientHasntManagerException(); } } }
Создание инстансов и тема статьи тут вообще не при чем.Delphinum
07.02.2017 19:54Давайте по порядку, какое бизнес-правило выражает ваш метод «Client::register2»?
Ну, предположим, первый регистратор регистрирует клиента с адресом (правило «клиент может быть зарегистрирован с адресом»), а второй регистрирует без адреса (правило «клиент может быть зарегистрирован без адреса»). Метод Order::checkout должен проверять возможность оформления заказа клиенту, но только с адресом.Sufir
07.02.2017 19:58Ну, а в чём проблема проверить наличие адреса?
class Order { function checkout($client, ...) { if (!$client->hasAddress()) { throw new ClientHasntAddressException(); } // ... оформляем Заказ } }
Delphinum
07.02.2017 19:59Ну как минимум в том, что требуется дублирование валидатора.
Sufir
08.02.2017 09:00Ознакомитесь с мнениями по данному вопросу ссылкам:
http://gorodinski.com/blog/2012/05/19/validation-in-domain-driven-design-ddd/
http://verraes.net/2015/02/form-command-model-validation/
reforms
07.02.2017 18:22А какого поведения вы ожидаете, например, от QueryBuilder'а, если не укажите таблицу?
Вот смотрите: я не указываю таблицу и...? Это валидный sql запрос для определнных СУБД: SELECT 1 на PostgreSql отрабатывает отлично.
Я соглашусь с Delphinum — валидность / невалидность объекта определяет (возможно должна определять) бизнес логика, хотя в рамках конкретных задач бывает и так, что и сам объект успешно справляется с этой задачей, например, на уровне деклараций.Delphinum
07.02.2017 18:26валидность / невалидность объекта определяет (возможно должна определять) бизнес логика
Я сторонник мнения, что валидироваться объекты должны на основании контекста их использования. Так, состояние сущности Клиент может быть валидно для сохранения в базу, но не валидно для оформления заказа. Валидность так же может быть вложенной, на пример оформление заказа требует как валидности для сохранения в базу, так и дополнительных проверок.
Реализуется это достаточно просто:
<?php class Client{ ... /** * @return InvalidCollection */ public function isValidForPersist(){...} /** * @return InvalidCollection */ public function isValidForCreateOrder(){...} }
reforms
07.02.2017 18:36+1С Вами соглашусь.
Я не работал с php, не знаю как принято в проектах на нем, но зачастую даже сама логика валидации может жить в отдельном классе по объекту и вызываться непосредственно перед требуемой задачей. С этой точки зрения объект — он как контейнер данных, хранит ровно то, что в него положили, а достаточно это или нет решает, как правильно подмечено, сам 'контекст'.Delphinum
07.02.2017 18:45Я не работал с php, не знаю как принято в проектах на нем
Я работаю с php и, к сожалению, не встречал еще ни одного проекта, в котором применялась бы контекстуальная валидация. Да даже на github не нашел ничего, что заинтересовало бы в этом вопросе. Пришлось писать свое )oxidmod
07.02.2017 19:37В symfony есть группы валидаций (https://symfony.com/doc/current/validation/groups.html)
В Yii есть сценарии, на основании которых валидируется модели
зы. Компонент симфони для валидации самостоятельный и может юзатся без симфониDelphinum
07.02.2017 19:43Мне решение symfony показалось очень усложненным, так как предлагается смешивать механизм контекстной валидации с валидацией данных как таковой, я предпочитаю разделять это на пакеты. Думаю у Yii аналогичная проблема.
oxidmod
07.02.2017 21:59В yii гораздо проще, но дело не в том. Не понравилось и ничего нету — разные вещи
Delphinum
07.02.2017 22:01Вы видимо плохо прочитали мой комментарий. Я не утверждал, что решения нету, я сказал, что не встречал еще ни одного проекта с ней и не нашел ничего, что меня бы заинтересовало в качестве ее реализации.
oxidmod
07.02.2017 22:19Да любой проект на симфони юзает компонент валидации.
Delphinum
07.02.2017 22:26Я не работаю с симфони, а на гитхабе не встречал, если есть ссылка на готовый проект с использованием контекстной валидации, то буду благодарен.
oxidmod
08.02.2017 00:12https://github.com/FriendsOfSymfony/FOSUserBundle/blob/master/Resources/config/storage-validation/orm.xml
Валидации сущности юзера в зависимости от контекста. Регистрация/редактирование профиля
oxidmod
08.02.2017 10:46Кстати, глянул ваш код, по сути это почти тотже компопнент симфони, но:
1. с обрезанными возможностями. в конечном итоге валидация любого поля конечно сводится к is, но приятно иметь библиотеку готовых валидаторов мыла, минимальной/максимальной длины и прочее-прочее-прочее. На практике всего пару раз пришлось писать свой валидатор для повторного использования разными объектами и пару раз в виде колбека для разового использования по месту необходимости
2. компонент симфони позволяет не замусоривать код валидируемых объектов? вынося правила валидации в yml/xml конфиги.
Для меня этих преимуществ было достаточно чтобы с ним разобратьсяDelphinum
08.02.2017 12:17но приятно иметь библиотеку готовых валидаторов мыла, минимальной/максимальной длины и прочее-прочее-прочее
Приятно конечно. Мое решение позволяет подключить любой набор валидаторов, будь то symfony, zend или yii инкапсулировав их в предлагаемую мной структуру.
компонент симфони позволяет не замусоривать код валидируемых объектов?
Ну организовать фабрику, которая будет генерировать валидатор на основании yml/xml не проблема, если это необходимо в проекте.oxidmod
08.02.2017 13:47+1Мое решение позволяет подключить любой набор валидаторов, будь то symfony, zend или yii инкапсулировав их в предлагаемую мной структуру.
ну собсвтенно симфониевский тоже, кроме того, что он обладает обширным набором из коробки
Ну организовать фабрику, которая будет генерировать валидатор на основании yml/xml не проблема, если это необходимо в проекте.
Ну вот допишите и получите тоже самое) Но я всеже предпочту уже готовый компонентDelphinum
09.02.2017 01:10Ну так я нисколько не настаиваю, код привел в качестве примера решения контекстуальной валидации.
mayorovp
08.02.2017 08:31В целом я с вами согласен, но вот методы я бы назвал по-другому.
isValidForPersist
— это, в моем понимании, простоisValid
. Потому что объект, который нельзя сохранить в базу — заведомо непригоден ни для чего другого.
А
isValidForCreateOrder
я бы сократил доcanCreateOrder
.
Tramvai
13.02.2017 21:22И чем это отличается от задумки автора? и у него и у вас есть валидация объекта. Разница в том, что вы запилили валидацию в сам обобьет, что само по себе не очень хороший вариант, потому как Клиент у вас теперь знает что-то про окружающий мир. Вы говорили, что проблема автора в копировании валидатора, это не проблема. Вынесите ваши 2 метода в отдельный сервис, и проводите валидацию в любом месте вашего приложение используя валидатор. Таким образом у вас валидация обьекта будет в зависимости от контекста. Я бы сказал, что вы с автором говорите об одном и том же, но используете разную архитектуру.
Fesor
14.02.2017 00:16+1"Валидность для сохранения в базу" — это в целом бесполезная метрика. Любой объект который соблюдает свои инварианты по умолчанию "валиден для сохранения в базу". За соблюдением инвариантов должен следить сам объект.
Что до примера с
hasManager
и подобными — абсолютно согласен, все это надо изолировать в сущности и сделать методcanCreateOrder
или вообще возложить на юзера ответственность за формирования ордера.
И да, сделав методы
hasManager
мы тем самым ломаем инкапсуляцию.Delphinum
14.02.2017 10:31это в целом бесполезная метрика
Это одна из самых важных метрик. Вопрос скорее в том, следует ли ее выделять в отдельный валидатор или использовать метод вида canPersist, но валидировать однозначно стоит все с тем же механизмом формирования причин, по которым это самое сохранение не возможно.VolCh
14.02.2017 13:06Он важна только если инварианты сущности позволяют её существование в виде недопустимом для записи. И если такое бывает по бизнес-процессам, то это просто ещё одна контекстная валидация со всеми вытекающими.
Delphinum
15.02.2017 14:56Совершенно верно. Отсюда можно сделать простой вывод — инвариант это частный случай контекстной валидации.
VolCh
15.02.2017 16:09Не так. Инвариант ограничен (по хорошему) сверху пересечением всех контекстных валидаций.
Delphinum
15.02.2017 16:31Согласен. Только вот имеет ли смысл это ограничение, ведь при добавлении или изменении всего множества валидаций, пересечение может изменяться. Не проще ли отказаться от инварианта как запрограммированной валидации?
michael_vostrikov
14.02.2017 13:31У меня есть мысль (правда не уверен насколько правильная), что все свойства бизнес-сущности должны быть доступны снаружи на чтение. Потому что мы их определили при анализе предметной области, при взгляде со стороны. Если бы это были детали реализации, мы бы их не обнаружили. Детали реализации — это, скажем, когда секретарша подписывает документы за шефа, потому что он ей разрешил. Официально это бумага, подписанная шефом. Мы об этом не узнаем, если только кто-нибудь из них сам не скажет.
Другое дело, что иногда одной сущности необязательно знать, что у другой сущности есть менеджер. Но это уже требования бизнес-логики для конкретного случая, а не требования архитектуры.VolCh
14.02.2017 20:41Не обязательно должны быть доступными все. Во-первых, могут быть чисто технические свойства, во-вторых, могут быть свойства, задаваемые, например, при конструировании и определяющие дальнейшее поведении, но никому об этом знать не нужно.
Fesor
14.02.2017 22:58Потому что мы их определили при анализе предметной области, при взгляде со стороны.
Сделайте read model и там используйте read-only публичные поля. Не вопрос. Ну там сериализации всякие и подобное. Хотя и это под вопросом (для этого придумали DTO). А для остальных случаев вам в целом нет смысла делать публичными поля — к ним никто не должен даже хотеть получать доступ. Ну то есть не нужны ни геттеры ни публичные поля. Стэйт остается в том объекте где он нужен. Если вам нужен стэйт другого объекта — значит что-то при декомпозиции пошло не так.
michael_vostrikov
15.02.2017 06:19Так я как раз не про сериализацию, а про бизнес-логику.
Если у товара есть характеристика "Цвет корпуса: черный", то он доступен всем — и клиенту, и продавцу, и начальнику, который видит, что черные лучше продаются. Причем именно в виде бизнес-свойства бизнес-сущности "Товар". А как мы его храним — в виде EAV или обычного столбца в таблице — это уже детали реализации.
А потом начальник вводит правило — при покупке белого начислять покупателю 100 бонусов. Как-то нелогично передавать в оформление заказа DTO, а не сущность.VolCh
15.02.2017 07:32Как раз в заказ и различные документы логично. Скорее даже не DTO, а value object. Поскольку сущность на то и сущность, что изменяет состояние, а в документах, как правило, нужно состояние сущности на момент формирования. Вы же не хотите, чтобы при изменении, например, цены во всех электронных представлениях документов (у которых есть часто бумажные представления с подписями и мокрыми печатями) поменялись цены и суммы? Или при изменении фамилии у заказчика, чтобы в документах она поменялась?
michael_vostrikov
15.02.2017 09:23Я, конечно, имею в виду ситуацию, когда состояние объекта не расшарено по разным процессам. То есть, во время процесса с сущностью извне произойти ничего не может.
Если надо сохранять в заказе текущее состояние сущности на момент заказа, то ничего не мешает это состояние прочитать. Поэтому и нужен публичный доступ на чтение. Мы передаем сущность, из нее берутся необходимые данные, причем часть может дергаться по связям (название организации), другая часть из вычисляемых полей (возраст/инициалы). В DTO придется явно указывать все что надо, и при изменении процедуры оформления заказа править все это во всех местах вызова.VolCh
15.02.2017 09:46Так мы получаем более сильную связанность сущностей заказа и клиента, особенно если из заказа дергать связи клиента. Из-за изменений в клиенте надо будет править заказы. Используя DTO или другие способы ограничения доступа извне к сущности (например вместо Customer в Order передавать PublicCustomerInterface, в котором только некоторые геттеры Customer перечислены, а то и реализуя в Customer OrderCustomerInterface описанный рядом с Order) мы снижаем связанность, при изменениях Customer ничего не придётся переписывать в Order.
Fesor
16.02.2017 09:09-1А потом начальник вводит правило — при покупке белого начислять покупателю 100 бонусов. Как-то нелогично передавать в оформление заказа DTO, а не сущность.
Попробуйте реализовать такое вот требование в рамках магазинчика. И сделать это без геттеров и вообще попыток добраться до стэйта. Если надо достать стэйт — значит либо логика должна быть в этом же объекте, либо мы должны объекту дать сервис какой-то которому объект делегирует логику и передаст нужные данные.
Все остальное — нарушение инкапсуляции. И всегда можно ее сохранять если достаточно подумать.
p.s. короч свойства объекта, как не крути, это детали объекта. Они должны быть спрятаны. То есть у доменных объектов уж точно не должно быть публичных пропертей.
VolCh
16.02.2017 10:14А как быть с, например, персональными данными клиента, которые в принципе бизнес не интересуют, но которые должны быть в представлениях, прежде всего печатных формах по требованию госорганов? Да и вообще много может быть свойств у различных сущностей в текущих процессах предназначенных исключительно для представления. Потом, может быть, какого-то аналитика осенит, и эти свойства будут влиять на процессы, а пока просто «чтобы было доступно для показа и редактирования»
Fesor
16.02.2017 10:40но которые должны быть в представлениях
Я уже говорил про представление. В них сущности пихать не нужно.
прежде всего печатных формах по требованию госорганов?
$report->print($pinter);
Да и вообще много может быть свойств у различных сущностей в текущих процессах предназначенных исключительно для представления.
Read Model, DTO. В целом нужно смотреть с позиции SRP. Ну и да, иногда проще фигануть геттеров пачку для представления.
VolCh
16.02.2017 10:57Пускай не пихать сущности, а пихать DTO, но конструктор или фабрика DTO должны иметь доступ к свойствам сущности, чтобы передать их в представление. Есть, конечно, отражения, с которыми можно залезть, но как-то… А friendly модификатора в PHP нет.
Fesor
16.02.2017 11:34Если мы посмотрим например на java а не на php, то там мы бы ассемблер DTO ложили бы в тот же пакет что и сущность. И тогда у ассемблера появился бы доступ к состоянию объекта. Еще как вариант — friend классы (для PHP есть RFC но когда ее примут и примут ли я не знаю). То есть смысл в том что если и экспоузить состояние, то явно не для всех а только для "доверенных лиц" скажем так.
То что в PHP это нельзя сделать нормально — ну печаль беда. Потому мне больше нравится идея делать выборки из базы на чтение прямо в DTO минуя мэппинги сущности и unit-of-work.
VolCh
16.02.2017 12:48Если в PHP делать выборки из базы на чтение прямо в отдельные DTO, то обычно получается много тупой работы, а пользы не сильно больше чем от массивов. Как оптимизация запросов — вариант. Закладывать сразу в архитектуру — как и с любой оптимизацией :)
michael_vostrikov
16.02.2017 10:39+1То есть вы считаете, что товар никому не должен сообщать свой цвет? Вот вы бы как реализовали это требование без геттеров и публичных свойств?
Изменилась процедура оформления заказа — добавилось новое правило. В товарах и заказах ничего не поменялось. Если свойства доступны, изменения в коде будут повторять изменения в реальности. Добавляем в процедуру оформления заказа новое правило с проверкой (хардкодом или отдельным классом в цепочке правил, не суть), и на этом всё. А со скрытым состоянием придется его открывать, причем специально для конкретного правила, то есть основываясь на деталях реализации другого объекта.
Я наверно соглашусь, что можно передавать не саму сущность, а прокси, который содержит только методы для чтения свойств. Но чтобы вообще всё скрывать, мне как-то сложно это представить.Fesor
16.02.2017 10:49Вот вы бы как реализовали это требование без геттеров и публичных свойств?
$orderLine = $product->order($quantity, $bonusCalculator);
это если влоб. А уже метод
order
возьмет свой стэйт, который нужен, и даст его калькулятору бонусов.
Словом не зря же закон Деметры придумали.
Добавляем в процедуру оформления заказа новое правило с проверкой
ключевое слово — процедура. Если мы говорим в терминах старого доброго процедурного программирования, а не объектов и сообщений, то да, нужен доступ к стэйту. Вот только есть проблема. Частенько "приоткрывая" стэйт мы тем самым порождаем простор для дублирования логики.
А со скрытым состоянием придется его открывать
В том то и дело, что не должно быть такой необходимости. Ну то есть суть не в том что бы делать все свойства приватными по умолчанию а потом "открывать доступ". А в том что бы этого доступа не нужно было давать. Либо объект свой стэйт сам дает своим зависимостям, либо это не нужно.
- если вам в одной сущности (А) надо заюзать стэйт другой сущности (Б), возможно этому стэйту место в сущности А.
- если вам надо приоткрыть стэйт сущности для каких-то проверок, значит у сущности должен быть метод, который эту проверку осуществляет.
ну и т.д.
Но чтобы вообще всё скрывать, мне как-то сложно это представить.
То что сложно представить — это я согласен. Мне года полтора понадобилось что бы привыкнуть к этой мысли и начать так думать по умолчанию. Я все еще часто ломают инкапсуляцию, использую геттеры чтобы что-то быстрее сделать. Так же есть задачи представления где просто нужна модель на чтение где проще юзать публичные свойства и геттеры. Но это ж отдельная модель. А если надо по быстрому что-то сделать — проще сделать выборку в массивчик минуя сущность.
Fesor
16.02.2017 11:41Вот вы бы как реализовали это требование без геттеров и публичных свойств?
Подумал чуть дольше. Мой предыдущий вариант не ок поскольку он вносит связанность между модулем подсчета бонусов, каталогом товаров и заказами. Это не ок.
Потому попробуем порассуждать.
У нас есть определенные бонусы на определенные продукты. То есть если продукт заказанный удовлетворяет определенной спецификации (это рубашка, например, она белая и определенного бренда) то мы начислям определенное количество бонусов. Это то как мы задаем бонусы для определенных продуктов.
Когда мы делаем заказ, мы можем либо кинуть доменный ивент что "такой-то заказ был сделан" либо явно дернуть модуль бонусов (смотря какой уровень связанности вас больше устраивает, вдруг у вас микросервисы всякие).
Модуль бонусов берет список продуктов из заказа и проверяет что продукты подпадают под спецификацию:
if ($product->matches($specification)) { // добавить бонусов }
По такому же принципу можно реализовать например какие-то хитрые скидки. Профит:
- мы более явно задали способ описания правил начисления бонусов
- держим связанность под контролем
- не нарушаем инкапсуляции и не закона Деметры
- спецификации можно реюзать для поиска по каталогу например.
VolCh
08.02.2017 10:27Состояние сущности может быть не валидно для одной цели, и валидно для другой. Тут мы упираемся в «Контекстуальную валидацию».
Речь о валидности, точнее консистенции самой сущности, о соблюдении её (и только её) инвариантов. Если для каких-то целей нужно более строгое состояние сущности чем задано её инвариантами, то есть два варианта:
— наследование (теоретически идеально подходит, на практике многие не рекомендуют применять)
— дополнительная валидация в сущности (в широком смысле слова, в 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) даже если разрешено допускать пропуски.Delphinum
08.02.2017 12:30Например, когда инварианты сущности клиента разрешают её создание без адреса, а для целей оформления заказа он обязтелен, то пишем код типа
Для банальных проверок ваше решение может подойти, но часто валидация сущности более сложный процесс, при этом повторяющийся, а предлагаемое вами решение смешивает логику валидации и приводит к дублированию кода.
Почему все так любят кидаться исключениями при валидации сущности?
Хотя всё чаще думаю о создании в таких случаях двух ключей — первичного UUID и обычного INT/BIGINT для UX целей. Или наоборот
Правильное решение. Наоборот не надо.
реализация монотонно увеличивающихся локальных идентификаторов не тривиальна
Не понял, где вы в UUID нашли монотонно увеличивающиеся локальные идентификаторы?Sufir
08.02.2017 12:44+1Почему все так любят кидаться исключениями при валидации сущности?
Потому что вы смешиваете клиентскую валидацию и выполнение бизнес-требований. Бизнес-требования должны соблюдаться всегда и такой код вам не позволит их нарушить. Я вам дал уже пару ссылок по теме, это не относится к рассматриваемой теме.
В более сложных ситуациях можно применить спецификацию, о ней тоже есть у Эванса, Фаулера и Вернона.
В начале статьи указано:
Для понимания материала понадобится базовое представление о предметно-ориентированном проектировании.
В конце статьи список материалов по теме, начните оттуда. Если же вы знакомы с DDD, но просто не хотите применять данный подход — то какой смысл писать в данной теме, всё описанное имеет смысл только в рамкам проектирования по модели.Delphinum
08.02.2017 12:53Потому что вы смешиваете клиентскую валидацию
Что за «клиентская валидация»? Вы наверно говорите про «предупреждающую валидацию»? Нет, я не о ней, я о «контекстуальной валидации». И почему выполнение бизнес-требований должно ограничиваться «валидацией до первой ошибки»?
В более сложных ситуациях можно применить спецификацию.
Нет, спецификация это уже из другой оперы.
В конце статьи список материалов по теме, начните оттуда
Если вы догмат теории, тогда рекомендую следующие ссылки для понимая того, о чем я говорю:
Первоисточник шаблона
Мнение Фаулера по темеSufir
08.02.2017 13:15+1Я не «догмат» и даже не догматик, просто вы пишите вещи напрямую не связанные с узким вопросом рассмотренным в статье, все эти вопросы значительно шире.
А спецификация именно об этом (Э. Эванс «Предметно-ориентированное проектирование (DDD): структуризация сложных программных систем», ИД «Вильямс» — 2011 г., стр. 205) или я не понял о чём вы вообще.
И почему выполнение бизнес-требований должно ограничиваться «валидацией до первой ошибки»?
А какая разница какое там количество ошибок? Первая — это уже не выполнение требуемых инвариантов.
Против альтернативных вариантов я тоже ничего против не имею. Более того, я думаю, что описанный мной построение объекта-сущности никак ей не противоречит. Напишите хорошую статью о контекстной валидации, это будет очень интересно.Delphinum
08.02.2017 13:24вы пишите вещи напрямую не связанные с узким вопросом рассмотренным в статье
На самом деле связанные, но да, охватывают немного другие темы, разве это плохо?
А спецификация именно об этом
Не совсем. Спецификация это только часть решения. Кстати да, тоже очень интересная тема.
А какая разница какое там количество ошибок?
Очень большая разница. Полная валидация сущности позволит:
1. Залогировать всю информацию о причинах отказа в обслуживании
2. Сообщить клиенту о всех причинах отказа в обслуживании
Более того, я думаю, что описанный мной построение объекта-сущности никак ей не противоречит
На самом деле контекстуальная валидация это альтернатива инвариантной валидации.
Напишите хорошую статью о контекстной валидации, это будет очень интересно
Писал уже, может скопирну на хабр когда нибудь.VolCh
08.02.2017 14:541. Залогировать всю информацию о причинах отказа в обслуживании
2. Сообщить клиенту о всех причинах отказа в обслуживании
Если есть такое бизнес-требование (часто оно прямо противоположное, по крайней мере по выдаче клиенту), то ничто не мешает предварительно проверить все нужные требования и в одной точке выдать весь список, а не выдавать при первой. В любом случае, по-моему, проверка сущности на пригодность для той или иной операции с ней, ответственность не сущности, а операции. Сущность отвечает лишь за свое минимально обязательное состояние.mayorovp
08.02.2017 14:55Если операция в отдельном объекте — то да. Но не всегда имеет смысл выносить операцию в отдельный объект.
VolCh
08.02.2017 15:02Ничем не противоречит, просто сущность и операция одно и то же :) Но опять же проверку на пригодность к операции обычно надо производить перед выполнением операции, а не при создании или попытке сохранения, если это не единственная операция, для которой сущность создавалась, а у сущности их несколько с разными требованиями.
mayorovp
08.02.2017 15:08Проверку на пригодность надо производить два раза. Один раз — перед выполнением операции, и, если это предусмотрено, сообщать полный список причин невозможности выполнения операции. Второй раз — при попытке выполнения, и просто падать с ошибкой "детектировано некорректное место крепления рук у программиста".
VolCh
08.02.2017 16:35Можно и так, если конструкции типа
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 }
mayorovp
08.02.2017 16:39Обычно все не так просто. О том, что операция недоступна, как правило, известно сильно заранее.
Скажем, если у пользователя нету прав на удаление записи — ему надо вовсе не показывать кнопку "удалить". А если он ее нажал не имея на то прав — значит, где-то в программе есть ошибка.
Delphinum
09.02.2017 01:13то ничто не мешает
Мешает выброс Exception при валидации на первой же ошибке. Зачем этот выброс там нужен, я не понимаю. Исключительная ситуация это та, которой не должно произойти, а если выполняется валидация, то не валидное состояние сущности это не исключительная, а вполне нормальная ситуация, которая как то должна обрабатываться при возврате методом валидации false, и try/catch это не подходящий обработчик для этой ситуации, так как подменяется операция ветвления if/else на try/catch, что делать нежелательно.VolCh
09.02.2017 07:35Можно накопить все ошибки и потом выбросить. Можно вообще не выбрасывать и возвращать ошибку как-то иначе. Но я придерживаюсь мнение, что попытка приведения сущности в невалидное состояние, нарушение её инвариантов — исключительная ситуация. Это не ошибка пользовательского ввода, ведь они отсеивается задолго до попадания в сущность, на уровне фронтенда, контроллеров бэкенда, сервисов приложения, доменных сервисов и т. д., это ошибка логики, раз запрос на такое приведение дошёл до сердца системы — сущности.
Delphinum
09.02.2017 12:14попытка приведения сущности в невалидное состояние, нарушение её инвариантов — исключительная ситуация
То есть вы считаете, что сообщить пользователю лучше: «ошибка, заполняйте форму сначала» — чем: «неверно введен адрес электронной почты и телефон»?
ведь они отсеивается задолго до попадания в сущность, на уровне фронтенда, контроллеров бэкенда, сервисов приложения, доменных сервисов и т. д.,
Зачем, когда можно обеспечить защиту на уровне фронтенда, а если там она не сработает (к примеру логика фронтенда отключена пользователем), то на уровне самой сущности?VolCh
09.02.2017 14:01+1То есть вы считаете, что сообщить пользователю лучше: «ошибка, заполняйте форму сначала» — чем: «неверно введен адрес электронной почты и телефон»?
Лучше сообщить пользователю 500 Internal Server Error, если он свою невалидную форму умудрился пропихнуть через фильтры фронтенда, контроллера и сервиса приложения.Delphinum
09.02.2017 14:06Понятно. Ну я предпочитаю не размывать валидацию безнес-модели на уровне контроллеров и сервиса (если сервис вообще реализуется), а держать ее на уровне домена.
Sufir
09.02.2017 14:31+1На уровне модели предметной области — выполнение ограничений предметной области (domain model). Валидация введенных пользователем данных — это задачи слоя отображения (presentation layer) и прикладного слой (application layer).
Вы предпочитаете так, ваше дело, я предпочитаю не смешивать слои, а разделяю ответственности в соответствии с SRP. Ваша валидация и красивости отображения ошибок к домену ни каким боком не относятся.Delphinum
09.02.2017 14:39Валидация введенных пользователем данных — это задачи слоя отображения
Не согласен. Я не валидирую введенные пользователем данные, я валидирую состояние сущностей перед их использованием, а вот за установку сущности в конкретное состояние может отвечать как пользователь, так и некая часть системы, это не важно.
Другими словами, я вполне допускаю наличие сущностей с невалидным состоянием, но я допускаю использование этой сущности только с валидным состоянием (чувствуете разницу?).
Ваша валидация и красивости отображения ошибок к домену ни каким боком не относятся
Валидация это часть домена, а вот отображение ошибок пользователю это уже слой представления и инфраструктуры (если ошибки валидации пишутся в лог).
VolCh
09.02.2017 14:38А это не размывание валидации бизнес-модели, а отдельные валидации пользовательского ввода и валидация бизнес-модели.
Грубо, правило валидации пользовательского ввода: поле 'name' веб-формы регистрации заявки не должно быть пустым, а правило валидации бизнес-модели: создаваемое в ходе процесса регистрации заявки значение $application->$person->$personalData->$name не должно быть пустым.Delphinum
09.02.2017 14:41А это не размывание валидации бизнес-модели, а отдельные валидации пользовательского ввода и валидация бизнес-модели
Что сразу создает дублирование, так как требует валидации как на уровне контроллера, так и на уровне бизнес-модели. Добавление еще одного потока ввода (к примеру из сторонней системы или микросервиса) потребует дополнительного дублирования.
Грубо, правило валидации пользовательского ввода: поле 'name' веб-формы регистрации заявки не должно быть пустым, а правило валидации бизнес-модели: создаваемое в ходе процесса регистрации заявки значение $application->$person->$personalData->$name не должно быть пустым
А зачем это дублирование?oxidmod
09.02.2017 14:45+2решается это валидацией команды.
в контроелере из реквеста создается команда и валидируется команда.
из консольного ввода создается таже команда и валидируется теми же правилами.
Если уж запустился команд хеендлер, то значит даные валидны.
Валидация сущностей — это уже излишне, они не должны существоавать в неконсистентом состоянии, вот и все. Но допустимы естественно проверки (оплатить можно при только при наличии бабла на счету, получить посылку, ри указании адреса и так далее)Delphinum
09.02.2017 14:48решается это валидацией команды
Это так же решается валидацией состояния сущности перед использованием. Получили вы от пользователя данные формы для регистрации нового Клиента, выполнили метод isValidPersist, если ошибок нет, то создаете новую сущность, если ошибки есть, обрабатываете их каким либо образом. На мой взгляд это намного проще и понятнее, нежели ввод самого шаблона «Команда», только для валидации входных данных.oxidmod
09.02.2017 15:03https://youtu.be/Nsjsiz2A9mg?t=863
Это гораздо проще чем 100500 isValidFor*Delphinum
09.02.2017 15:10-1Не заметил простоты.
Это гораздо проще чем 100500 isValidFor*
Вас смущает решение или 100500 методов? Если второе, то можно вынести все валидаторы в один большой «божественный объект», и использовать его )oxidmod
09.02.2017 16:05+2Меня смущает, что вы вносите в сущность знание обо всех юз-кейсах её использования.
При появлении нового юзкейса вы добавлете новый метод isValidFor*
Вы дублируете код, что если isValidForOrder еквивалентно isValidForCredit? не будете же вы в при оформлении кредита дергать isValidForOrder?Delphinum
09.02.2017 16:16Нет, не обязательно выносить в сущность все методы для валидации ее состояния для всех юзкейсов, вы вполне можете оформить валидаторы в виде отдельных классов, как это было предложено уже автором статьи в виде, к примеру, спецификаций. Это было бы очень удобно, на мой взгляд. Если очень постараться (решения в интернете не нашел), то эти же спецификации можно использовать не только для валидации сущностей перед операциями над ними, но и для запроса сущностей с валидным состоянием прямо из базы, к примеру:
// Клиенты, для которых можно создать заказ $clients = $clientRepository->fetchAll(new CanMakeOrder());
Sufir
09.02.2017 08:41+1Исключительная ситуация это та, которой не должно произойти
Именно так, это она и есть.Delphinum
09.02.2017 12:15То есть пользователь в принципе не должен вводить неверные данные в формы? )
С другой стороны, зачем тогда вообще формировать метод isValid, который либо срабатывает, либо выбрасывает исключение, может лучше сразу перехватывать ошибки уровня языка?Sufir
09.02.2017 14:23А что это за метод и где вы его собираетесь «формировать»?
Delphinum
09.02.2017 14:45-2Так он в вашем примере (название правда другое, но это не важно):
class Order { function checkout($client, ...) { if (!$client->hasManager()) { throw new ClientHasntManagerException(); } } }
То есть вы создаете метод, который должен (как я понимаю) проверить возможность выписки счета, но при этом метод либо сообщает о возможности этой выписки, либо выбрасывает исключение. На мой взгляд это совсем неправильное использование исключений. Это как если бы вы выбрасывали исключение из функции count при проверке длины массива, если бы эта длина была равна нулю. То есть, метод checkout (isValid) должен нам сообщать валидно или не валидно это состояние, а мы уже должны как то на это реагировать, но никак не выбрасывать исключение.Sufir
09.02.2017 15:02Нет, этот метод явным образом в коде описывает бизнес-требования, к валидации он никакого отношения не имеет. Есть требование сформулированное бизнесом «нельзя оформить заказ на клиента без менеджера» (вот просто нельзя никогда, бизнесу не интересно как ты технически это реализуешь, ему важно что бы правило выполнялось) и оно в представленном коде легко прочитывается.
Никаких технических isValid() в модели быть не должно, их нет в едином языке и они не выражают никаких бизнес-правил. Сюда же и сеттеры, которые не несут никакой смысловой нагрузки с точки зрения предметной области.
В предметной области есть Клиент и Заказ, заказ может быть «Оформлен», Клиент может быть «Переименован» и т.д., поведение явно отражает бизнес-логику и единый язык. В этом основной смысл проектирования по модели — предметно-ориентированного проектирования. Ни о каких setSomething() и isValid() бизнес не знает и в проектируемой модели их быть не должно (иногда, конечно, технические ограничения требуют введения служебных методов в классы модели).Delphinum
09.02.2017 15:18-2Нет, этот метод явным образом в коде описывает бизнес-требования, к валидации он никакого отношения не имеет
То есть валидирующий состояние сущности метод не имеет к валидации никакого отношения? )
Вы вынесли в сущность Заказ метод, который проверяет стороннюю сущность и называется checkout (проверять, контролировать), но при этом говорите, что в модели не должно быть технических валидаторов ))
Ну ок, назовите метод не isValidPersist, а canBePersist чтобы он соответствовал единому языку и отражал бизнес-правило: нельзя регистрировать Клиента без Адреса. Так будет лучше?mayorovp
09.02.2017 15:27+1ЁПРСТ ИКЛМН! "checkout" переводится не как "проверять, контролировать", а как "выписывать, оформлять"!
В данном случае, речь идет об оформлении заказа, а не о его проверке!
Delphinum
09.02.2017 15:30-1Значит метод оформления заказа отвечает за контроль состояния объекта, дабы он отвечал бизнес требованию и за, собственно, оформление этого самого заказа? )
mayorovp
09.02.2017 15:31Нет. Метод оформления заказа отвечает за оформление заказа. Он должен либо оформить заказ, либо сообщить о том, что это невозможно.
Delphinum
09.02.2017 15:33Но метод не сообщает о том что это невозможно, метод падает с исключением. Или для вас это одно и то же?
mayorovp
09.02.2017 15:35В данном случае — да, это одно и то же. Потому что попытка оформления невозможного заказа является исключительной ситуацией.
Вот если бы метод не падал с исключением, а возвращал булево значение или более сложную структуру — тогда бы я сказал что у него смешаны две ответственности (оформление заказа и его проверка).
Delphinum
09.02.2017 15:40Он должен либо оформить заказ, либо сообщить о том, что это невозможно
Значит правильнее сказать, что метод либо оформляет заказ, либо падает исключением. Метод не сообщает о том, что это невозможно, так как исключение это не сообщение.
Я же предлагаю вообще не пытаться вызывать метод оформления заказа, который помимо этого оформления должен еще «знать» о бизнес-условиях этого действия и выполнять валидацию всех сущностей, с которыми работает, а вынести эту логику в отдельный валидатор, оформленный в виде метода или класса. Плюсы в том, что:
1. Валидация выделяется из метода, который этой валидацией заниматься не должен
2. Валидация может использоваться повторно и комбинироваться
3. Вы получается подробную информацию о всех причинах отказа при выполнении операцииmayorovp
09.02.2017 15:43Delphinum
09.02.2017 15:48Да, есть такая вещь, но что нам мешает оспаривать ее эффективность и/или приводить альтернативные решения?
Кстати да, еще одна польза контекстной валидации: вы можете использовать ее вместе с защитным программированием, просто выполняя еще одну валидацию внутри метода, от нее зависящего. Если валидация не выполняется, то выбрасывается уже ваше любимое исключение. Выгода тут в том, что вы не дублируете код валидатора, а оформляете его в виде бизнес-требований и используете повторно перед вызовом метода и внутри метода (если вдруг, каким то чудом первая проверка пропустила невалидную сущность, но это уже из области 1С и излишне).mayorovp
09.02.2017 15:53В таком случае я не понимаю о чем вы вообще спорите.
Delphinum
09.02.2017 15:54Эмм… Я удивляюсь выбросу исключений при валидации бизнес моделей и предлагаю обратить внимание на контекстную валидацию.
mayorovp
09.02.2017 15:58Задача метода checkout — не в валидации. Исключения он должен выбрасывать согласно принципу fail fast. Контекстная валидация — опциональна, ее лучше делать, но можно и пропустить если ее логика слишком простая (в примере с checkout — проверяется только 1 свойство).
Delphinum
09.02.2017 16:04Задача метода checkout — не в валидации… Контекстная валидация — опциональна
Согласно DDD вы как хотите в коде оформить проверку бизнес-требований, если в задачу метода checkout она не входит?mayorovp
09.02.2017 16:06Чем кроме названия метод
hasManager
не подходит в качестве метода контекстной валидации? :)Delphinum
09.02.2017 16:111. Контекстная валидация, по моему мнению, должна предоставлять вам информацию о причинах невалидности сущности
2. Контекстная валидация позволила бы нам вообще отказаться от метода hasManager, инкапсулируя его в логике валидатора, а не вынося в публичный API класса
3. Если бизнес-требования изменятся, то, возможно, проверки одного только hasManager станет недостаточно, и тогда придется искать весь продублированный код по проекту вместо того, чтобы изменить один только валидатор
4. Валидатор с именем canMakeOrder более информативен и больше соответствует единому языку (на мой взгляд), чем выброс исключений и их обработка через try/catch
maghamed
09.02.2017 19:34+1Автор прав, идеально добиться состояние, когда сущность после создания всегда находится в валидном состоянии, т.е. инварианты всегда соблюдаются.
Есть давняя статья Грега Янга (создателя термина 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 который должен выполняться.Delphinum
10.02.2017 12:07валидном состоянии
Валидном для чего?pbatanov
11.02.2017 18:19Мне кажется, вы пришли к конечным автоматам. Посмотрите на компонент workflow в symfony
Delphinum
11.02.2017 20:28-1Таки пришел не я, контекстуальная валидация это не моя идея. В остальном да, если состояния сущности могут быть представлены конечным автоматом (а как правило, это именно так), то перед попыткой смены состояния (операцией), предлагается выполнять проверку, действительно ли автомат находится в состоянии, при котором эта смена осуществима.
Автор идет по другому пути, при котором переход в конкретное состояние невозможен в принципе, и не важно, почему.
Symphony workflow позволяет определить причину невозможности перехода?VolCh
12.02.2017 10:32По хорошему, контекстуальная валидация сущности должна производиться контекстом, а не самой сущностью. Сущность о контекстах её использования знать ничего не должна. Например, при добавлении заказа в коллекцию заказов сущности клиента, сущность не должна проверять заполнено ли у неё поле менеджер, это ответственность процесса заказа, какого-то менеджера, максимум что сущность может сообщить ему — заполнено у неё это поле ли нет. Сущность не отвечает за процессы, она объект процессов.
Delphinum
12.02.2017 14:46Я бы даже сказал, что контекстуальная валидация сущности должна производиться специальны валидатором, который вызывается контекстом, но знающие люди говорят, что знания о контекстах использования можно хранить на уровне сущности.
VolCh
12.02.2017 15:21Пока контекст один — можно без проблем. Собственно концептуальная валидация будет являться и инвариантом модели. Но чем дальше, чем больше контекстов, тем больше их будет и в сущности, она станет центром всех контекстов. Одних только методов типа isValidFor может стать сотни. И за все контексты отвечать будет сущность — это очень далеко от SRP.
Delphinum
12.02.2017 18:49Если валидатор реализовать в виде класса-спецификации, то можно будет использовать его в контроллере перед вызовом операции.
VolCh
12.02.2017 19:09Не важно как реализовать, можно в контроллере дергать метод hasManager(), можно натравливать спецификацию ReadyToOrder, которая будет его дергать, главное что проверка состояния сущности на валидность для какого-то узкого контекста будет проводиться снаружи сущности, пускай и на основе её состояния.
Delphinum
12.02.2017 20:55Тут важно не местонахождение валидатора, а момент валидации. Инвариант запрещает существование сущности в некорректном состоянии, а контекстная валидация запрещает переход сущности в некорректное состояние. В этом то и разница.
VolCh
13.02.2017 00:35Контекстная валидация немного не про то, по моему, она запрещает операции в которых участвует сущность в некорректном состоянии, состоянии самой сущности может не изменяться при этом. Как в примере с заказами — создание заказа не изменяет сущность клиента (допустим в модели связь заказ-клиент односторонняя), но сама операция создания заказа запрещена, если у клиента нет связи с менеджером. И даже если изменяет, то изменять она его может корректно с точки зрения самой сущности, её основного контекста, некорректно это будет лишь для данной конкретной операции.
Delphinum
13.02.2017 01:35Как в примере с заказами — создание заказа не изменяет сущность клиента
Но изменяет состояние системы. Если рассматривать проблему с этой точки зрения, то контекстная валидация позволяет проверять валидность операции изменения состояния всей системы или ее частей. В случае инварианта, предполагается, что раз сущность имеется, значит операция позволена. На мой взгляд и опыт это ошибочное суждение.VolCh
13.02.2017 14:15Контекстная валидация — инвариант всей системы, зачастую не относящийся к состоянию одной конкретной сущности, а комбинации состояний разных, типа можно иметь в системе клиента без менеджера и заказ с (задача на доставку — отдельная сущность) доставкой, но нельзя чтобы заказ с доставкой был у клиента без менеджера.
В случае инварианта предполагается лишь, что сущность всегда находится в корректном состоянии и не допустит приведение себя в некорректное в рамках выполнения одной своей операции, просто не даст своим клиентам выполнить такую операцию, которая приведёт её в некорректное состояние. Клиенты не должны делать предположений о допустимости той или иной операции и всегда ожидать, что сущность откажется её выполнять.
reforms
07.02.2017 21:32Буду благодарен за отзывы и конструктивные замечания.
Что так: суть статьи и ее посыл понятны + делитесь опытом — за это спасибо.
Что не так для меня:
1. ClientBuilder — лишний контракт.
2. MySqlClientBuilder размывает границы доступа к БД в приложении и усложняет процесс его понимания
Как бы сделал я:
1. Сделал объект Client настолько простым, насколько это возможно, в данном случае список полей и get/set методы к ним
2. Объект доступа к БД, ClientDao с операциями saveClient, loadClients, updateClient и т.д.
3. Объект проверки сущности Client в ClientValidator — проверка заполнености всех данных, а также их прикладная согласованность.
4. В слое бизнес логики перед сохранением проверка Client с помощью ClientValidator
Итого имеем: строго формализованные слои приложения:
- Слой 'Бизнес Объект' — Client
- Слой 'Объект доступа к данным' — ClientDao
- Слой 'Проверки объектов' — ClientValidator
- Слой 'Бизнес логики' — где происходит манипуляция остальными вышеперечисленными слоями.
VolCh
08.02.2017 10:43+1Слой 'Бизнес Объект'
Это не объект, а так, структура из Си или запись из Паскаля. В PHP вообще массив может быть. DTO в лучшем случае. Собственно геттеры и сеттеры в вашем подходе не нужны, достаточно публичных свойств. Суть бизнес-объекта (сущности) именно в том, что он инкапсулирует относящуюся к нему (и только к нему) бизнес-логику. В общем случае у него не должно быть сеттеров, да и геттеры не на все свойства обычно нужны. И он должен быть максимально сложным (в рамках SRP), а не максимально простым, чтобы максимально использовать преимущества ООП. А у вас ООП вырождается в процедурное программирование с неймспейсами.
Ваш подход плохо подходит для моделирования сложных областей, потому что, как минимум, слой бизнес-логики становится зависимым от слоя доступа к данными. Что-то меняем в системе хранения и надо переписывать бизнес-логику. Это уже не бизнес-логика получается, а управление логикой хранения. В идеале логика хранения вообще должна быть сбоку (или хотя бы сверху) от бизнес-логики. Бизнес-логика, включая бизнес-объекты в идеале ничего не должны знать о системе хранения, о фреймворке, вообще о среде исполнения.
VolCh
08.02.2017 10:32-1По-моему, обычно, для создания сущности с обязательными параметрами, билдер — оверинженеринг. Обязательные параметры в конструкторе, пускай их с десяток (несвязанных друг с другом!) — норма. Билдеры хорошо подходят когда есть необязательные параметры — первым вызовом создаём обязательные, а дальше если нужно в данном кейсе заполняем необязательные, в том числе инкапсулируя создание объектов-значений.
avloss
08.02.2017 10:53Спасибо за статью! всё шикорно! только одна проблемка, как бы пользователт этой системы не начали передавать сам обьект билдера (недосозданный)
$halfBakedClient = $builder->setId($id) ->setName($name)
ничего же не мешает это передать в какую-нибудь другую функцию, которая что то там ещё запонить должна. мне кажется стоит остановться на шаге втором. Такой конструктор — это немного перебор (по-моему). Ну или нужно как то запретить существование "полу-созданных" объектов!
Delphinum
08.02.2017 12:36А что плохого в передаче полу заполненного Строителя?
mayorovp
08.02.2017 13:11Полузаполненный строитель со временем вытесняет использование того объекта, который предполагалось им строить. Особенно когда код отдается фрилансерам в условиях постоянно меняющихся требований.
Delphinum
09.02.2017 01:17Полузаполненный строитель со временем вытесняет использование того объекта, который предполагалось им строить
Такого в принципе быть не может, так как у Строителя одна задача, а у создаваемого им объекта совершенно другая. Другими словами у них разная семантика, чтобы такое проворачивать. Конечно если наговнокодить, то можно все, но делать это не надо и проблема тут не в передаче полузаполненного Строителя.
VolCh
08.02.2017 14:59+1Наоборот, это очень крутая возможность передавать строитель в различные функции, чтобы они его дозаполняли по необходимости. Главная функция заполнения может даже не знать о том, что и как заполняют и заполняют ли вызываемые функции и не заполнять ничего сама. А «полусозданные» объекты запрещаются на самом последнем этапе работе билдера, на собственно создании объекта. Если данных недостаточно, то объект не создаётся.
boom
09.02.2017 10:37Спасибо за статью, но
во-первых:
$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 сущность нужно восстанавливать из хранилица тоже консистентной, даже если вы не пользуетесь всей сущностью.boom
09.02.2017 10:56Наверно в моем случае правильнее использовать CQRS :)
Но всеравно есть агрегаты, которые тяжело восстанавливаются (Ну например, зачем восстанавливать аггрегат заказа с 1000 строк в заказе? везде и всегда)VolCh
09.02.2017 11:44CQRS и DDD в общем случае дополняют друг друга. Например, одни сервисы приложения реализуют команды, а другие запросы, не смешивая их, команды возвращают максимум true/false, а запросы ничего не меняют (кроме разве что логов технического аудита).
Есть ленивая загрузка графов объектов из базы. В контексте PHP Doctrine это умеет более-менее. А Value object в отдельной табличке обычно костыль, чтобы применить ту схему данных, которая нравится, а не которое предлагается той же Doctrine.maghamed
09.02.2017 19:50+1идея в том, что для операций чтения, которых в большинстве приложений сильно больше чем записи вы можете иметь отдельную упрощенную структуру данных, т.е. Query операции вам возвращают просто массив данных key=>value или массив DTO. А в Command у вас полноценные модели, с процеркой всех валидацией и тяжелыми связями для обеспечения консистентности
В CQRS подходе DDD у вас живет только в коммандах, квери должны быть очень упрощенными и быстрыми.
Как правило с отдельным индексом и без тяжелого ORMаVolCh
12.02.2017 10:39Как вариант упрощенной структуры данных может быть вытягивание в те же классы сущностей DDD без всех их связей, без отслеживания изменений в объектах после вытягивания и т. п. Это позволяет, с одной стороны, хранить иметь только одну модель и только один маппинг на базу, не заморачиваясь с синхронизацией при каждом чихе, а, с другой, позволяет выполнять запросы заметно быстрее чем команды.
maghamed
12.02.2017 11:13то что вы говорите — не применимо и не правильно.
у вас в квери сценарии будут какие-то недоинициализированные сущности, с нерабочими интерфейсамми, которые кидают эксепшены на вызовах определенных методов.
В этом и фишка CQRS — у вас две архитектуры, для чтения и записи. И масштабируете вы их тоже отдельно. Это не недостаток, а достоинство подхода. А вы хотите это нивелировать, пытаясь это уложить на одну кодобазу.
maghamed
09.02.2017 19:45то что вы написали, это реализация фабрики. Билдер это как раз про цепочки сеттеров.
А по поводу вопросов дороговизны. Если ваши сущности иммутабельны, т.е. не изменяемы, то вы вполне можете их шарить по всему приложению, так как никто не сможет их поломать изменив глобальный стейт. И у вас есть некий Object Manager — глобальный контейнер в который кладутся уже созданные сущности и который отвечает за создание новых, которые требуются параметрами конструкторной dependency injection. То это сильно сократит стоимость.
Но если у вас очень нагруженное приложение и требование по перформансу очень высоки, то DDD может быть действительно не для вас.
maghamed
09.02.2017 19:23Ну, сам процесс создание объектов-сущностей не большая проблема. И даже большое кол-во зависимостей может быть не проблемой (хотя большое кол-во зависимостей это «smell» в коде, который намекает, что ваш объект берет на себя много обязанностей и вероятно нарушает принцип единой ответственности, что всегда плохо в ООП) если у вас есть Object Manager (Entity Manager/DI Container) который проинстанциирует все внешние зависимости сущности.
Но конкретно в вашем примере нельзя Client называть Enitity. У вас это классический Data Transfer Object, т.к. вы показали что он хранит только данные и аксессоры к этим данным. И никаких методов бизнесс логикиSufir
09.02.2017 23:38Нет, это конечно же не DTO и я начинаю с того, что бы как раз сделать шаг от анемичной модели к rich и превратить DTO в Entity. Но да, мы уже обсудили это в другом месте, моя ошибка. Для упрощения примера и акцента на теме создания объектов не стал упоминать о поведении, но из-за этого пример выглядит неубедительно. Хотя бы вкратце это оговорить нужно было.
maghamed
12.02.2017 11:18DTO который содержит пачку геннеров и сеттеров — это не всегда плохо, точней если он используется для межпроцессной коммуникации (например в web api), то это даже хорошо.
Поэтому может просто имеет смысл выделить отдельную сущность — доменную модель. А DTO оставить как DTO. Сериализируемый объект, который всегда можно передать по сети.VolCh
12.02.2017 15:33Лучше выделить DTO (если он вообще нужен, особенно в PHP, где вокруг динамических ассоциативных массивов чуть ли не бОльшая половина экосистемы крутится) из сущности прозрачно замапленной на БД, чем делать ещё один слой сохранения данных поверх доктрины.
В теории вроде разницы нет, но на практике DTO между доменной моделью и слоем хранения данных сильно замедляет разработку, не давая особых плюсов, пока возможностей используемой ORM не ограничивают (или ограничивают но не сильно) доменную модель. А когда ограничивают, то чаще всего это уже просачиваются через ORM ограничения РСУБД, а не самой ORM. Ситуации когда четко видишь как разработанная модель красиво ложится на SQL БД, но при этом ORM не даёт так положить — редки.
Sufir
13.02.2017 10:04Конечно лучше, но в данной статье я ничего о DTO и их применении не говорил, тут речь только о сущностных. В модели предметной области DTO могут использоваться для реализации событий предметной области, в остальных ситуациях DTO не место в слое модели.
maghamed
13.02.2017 12:29ну если речь о сущностях, тогда в вашей реализации проблемы, так как ваши сущности отдают наружу доступ ко всем смоим данным и тем самым нарушают инкапсуляцию.
Контракт сущности (entity) не должен состоять из getter/setter к данным из которых эта сущность состоитVolCh
13.02.2017 14:24Вообще говоря не задача сущности изолировать свои данные от внешнего (по отношению к домену) мира. Это задача уровня сервисов приложения. Они обращаются к сущностям и сервисам модели как часть домена и, если нужно, изолируют домен от остального приложения, в частности возвращая вместо сущностей DTO, часто заточенный под конкретный запрос приложения.
alxsad
13.02.2017 21:35+1Пару замечаний.
Для идентификации сущностей лучше использовать именно UUID, это лучше и с точки зрения безопасности, ведь часто авто инкременты фигурируют в URL, откуда третьи лица могут, например, сделав дифф двух значений, узнать сколько у вас было транзакций в день, что может являться бизнес тайной.
А по поводу шаблона проектирования «строитель», я думаю, для вашего примера он не очень подходит, обычно его используется для сложных объектов, где каждый параметр в конструкторе не обязателен и может быть, например, нулом.
Есть хороший туториал от создателей doctrine.VolCh
14.02.2017 10:23+1Для идентификации сущностей лучше использовать именно UUID
Нет однозначного ответа, что лучше. UUID лучше с точки зрения простоты разработки и безопасности, но хуже с точки зрения юзабилити (ссылку по телефону затруднительно передать будет, например или отсортировать объекты в порядке создания без создания дополнительных полей) и, обычно, потребляемых ресурсов, особенно если бинарное представление недоступно и используется какое-то из символьных.Delphinum
14.02.2017 10:37-1Не рекомендуется использовать ключи идентификации для целей, отличных от идентификационных, таких как — сортировка, юзабилити и т.д. У каждого элемента должна быть минимальная ответственность.
alxsad
14.02.2017 12:56+1Для сортировки есть UUID, сгенерированные на основе timestamp, а для ссылок лучше использовать отдельное поле с «красивым» значением, авто инкременты лучше не показывать пользователям с точки зрения безопасности и конфиденциальности бизнеса.
VolCh
14.02.2017 20:38Если с красивым (номер документа, например), то с точки зрения безопасности и конфиденциальности бизнеса разницы нет между автоинкрементным первичным ключом и генерируемым последовательно номером.
alxsad
15.02.2017 11:31Пример «красивого» идентификатора без номера youtube.com/watch?v=ysM-Z_lLcEU
VolCh
15.02.2017 12:12Чем он красивый? Как его продиктовать?
alxsad
15.02.2017 13:01Так же как и цифровой, да, сложнее, но безопаснее. Вообще кейс странный, не припомню когда мне приходилось надиктовывать ссылки. Можно скинуть, например, в мессенджер.
VolCh
15.02.2017 16:07Вполне нормальный кейс когда речь идёт о корпоративном софте, в частности об удобстве топ-менеджеров, которые не хотя добавлять в свои мессенджеры рядовых работников.
Delphinum
15.02.2017 16:36+1Можно скинуть, например, в мессенджер
Вы это скажите тёте-секретарю 50 лет, которая забивает документы в СЭД и периодически звонит в УФССП для уточнения чего либо по определенному делу. Нет, пользоваться скайпом/мылом/аськой и т.д. она не умеет, с трудом научили вбивать данные в поля и нажимать «Сохранить».alxsad
15.02.2017 16:59О чем вообще речь? Я выше писал, что для публичных ссылок лучше не использовать инкременты, вы сейчас говорите о внутренних процессах тети-секретаря. В первом случае ссылки могут содержать UUID, во втором — «красивые» номера для внутреннего пользования тетями-секретарями. Причем можно иметь сразу два идентификатора для каждой сущности.
VolCh
15.02.2017 17:01авто инкременты лучше не показывать пользователям с точки зрения безопасности и конфиденциальности бизнеса.
Где слово «публичные»?alxsad
15.02.2017 17:04+1Ясно, разошлись с самого начала, все что я писал выше о безопасности, относится к случаям, когда идентификаторы могут видеть люди, не обремененные всякими NDA.
Delphinum
15.02.2017 23:43Причем можно иметь сразу два идентификатора для каждой сущности
Значит я вас не правильно понял, прошу простить. Полностью согласен, для человека лучше применять отдельный формат нумерации, а не идентификатор сущности.
alxsad
14.02.2017 13:05+1Еще одно преимущество использования UUID — масштабирование, когда у вас «распределенная» модель базы данных, можно быть уверенным что идентификаторы не будут иметь дубликатов на различных серверах.
Fesor
14.02.2017 23:04+1Не согласен с тобой относительно билдеров.
У меня тут очень простая позиция. У методов не должно быть более 3-х аргументов. То есть если у сущности есть 5 обязательных параметров, вместо того что бы делать конструктор с 5-ю аргументами проще сделать билдер. Это будет и читабельнее, и удобнее в использовании.
В целом для сущностей я обычно по умолчанию билдеры юзаю. Для VO уже намного реже. Иногда вместо билдера еще фабрики можно делать + dto какое-то… это не столь важно. Важно чтобы все данные передавались одним пакетом и связанность по данным была ниже.
Что до туториалов: вот еще на эту тему
alxsad
15.02.2017 11:41Все зависит от конкретного случая, 5 аргументов нормальная ситуация если они скаляры. Для сложных сущностей, где куча аргументов VO, я использую фабрики, имхо лучше подходят нежели билдеры.
Fesor
16.02.2017 09:21+15 аргументов нормальная ситуация если они скаляры.
А в чем разница можешь сказать? Ну вот у тебя есть класс. По твоей логике если я беру класс у которого 5 аргументов в конструкторе, все хорошо пока они скаляры. А если я эти скаляры оберну в свой тип (
Email
,Password
, etc) то уже не ок что-ли? Почему раньше тогда было ок?alxsad
16.02.2017 12:26Кейс выдуманный для примера
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.
mayorovp
16.02.2017 09:27Вообще-то наоборот. 5 аргументов — это нормально если они не скаляры. Потому что скаляры не могут сами себя документировать, особенно если они константы.
Fesor
16.02.2017 09:50Проблема со связанностью по данным, а скаляры это или нет не имеет никакой разницы. Потому есть всякие правила:
- идеальный метод имеет 0 аргументов (тут только message coupling, самый низкий вид связности к которому нужно стремиться)
- 1 аргумент норм (но уже начинается data coupling)
- 2 аргумента ну так, сойдет (data coupling выше)
- 3 аргумента — край. (еще выше).
data coupling это конечно не content coupling но все же уровень этого вида связности нужно держать под контролем.
mayorovp
16.02.2017 10:00Это уже высокие материи, различие пять скаляров от пяти объектов — в другом.
Если есть вызов вида
foo(5, 3.0, false, "bar", null)
— то проблема не в data coupling, а в том, что чтобы понять что эта строчка делает надо несколько раз "прыгнуть" между этой строчкой и определением методаfoo
. А если у параметров еще и типы одинаковые — то все вовсе печально.
При вызове же метода с передачей ему пяти объектов такой проблемы не возникает. И тут уже можно начинать рассуждать о том что data coupling — это плохо :)
Fesor
16.02.2017 10:11то проблема не в data coupling, а в том, что чтобы понять что эта строчка делает надо несколько раз "прыгнуть" между этой строчкой и определением метода foo.
вменяемые IDE решают эту проблему запросто. А вот проблему data coupling не решают.
В целом же даже если брать ваш вариант — либо у вас код будет выглядеть как-то так:
foo(new Foo(5), new Bar(3.0), State::invalid(), new Baz('bar'));
либо вы не решили проблему и нам всеравно нужны подсказки и хинтинги от IDE.
VolCh
16.02.2017 10:24+1в том, что чтобы понять что эта строчка делает надо несколько раз «прыгнуть» между этой строчкой и определением метода 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);
А то и лучше, ведь от скаляров никуда не делись.
alxsad
16.02.2017 12:46Оборачивать скаляры в объекты ради документации кода? Так себе идея, тем более есть php7 и доктайпы в IDE.
VolCh
15.02.2017 12:14А внутри билдер вызывает конструктор с пятью аргументами или конструктор с тремя и два сеттера потом?
alxsad
15.02.2017 12:58Все зависит от доменной модели, если сущность может существовать без какого-то параметра, можно сделать сеттер, вообще сущность должна быть валидной всегда.
Fesor
16.02.2017 09:15public 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();
как-то так обычно.
alxsad
16.02.2017 12:39И твоя сущность зависит не от домена, а от билдера, который по сути выступает в роли хелпера. И чем это лучше такого кода:
$user = new User(
$email,
$password,
$passwordEncoder,
new Profile($name, $birthDay, $picture)
);Fesor
16.02.2017 12:47- больше простора для дальнейших действий
- меньше связанность по данным (не так больно добавлять новый обязательный параметр)
- проще хэндлить валидацию
- в тестах можно реюзать билдеры:
$customer = UserFixtures::customerBuilder()->build(); $referral = UserFixtures::customerBuilder()->setIntroducer($customer)->build();
выходит весьма и весьма удобно.
alxsad
16.02.2017 12:55Не понимаю насчет связанности, ты отвязываешь сущность от доменных объектов и привязываешься к хелперу-билдеру, в итоге сущность не может существовать без хелпера-билдера, имхо лучше использовать отдельные независимые классы-фабрики. Насчет валидации, это отдельная тема, которую можно вынести в DTO.
Fesor
16.02.2017 15:59Не понимаю насчет связанности
Есть такой вид связности — связность по данным.
ты отвязываешь сущность от доменных объектов и привязываешься к хелперу-билдеру
сущность и есть доменный объект. Я просто делаю что-то типа объекта-сообщения который содержит все необходимые сущности данные (вместо полотнища аргументов которые не читабельный и неудобны при расширении логики).
И это не "хелпер-билдер", это часть предметной области. Те данные которые необходимы для регистрации пользователя. DTO если хочешь.
Если для создания сущности или каких-то значений нужно что-то больше чем делегация сервисов в методы билдера, то тогда выгодно использовать отдельную фабрику которая будет делать юзера через все те же билдеры.
ghost404
16.02.2017 10:27+1Мой комментарий получился немного великоват и я оформил его в виде отдельной статьи:
https://habrahabr.ru/post/321892/Sufir
17.02.2017 12:25+1Интересно и по теме, хотя и не совсем непосредственно ответ. Команды (DTO) и их обработчики я отношу к прикладному слою, а не к домену, и скармливаю их командной шине. Статья не о том в целом, вы скорее развиваете вопрос. Любопытно, спасибо за ответ и за DDD!
ghost404
17.02.2017 20:07DTO это про передачу данных. Я DTO рассматриваю как средство общения доменного слоя и слоя имплементации. Возможно я черезчур связываю DTO и слой реализации и в результате доменный слой оказывается зависимым от слоя реализации через DTO.
К сожалению, я не нашёл другого способа защетить доменный слой от использования его вне бизнесс процессов.
apelserg
Проектирование сущностей начинается с составления ER-модели. Предварительно желательно описать бизнес-процессы. Для увязки целостности процессов используется язык UML. Для этого существуют соответствующие CASE-системы (например BPWin, ERWin, Rational Rose, Oracle Designer).
Sufir
UML-проектирование — это о другом. Вероятно не совсем удачно подобрано название статьи. Тут речь о проектировании классов представляющих в коде сущности предметной-области, собственно акцент на создание объектов, а поведение и отношения остаются за рамками.
Изучение предметной области, формирование единого языка, выделение ограниченных контекстов, документирование, проектирование, описание и формализация в виде диаграмм или каким-то ещё способом отношений и бизнес-процессов и ещё много чего, всё это невозможно объять одной статьей, даже одной книгой если только в общем виде. О диаграммах, схемах и документировании в книге Эванса так же есть отличная глава. Как я указал вначале, всё это выходит за рамки данной статьи. Тут я просто поделился решением пары конкретных проблем, с которыми сам столкнулся в своём небольшом опыте.
Toshiro
UML-проектирование, это именно об этом.
https://ru.m.wikipedia.org/wiki/Диаграмма_классов
То что тема обширная — не оправдание тому, чтобы в статье хаотично все смешать в одну кашу. Есть проектирование и есть реализация. Проектирование — ERM/UML/IDEF/ARIS/итд., реализация — SQL/ORM/%language_name%/итд.
Если ваша статья про проектирование, то где диаграммы? Если про реализацию… то где диаграммы, которые вы реализуете?)) Не надо приучать новичков к плохому) Всегда начинайте любое проектирование с хоть какой-нибудь схемы)
mayorovp
Во-первых, иногда код программы сам по себе достаточно выразительный. Во-вторых, некоторые вещи на диаграмме не разглядеть (например, возвращаемое значение в сеттерах).
Delphinum
Вполне приемлемо проектирование вообще без визуальных образов, если этого достаточно для понимания архитектуры проекта всем участникам.
Sufir
Диаграммы классов я не рисую, занятие в большинстве случаев бесполезное. Слишком трудоёмко их поддерживать в актуальном состоянии, а с применением DDD код сам по себе является прекрасной документацией. Обычно хватает use case diagram, activity diagram и/или упомянутой ER-модели. В общем тут по ситуации.
Рекомендую, там подробнее.Но несомненно, всегда лучше начать с того, что бы немного порисовать, как я и сказал, к данному моменту вы «изучили предметную область, сформировали единый язык, выделили ограниченные контексты и определились с требованиями», написали проектную документацию, в соответствии с требованиями и принятым регламентом в вашей компании, почистили зубы и возможно много чего ещё сделали. Здесь речь о проектировании класса, это я делаю без схем.
Ну, и как я уже писал выше:
Я к «и т.д.» отношу ещё и объектно-ориентированное проектирование, предметно-ориентированное проектирование, а SQL/ORM/%language_name% — это детали, ООП и DDD от них принципиально не зависят, хотя и приходится считаться с техническими ограничениями одно из которых в статье разобрано.
Так вот разработка предварительных проектных решений вместе с разработкой документации на информационную систему относится к стадии реализации проекта в соответствии с ГОСТом (если не ошибаюсь 34.601-90, могу не точно помнить) и относятся к этапу технического и рабочего проектирования. Это если уж совсем в формализм.
sayber
Не всегда.
Есть бизнес подход от обратного. Когда заранее не известно что должно быть.
Тогда мы применяем DDD.