Infrastructure VS Domain, или как реализовать правила предметной области

Sufir

Я не волшебник, я только учусь
PHP:
namespace Domain;

class User {
    function __construct(
        $id,
        $login,
        $email,
        // ...и ещё с десяток обязательных параметров
    );
 
    function changeEmail($email);
 
    function bindStaff($staffId);
}
1. Логин и почта пользователя должны быть уникальны.
2. Пользователь (данного приложения) может иметь привязку к сотруднику компании: привязка так же должна быть уникальной, сотрудник должен существовать и не должен быть уволенным на момент привязывания. (Сами сотрудники - это другой контекст, совершенно другой "корневой объект", "сводный корень" или как там кому больше нравится называть, совершенно друная другая БД. Не суть...).

Вроде как напрашивается спецификация?
PHP:
namespace Domain\User;

interface LoginSpecification {
    function isSatisfiedBy($login);
}
PHP:
namespace Infrastructure\User;

interface LoginSpecification {
    function isSatisfiedBy($login) {
        return $this->db->query('SELECT false ... WHERE EXISTS $login');
    }
}
Но если её инжектить в "сеттеры" как-то так $user->bindStaff($staffId, StaffSpecification); ещё понятно, но что делать с конструктором? Там итак не самая изящная портянка из полутора десятков параметров.
 

AnrDaemon

Продвинутый новичок
И что, каждый из полутора десятков параметров - обязательный и уникальным образом идентифицирует пользователя?
 

Вурдалак

Продвинутый новичок
1. Логин и почта пользователя должны быть уникальны.
PHP:
interface UserRepository
{
    /**
     * @throws LoginAlreadyExists
     * @throws EmailAlreadyExists
     */
    public function save(User $user);
}
2. Пользователь (данного приложения) может иметь привязку к сотруднику компании: привязка так же должна быть уникальной, сотрудник должен существовать и не должен быть уволенным на момент привязывания.
Я не уверен по поводу правильности использования слова «bind», но как-то так, например:
PHP:
final class User
{
    public function bindTo(StaffMember $staffMember)
    {
        if ($staffMember->isFired()) {
            throw new StaffMemberShouldNotBeFired($staffMember->getId());
        }

        $this->staffMemberId = $staffMember->getId();
    }
}
Но если её инжектить в "сеттеры" как-то так $user->bindStaff($staffId, StaffSpecification); ещё понятно, но что делать с конструктором?
http://verraes.net/2014/06/named-constructors-in-php/
PHP:
final class User
{
    public static function registerByStaffMember(
        UserId $id,
        // ...
        StaffMember $staffMember
    )
    {
        $user = new self(...);
        $user->bindTo($staffMember);

        return $user;
    }

    private function __construct(UserId $id, ...)
    {
        // ...
    }
}
(ну это с допущением, что в твоей предметной области сотрудник может зарегать юзера и ты имел в виду это).
(Сами сотрудники - это другой контекст, совершенно другой "корневой объект", "сводный корень" или как там кому больше нравится называть, совершенно друная другая БД. Не суть...)
А что тебя смущает?
 
Последнее редактирование:

Sufir

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

http://verraes.net/2014/06/named-constructors-in-php/
(ну это с допущением, что в твоей предметной области сотрудник может зарегать юзера и ты имел в виду это).
Нет, это не то. Точнее пользователя может зарегать ТОЛЬКО сотрудник и только одним способом. Поэтому в именованных конструкторах нет необходимости, есть единственный дефолтный способ создания пользователей.

Я не уверен по поводу правильности использования слова «bind», но как-то так, например:
PHP:
final class User
{
    public function bindTo(StaffMember $staffMember)
    {
        if ($staffMember->isFired()) {
            throw new StaffMemberShouldNotBeFired($staffMember->getId());
        }

        $this->staffMemberId = $staffMember->getId();
    }
}
А что тебя смущает?
Меня смутило то, что неоднократно встречал упоминание, что корневые объекты нужно связывать через ID. Видимо так запало, что о таком варианте я даже не подумал... Да, спасибо, это действительно то что нужно и гораздо более гибко можно реализовать проверку и более явно выражено в коде.

По поводу «bind» - я не силен в английском, взял первое пришедшее на ум слово. Но смысл тут другой. Не "привязать к/закрепить за", а "привязать учётную запись сотрудника компании к пользователю". Т.е. что данный пользователь приложения ещё и является сотрудником и это ссылка на него.
 
Последнее редактирование:

Sufir

Я не волшебник, я только учусь
PHP:
interface UserRepository
{
    /**
     * @throws LoginAlreadyExists
     * @throws EmailAlreadyExists
     */
    public function save(User $user);
}
Да, я об этом думал, но меня смутило то, что до/без вызова $repository->save() сущность будет находиться в не валидном состоянии. А если использовать доктрину, то в ->save($user) при изменении нет необходимости, у меня будет: ->add($user), а flush() будет вызываться где-то уже вне контекста работы с доменными объектами. Хотелось эти правила внедрить раньше, при работе с сущностью и как-то более явно, поэтому спецификации...

В общем это будет выглядеть как-то так?
PHP:
$user = new User($id, $login, ...);
$userRepository->save($user);

$user = $userRepository->get($id);
$user->bindStaff($staff);
$user->changeEmail($email);
try {
    $userRepository->save($user);
} catch (EmailAlreadyExists $exc)
 
Последнее редактирование:

AnrDaemon

Продвинутый новичок
Ты либо не понял, либо не до конца дочитал мой вопрос.
Либо внутренняя логика твоей сущности убога.
Когда ты создаёшь объект, тебе должно быть необходимо и достаточно передать ТОЛЬКО данные, уникальным образом его идентифицирующие.
Всю остальную требуху - в сеттеры, с валидацией.
Сохранять объект ты всё равно будешь отдельно, вот там и сделаешь проверку, все ли поля заполнены.
Завтра у тебя изменится набор полей - что будешь делать? Писать (null, null, "хрен", null) ? А голова не лопнет от подсчёта нуллов? Менять сигнатуру метода? По всему коду за ним бегать будешь?
В общем, подумай ещё раз, какую [цензуру] ты делаешь…
 

Sufir

Я не волшебник, я только учусь
Завтра у тебя изменится набор полей - что будешь делать?
Набор полей изменится, если изменятся требования предметной области. По Эвансу все изменения предметной области должны отображаться в модели, а значит и в коде, который является выражением модели. Да, меня тоже немного смущает такой раздутый некрасивый конструктор, но мне кажется это более правильным чем сеттеры. Даже изменения смысла терминов или самих терминов - это должно отражаться на коде, что уж тогда говорить об изменениях бизнес-правил?
Эрик Эванс написал(а):
Изменения в языке следует принимать как изменения в модели - соответственно группа разработчиков должна вносить изменения в диаграммы классов, переименовывать классы и методы в исходном коде или даже изменять функции программы при изменении значения того или иного термина.
...
Код при таком подходе является выражением модели, так что изменения в коде будут и изменениями модели, а влияние таких изменений должно расходиться, как круги по воде, по всем видам работ в проекте.
А, что-бы "по всему коду за ним не бегать", будет комманда CreateUser, которая и будет использоваться "по всему коду" и вслучае изменений в предметной области изменения будут вноситься только в ней. Вполне вероятно, что я что-то не верно понимаю, серьёзного опыта у меня в этом пока не было. Я достаточно много читал по теме, что бы разобраться, но на практике попробовать возможность появилась только сейчас, поэтому ошибки вполне закономерны. Потому и обращаюсь за помощью.
При чем здесь уникальность я действительно не понял. "[Цензуру]" с "требухой" я делаю, что бы сущность сразу после создания была валидной. Если уж пилить сеттеры и проверять только при сохранении, то и всё можно в сеттеры убрать. Зачем тогда зачем вообще передавать и "уникальным образом его идентифицирующие"? И это тоже сеттером установить можно. Проверяться-то всё равно будет при сохранении... Поясни.
 
Последнее редактирование:

Вурдалак

Продвинутый новичок
Нет, это не то. Точнее пользователя может зарегать ТОЛЬКО сотрудник и только одним способом. Поэтому в именованных конструкторах нет необходимости, есть единственный дефолтный способ создания пользователей.
Да в принципе это неважно, лучше обычных конструкторов для aggregate root избегать.

Да, я об этом думал, но меня смутило то, что до/без вызова $repository->save() сущность будет находиться в не валидном состоянии.
Свойство уникальности не принадлежит самой сущности.

А если использовать доктрину, то в ->save($user) при изменении нет необходимости, у меня будет: ->add($user), а flush() будет вызываться где-то уже вне контекста работы с доменными объектами. Хотелось эти правила внедрить раньше, при работе с сущностью и как-то более явно, поэтому спецификации...
Я не понимаю почему детали реализации должны на это влиять. ->save() должен вызываться всегда.

В общем это будет выглядеть как-то так?
PHP:
$user = new User($id, $login, ...);
$userRepository->save($user);

$user = $userRepository->get($id);
$user->bindStaff($staff);
$user->changeEmail($email);
try {
    $userRepository->save($user);
} catch (EmailAlreadyExists $exc)
Я сомневаюсь, что тебе нужно тут же ловить этот exception.

Что касается полутора десятка полей, то возможно что-то можно вынести в другой контекст, что-то объединить в whole value.
 

Вурдалак

Продвинутый новичок
Не "привязать к/закрепить за", а "привязать учётную запись сотрудника компании к пользователю". Т.е. что данный пользователь приложения ещё и является сотрудником и это ссылка на него.
А не проще ли избавить этот контекст от понятия staff member вообще, просто задавая userId, совпадающий со staffMemberUserId?

Решения могут сильно отличаться в зависимости от бизнес-требований.
 

Sufir

Я не волшебник, я только учусь
Я сомневаюсь, что тебе нужно тут же ловить этот exception.
Ну, это условный пример, этот код будет обёрнут в комманду и т.д. С этим я ещё посмотрю.

Что касается полутора десятка полей, то возможно что-то можно вынести в другой контекст, что-то объединить в whole value.
С Whole Value скорее всего так и сделаю, пока предварительные наброски делаю, и по ходу углубления будет яснее. Я вообще первый момент думал сделать все параметры Value Object и в них же поместить соответствующие правила.

А не проще ли избавить этот контекст от понятия staff member вообще, просто задавая userId, совпадающий со staffMemberUserId?
Эмм... не понял. Имеешь в виду ID пользователя = ID сотрудника? Это невозможно, т.к. обе базы уже существуют и данные есть и используются, они уже не совпадают. Дело в том, что это новая версия очень старого проекта, и с этим приходится считаться. Если от старого кода отказаться решили (точнее часть функционала вынести на отдельный проект, т.к. тот очень большой), по крайней мере частично, то от базы нельзя, т.к. оно очень много где используется.
 

Вурдалак

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

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

Ты либо не понял, либо не до конца дочитал мой вопрос.
...
Сохранять объект ты всё равно будешь отдельно, вот там и сделаешь проверку, все ли поля заполнены.
Есть ещё вариант, что ты не понял о чём тред.
 
Последнее редактирование:

Sufir

Я не волшебник, я только учусь
И ещё такой вопрос. В подобных случаях $user->bindStaff(Staff $staff); где $staff по сути является подсущностью (внутренним объектом агрегата, но именно сущностью, а не VO), для него заводить репозиторий (в данном случае исключительно read-only, понятное дело)? Понятное дело только для чтения? Или ещё какие-то техники/шаблоны есть для этого? Просто тот же Эванс, пишет, если я правильно помню, что хранилища должны иметь только корневые объекты агрегатов.

Да в принципе это неважно, лучше обычных конструкторов для aggregate root избегать.
А в чём причина? Какие могут быть пролемы и в чём приимущество Entity::create() перед new Entity()?
 

Вурдалак

Продвинутый новичок
И ещё такой вопрос. В подобных случаях $user->bindStaff(Staff $staff); где $staff по сути является подсущностью
StaffMember тут вполне себе отдельный aggregate root. Хранить идентификатор в виде value object'а на другой AR — нормально.

А в чём причина? Какие могут быть пролемы и в чём приимущество Entity::create() перед new Entity()?
Entity::create(), пожалуй, скорее всего не имеет смысла. Но осмысленный с точки зрения домена глагол — имеет. Юзеры обычно регистрируются (register), ставки предлагаются (offer), фотки загружаются (upload), etc.

Причин появления экземпляра сущности может быть несколько и логика там может слегка отличаться. Например, пользователь может зарегаться сам, может — по ссылке от партнера и т.д. И если там есть различие в состоянии сущности при этих двух регистрациях, то это и методы регистрации должны быть разными, т.е. User::register(), User::registerByPartnerRef(..., PartnerId $partnerId), etc. К примеру, при обычной регистрации пользователю будет требоваться подтвердить email, а при регистрации через партнера — нет. А голый конструктор с кучей свойств — это та же самая анемия. Он ничего не говорит о конкретном use case'е.
 

AnrDaemon

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

Sufir

Я не волшебник, я только учусь
@Вурдалак, спасибо за подсказки и направление, пока мне нравится, что получается.
@AnrDaemon, не нервничай, после того как я объединил данные в логические группы в ValueObject-ы, в конструкторе осталось 7 параметров (с Identity), что на мой взгляд уже вполне приемлемо и выглядит очень прилично.

Вот такой ещё вопрос. Как быть c простейшими действиями бедными логикой? Управление словарями/справочниками, например, которое сводится к редактированию данных и их валидации?
Т.е. есть некоторые справочные данные, допустим реквизиты клиента или данные пользователя. По сути тут CRUD с минимальной валидацией на корректность данных (email, ИНН и т.п.), в UI это здоровенная форма с кучей полей и кнопкой "сохранить". В общем тот случай, когда и AR прекрасно справляется...
PHP:
// Тут получается, либо нездоровый:
$entity->update(...); // C кучей параметров, да ещё значительна доля которых необязательные. Что выглядит довольно уродливо

// Либо, опять же практически бессмысленные сеттеры:
$entity->setValue1()->setValue2()->...;

// Комбинированный вариант, воспользоваться неким DTO, который использовать для Entity::update()?
$updateDTO->setValue1()->setValue2()->...;
$entity->update($updateDTO);
Но что-то ни один из вариантов мне особенно не нравится. Может я вообще не в ту сторону смотрю?
 

Вурдалак

Продвинутый новичок
Да, я об этом думал, но меня смутило то, что до/без вызова $repository->save() сущность будет находиться в не валидном состоянии. А если использовать доктрину, то в ->save($user) при изменении нет необходимости, у меня будет: ->add($user), а flush() будет вызываться где-то уже вне контекста работы с доменными объектами.
Кстати, Vernon Vaughn это называет «collection-oriented repository». Т.е. по аналогии с обычными in-memory collection, когда для уже существующего в коллекции объекта мы метод сохранения не вызываем, т.к. репозиторий уже хранит ссылку на этот объект. Но, на мой взгляд, это leaky abstraction и исключения типа EmailIsNotUnique становятся неявными.

Как быть c простейшими действиями бедными логикой? Управление словарями/справочниками, например, которое сводится к редактированию данных и их валидации?
Т.е. есть некоторые справочные данные, допустим реквизиты клиента или данные пользователя. По сути тут CRUD с минимальной валидацией на корректность данных (email, ИНН и т.п.), в UI это здоровенная форма с кучей полей и кнопкой "сохранить".
Я бы сделал так:
PHP:
if ($command->getEmail()) {
    $user->changeEmail(new Email($command->getEmail()));
}

// ...

$this->userRepository->save($user);
Или так:
PHP:
// value object
$profile = new Profile(
    new Email($command->getEmail()),
    ...
);

$user->editProfile($profile);
 

Adelf

Administrator
Команда форума
А почему репозиторий проверяет на уникальность email и кидает соответствующие исключения? Ему не фиолетово - уникальные они или нет? Я всегда думал что это задача слоя повыше. Business layer.
 

Вурдалак

Продвинутый новичок
А почему репозиторий проверяет на уникальность email и кидает соответствующие исключения? Ему не фиолетово - уникальные они или нет? Я всегда думал что это задача слоя повыше. Business layer.
А repository там и находится
 

AnrDaemon

Продвинутый новичок
Repository это не помойка, это хранилище данных для работы программы. Если программа подразумевает уникальный емейл, репо должно обеспечить эту уникальность хотя бы на своём уровне.
IMHO.
 
Сверху