Доменные Value Object 30.04.2024

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

Когда проектируешь агрегат, постоянно возникают вопросы вида «а нужно ли здесь объединить нескольких полей в Value Object?» или «а нужен ли тут Value Object?». Все эти вопросы также перемежаются с ограничениями используемого DataMapper, потому что одни из них не позволяют вложенность одного объекта-значения в другой, другие не позволяют делать внутри объектов-значения связи с другими агрегатами. А третьи позволяют все это, но страдай с первыми двумя вариантами, ведь у тебя PHP и CycleORM/Doctrine, а не Hibernate или EntityFramework.

Чем плох обычный string или int, ведь первый уже не допустит целочисленное значение в себя, а второй не допустит строку? Конечно, если разработчик не сделает соответствующие допущения руками или забудет включить strict-режим для проверки типов.

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

Если кратко: да, любое простое значение следует обернуть в Value Object. Даже если кажется, что это сейчас выглядит чем-то сверх. Даже если для его добавления требуется написать как минимум сам VO и один-два ORM-конвертера для гидрации и дегидрации, а разработчику очень лень и вообще в компании принято экономить туалетную бумагу и классы. Польза от этого в долгосрочной перспективе несколько раз успеет покрыть начальные временные и ресурсные затраты. Скупой, как говорится, платит дважды.

Давайте посмотрим на пример простого агрегата покупателя чего-либо.

final class Customer
{
/**
* @var string $id Идентификатор
* @var string $lastName Фамилия
* @var string $firstName Имя
* @var null|string $secondName Отчество
* @var string $phone Телефон
*/

public function __construct(
public string $id,
public string $lastName,
public string $firstName,
public ?string $secondName,
public string $phone,
) {}
}
Сразу же оговорюсь, что публичные поля должны быть закрыты от прямого внешнего изменения через модификатор доступа public для чтения и private для записи хотя бы на уровне статического анализатора, но это уже другая тема, которая усложнит мои примеры.

Какие проблемы могут быть в этом коде?

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

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

final class Customer
{
/**
* @var non-empty-string $id Идентификатор
* @var non-empty-string $lastName Фамилия
* @var non-empty-string $firstName Имя
* @var null|non-empty-string $secondName Отчество
* @var numeric-string $phone Телефон
*/

public function __construct(
public string $id,
public string $lastName,
public string $firstName,
public ?string $secondName,
public string $phone,
) {}
}

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

На самом деле вообще нет. В UUID мы все так же можем засунуть как произвольную строку, так и строку, состоящую из пробелов. А в телефон мы можем поместить -123.45, и это также будет валидно. Кроме того, phpDoc только подсказывает разработчику в момент конструирования и линтеру в момент прохода статического анализа более точные типы, не предусмотренные языком. Но здесь все еще сохраняются возможности передать пустую строку через вот такую конструкцию и тем самым нарушить инвариант:

/** @var non-empty-string $id */
$id = '';

new Customer(
id: $id,
// ...
);

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

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

final class Customer
{
/**
* @var non-empty-string $id Идентификатор
* @var non-empty-string $lastName Фамилия
* @var non-empty-string $firstName Имя
* @var null|non-empty-string $secondName Отчество
* @var numeric-string $phone Телефон
*/

public function __construct(
public string $id,
public string $lastName,
public string $firstName,
public ?string $secondName,
public string $phone,
) {
Assert::uuid($id);
Assert::notWhitespaceOnly($lastName);
Assert::notWhitespaceOnly($firstName);
Assert::nullOrNotWhitespaceOnly($secondName);
Assert::regex($phone, '/^79\d{9}$/');
}
}

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

И снова зададимся тем же самым вопросом: стало ли лучше?

Теперь можно уверенно говорить, что, по сравнению с первой версией нашего агрегата, эта версия уже не пропустит что-то отличное от UUID в идентификатор, а мы этого и добивались. Код стал более бизнесовым, он лучше соответствует моделируемой предметной области, так как реализует все необходимые (или их большинство) важных бизнес-правил. В телефон придет только телефон и, если там произвольная строка или телефон любой другой страны, мы вывалимся через fail fast с LogicException и тем самым не допустим проникновение заведомо неконсистентных данных в наше хранилище.

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

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

Все это приводит нас к осознанию необходимости использовать объекты-значения:

final class Customer
{
/**
* @var CustomerId $id Идентификатор
* @var CustomerLastName $lastName Фамилия
* @var CustomerFirstName $firstName Имя
* @var null|CustomerSecondName $secondName Отчество
* @var PhoneNumber $phone Телефон
*/

public function __construct(
public CustomerId $id,
public CustomerLastName $lastName,
public CustomerFirstName $firstName,
public ?CustomerSecondName $secondName,
public PhoneNumber $phone,
) {}
}
readonly final class Phone
{
/**
* @param numeric-string $value
*/

public function __construct(
public string $value,
) {
Assert::regex($phone, '/^79\d{9}$/');
}
}

Даже если кажется, что в каком-либо месте допустима произвольная строка, а значит хватит простого типа string для покрытия нужд, нужно задать вопрос: а точно ли оно так? Допустима ли пустая строка? А строка, состоящая только из пробелов? А китайских иероглифов? А если нулевые байты? А строка, длиной в N+1 символов?

Но и это еще не все. Если посмотреть внимательно на агрегат покупателя, можно увидеть, что у него на самом-то деле лишь 3 поля, а не 5. Какие? Вот они слева направо: идентификатор, имя и телефон. Фамилия, имя и отчество — это три компонента, составляющие имя, поэтому итоговый код выглядел бы так:

final class Customer
{
/**
* @var CustomerId $id Идентификатор
* @var CustomerName $name Имя
* @var PhoneNumber $phone Телефон
*/

public function __construct(
public CustomerId $id,
public CustomerName $name,
public PhoneNumber $phone,
) {}
}
readonly final class CustomerName
{
/**
* @param non-empty-string $last Фамилия
* @param non-empty-string $first Имя
* @param null|non-empty-string $second Отчество
*/

public function __construct(
public string $last,
public string $first,
public ?string $second,
) {
Assert::notWhitespaceOnly($lastName);
Assert::notWhitespaceOnly($firstName);
Assert::nullOrNotWhitespaceOnly($secondName);
}
}

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

В заключении

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

Выделяя каждое значение в отдельный Value Object, мы получаем множество преимуществ.

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

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

Третье, не самое очевидное: в языке PHP отсутствует перегрузка конструкторов. Это хороший синтаксический сахар в некоторых языках, но даже там из-за перегрузки конструкторов теряется самая важная вещь: понятность конструкций. Объекты-значения позволяют добавить к себе специальные статические методы, так называемые именованные конструкторы, чтобы сделать конструкции в коде еще более понятными при чтении. Мы можем добавить два таких статических метода к телефону, где первый — fromString — будет создавать VO из простой строки, а второй — fromComponents — из кода оператора и основного номера, если это требуется где-либо. При этом предусловия будут инкапсулироваться ровно в одном месте, ведь для бизнеса нет разницы — из одного строкового аргумента создается этот телефон или нескольких.

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

Пятое: мы получаем тот самый DRY и переиспользуемость знаний предметной области. Если телефон используется в нескольких разных агрегатах, при этом бизнес-ограничения у этих телефонов идентичны, то мы берем уже готовый VO и используем его.

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

Седьмое: добавление VO делает код более выразительным, так как имя типа уже несет в себе некоторые бизнес-контекст и самодокументируемость, чем простой non-negative-int. Также разработчик уже не сможет в телефон поместить UUID, это будет ошибкой на уровне типизации, о чем ему будут сигнализировать как IDE, так и линтеры.

Любите свой домен. У него нет никого, кроме вас.