Skip to content
Permalink
master
Switch branches/tags
Go to file
@zlodes
Latest commit b2c6354 May 25, 2021 History
5 contributors

Users who have contributed to this file

@adelf @zlodes @yesnik @pluk @superrosko

Доменный слой

A> private $name, getName() и setName($name) это НЕ инкапсуляция!

Когда и зачем?

Бизнес-логика, на английском Domain logic или Логика предметной области, это та логика, которую представляют себе пользователи или заказчики. Например, для игры это будет полный свод её правил, а для финансового приложения - все сущности, которые там есть и все правила расчетов. Для блога всю бизнес-логику можно грубо описать так: есть статьи, у них есть заголовок и текст, администратор может их создать и опубликовать, а другие пользователи могут видеть все опубликованные статьи на главной странице.

Сложные приложения могут быть разделены на два класса:

  • Сложная логика приложения, но средняя или даже простая бизнес-логика. Пример: нагруженные социальные сети или иные контент-проекты. Логика приложения там огромная: SEO, интеграции с API, авторизации, поиск контента и т.д. Бизнес же логика, в сравнении с этим, довольно проста - посты и комментарии :)
  • По-настоящему сложная бизнес-логика и сложная или не очень сложная логика приложения. Пример: игры или энтерпрайз приложения.

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

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

Unit-тестирование

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

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

class CreatePostTest extends \PHPUnit\Framework\TestCase
{
    public function testSuccessfulCreate()
    {
        $post = new Post('title', '');

        $this->assertEquals('title', $post->title);
    }

    public function testEmptyTitle()
    {
        $this->expectException(InvalidArgumentException::class);

        new Post('', '');
    }
}

class PublishPostTest extends \PHPUnit\Framework\TestCase
{
    public function testSuccessfulPublish()
    {
        $post = new Post('title', 'body');

        $post->publish();

        $this->assertTrue($post->published);
    }

    public function testPublishEmptyBody()
    {
        $post = new Post('title', '');

        $this->expectException(CantPublishException::class);

        $post->publish();
    }
}

Такие тесты требуют минимальных усилий. Их легко писать и поддерживать. А реализовывать сложную логику сущности при поддержке хорошо написанных тестов - в разы проще. Простота и легкость таких тестов результат того, что класс Post реализует единственную ответственность - логика сущности Статья. Он не отвлекается на такие вещи, как база данных. Из подобных классов и состоит Доменный слой.

Простота поддержки кода

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

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

Active record и Data mapper

Eloquent является реализацией шаблона Active Record. Классы моделей Eloquent очень умные - они сами могут загрузить себя из базы и сохранить себя там же. Класс User, наследуясь от Eloquent Model, наследует огромный пласт кода, который работает с базой данных и сам становится навеки связанным с ней. Работая с ним, всегда приходится держать в голове такие факты как то, что $this->posts - это не просто коллекция объектов Post. Это псевдо-свойство, это проекция отношения posts. Нельзя просто взять и добавить туда новый объект. Придется вызвать что-то вроде $this->posts()->create(...).

Этот код не может быть покрыт unit-тестами. Приходится постоянно держать в голове базу данных и когнитивная нагрузка на программиста растёт все больше с ростом сложности логики.

Библиотеки, реализующие шаблон Data mapper, пытаются снять эту нагрузку. Классы сущностей там - обычные классы, которые не обязаны наследоваться от какого-то класса Model. Как мы успели убедиться, unit-тесты для таких классов писать весьма приятно. Когда разработчик хочет сохранить состояние сущности в базе данных, он вызывает метод persist у Data Mapper библиотеки и она, используя некоторую мета-информацию про то, как именно нужно хранить эту сущность в базе данных, обеспечивает их маппинг в базу данных и обратно (хорошего точного перевода слова mapping нет. можно перевести как отображение объектов в базе данных). Я видел две Data Mapper библиотеки, работавшие в Laravel проектах: Doctrine и Analogue. Буду использовать первую для будущих примеров. Следующие "причины" будут описывать преимущества использования чистых сущностей с помощью Data Mapper, вместо Active Record.

Высокая связность бизнес логики

Вернёмся к примеру с сущностью опроса. Опрос - весьма простая сущность, которая содержит текст вопроса и возможные ответы (опции). Очевидное условие: у каждого опроса должно быть как минимум два варианта ответа. В примере, который был раньше в книге, в действии создатьОпрос была такая проверка перед созданием объекта сущности. Это же условие делает сущность PollOption зависимой. Приложение не может просто взять эту сущность и удалить её. В действии удалитьОпциюОтвета сначала должно быть проверено, что в объекте Poll после этого останется достаточно опций ответа. Таким образом, знание о том, что в опросе должно быть как минимум две опции ответа, теперь содержится в обоих этих действиях: создатьОпрос и удалитьОпциюОтвета. Связность данного кода слабая.

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

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

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

Сдвиг фокуса с базы данных к предметной области

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

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

Пример проекта: фрилансерская биржа. Некоторые такие биржи имеют разные страницы регистрации и логина для клиентов (тех, кто заказывает работы) и фрилансеров. У других же всё в одном и они позволяют пользователям быть и клиентом и фрилансером. Первые обычно имеют две разные таблицы, например clients и freelancers. Вторые же одну: users.

В проектах с Eloquent-сущностями, разработчики выбирают сущности, исходя из структуры базы данных. Для действий с заказом это могут быть: Client & Freelancer или User & User, в зависимости от логики работы логина и регистрации. Т.е. из-за неразрывной связи с базой данных одна часть приложения влияет на другую. А во втором варианте и вовсе заставляет держать логику фрилансера и клиента в одном классе.

Когда я описываю процесс создания заказа и предложений фрилансеров на него, я хочу использовать нормальные имена сущностей. Имена, которые понятны любому, не только программисту. Я имею в виду Client и Freelancer. Также я хочу использовать осмысленные объекты значения. Если у клиента есть email, то я бы хотел использовать объект класса Email, уже рассмотренный нами ранее, а не просто какую-то строку. Если у клиента есть адрес, то опять-таки, объект Address, а не пять строк.

Если несколько разных типов сущностей, которые хранятся в одной таблице, еще можно представить и для Active Record библиотек, то полноценную поддержку объектов-значений я ещё не встречал. В то же время, любая из виденных мною data mapper библиотек поддерживает их.

Сдвиг фокуса с пользовательского интерфейса к предметной области

Некоторые разработчики начинают свои проекты с создания миграций к базе данных. Некоторые - с разработки пользовательского интерфейса (User Interface, UI). Все эти факторы влияют на конечный код.

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

Проблемы возникли тогда, когда мы решили хранить данные обо всех участниках конференции. Партнеры конференции имеют право на какое-то количество билетов. Процесс был довольно неудобный: представитель партнера создавал заказ, сообщал номер заказа организатору, тот шел в админку, ставил цену заказа в ноль и статус в... "оплачено"? Нет. Участники от партнёров должны были быть отдельно - это нужно и для правильных отчётов, и для списков участников. Появился новый статус заказа: "партнёр". А потом: "спикер", "пресса" и т.д. Новый статус, "гарантирован", появился для случаев когда организация не успевает провести безналичный платеж, но пишет официальное письмо с гарантией это сделать. Интерфейс формы заказа в админке продолжал оставаться "простым", но когнитивная нагрузка на админа возросла. Стало довольно легко сделать ошибку в этой форме, что, кстати, случалось не раз.

В коде стало появляться очень много нехороших условий в стиле if ($order->status == 'payed' || $order->status == 'guaranteed'). Некоторые из таких условий прятались в методы класса Order, но это не всегда было возможно (есть же отчёты!).

Это происходит очень часто. "Простые" интерфейсы пользователя, часто привязанные к таблицам базы данных, становятся привычны всем: заказчику, менеджеру проекта и разработчикам. Дизайнер, если он есть в команде, частенько ворчит, но и это не всегда помогает. UI от которого мало кто хочет отказываться, сильно влияет на структуру кода, поддерживать который становится всё сложнее и сложнее.

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

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

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

Инварианты сущностей

Инвариант класса, это условие на его состояние, которое должно выполняться всегда. Каждый метод, изменяющий состояние сущности, должен сохранять этот инвариант. Примеры: у клиента всегда должен быть email, опрос всегда должен содержать как минимум два варианта ответа. Условие сохранять инвариант в каждом методе, который изменяет состояние, заставляет проверять этот инвариант в этих методах.

Кстати, идея иммутабельных объектов, т.е. объектов, которые создаются раз и не меняют никаких своих значений, делает задачу поддержания инварианта простой. Пример такого объекта: объект-значение Email. Его инвариант - содержать всегда только правильное значение строки email-адреса. Проверка инварианта делается в конструкторе. А поскольку он дальше не меняет своего состояния, то в других местах эта проверка и не нужна. А вот если бы он имел метод setEmail, то проверку инварианта, т.е. корректности email, пришлось бы вызывать и в этом методе.

В сущностях Eloquent крайне трудно обеспечивать инварианты. Объекты PollOption - формально независимы, хотя и должны быть под контролем объекта Poll. Любая часть приложения может вызвать remove() метод сущности PollOption и она будет просто удалена. В итоге все инварианты Eloquent сущностей держатся буквально на честном слове: на соглашениях внутри проекта или отлаженном процессе code review. Серьезным проектам необходимо что-то более весомое, чем честное слово. Системы, в которых сам код не позволяет делать больших глупостей, намного более стабильны, чем системы полагающие, что ими будут заниматься только очень умные и высоко-квалифицированные программисты.

Реализация Доменного слоя

Какая логика является "бизнес-"?

Итак, взвесив все "за" и "против", команда решила выносить бизнес-логику в отдельный слой. Сначала надо определить какую именно логику надо выносить, а с этим бывают проблемы. Однажды я нашел github-репозиторий с "лучшими" практиками Laravel. Там было полно странноватых советов и один из них был такой: "Бизнес логика должна быть в сервисном классе" с примером:

public function store(Request $request)
{
    $this->articleService
        ->handleUploadedImage($request->file('image'));

    ....
}

class ArticleService
{
    public function handleUploadedImage($image)
    {
        if (!is_null($image)) {
            $image->move(public_path('images') . 'temp');
        }
    }
}

Это иллюстрирует то, как трудно бывает понять чем именно является бизнес-логика. Слово "бизнес" здесь ключевое. Коммерческая разработка не является средством развлечения. Она должна решать проблемы бизнеса. e-commerce приложение работает с товарами, заказами, скидками и т.д. Блог работает со статьями, комментариями и другим контентом. Картинки - это тоже контент, но для большинства приложений, содержимое картинки не является частью логики. Оно имеет значение только для графических редакторов, OCR-приложений или чего-то схожего. В обычных приложениях их просто загружают в какое-либо хранилище, и дальше используется только URL-адрес картинки.

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

Пример домена

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

final class Client
{
    /** @var Email */
    private $email;

    private function __construct(Email $email)
    {
        $this->email = $email;
    }
    
    public static function register(Email $email): Client
    {
        return new Client($email);
    }
}

Просто сущность клиента.

Где имя и фамилия клиента?

Я пока не нуждаюсь в этой информации. Кто знает, может это будет биржа с анонимными клиентами? Надо будет - добавим.

Почему тогда есть email?

Мне нужно как-то идентифицировать клиентов.

Где методы setEmail и getEmail?

Они будут добавлены как только я почувствую нужду в них.

final class Freelancer
{
    /** @var Email */
    private $email;

    /** @var Money */
    private $hourRate;

    private function __construct(Email $email, Money $hourRate)
    {
        $this->email = $email;
        $this->hourRate = $hourRate;
    }

    public static function register(
        Email $email, Money $hourRate): Freelancer
    {
        return new Freelancer($email, $hourRate);
    }
}

Сущность Фрилансер. По сравнению с клиентом было добавлено поле с часовой ставкой. Для денег используется класс Money. Какие поля там есть? Что используется для количества: float или integer? Разработчик, который работает над сущностью Фрилансер не должен беспокоиться об этом! Money просто представляет деньги в нашей системе. Этот класс умеет всё, что от него требуется для реализации доменной логики: сравнивать себя с другими деньгами и некоторые математические операции.

Изначально проект будет работать с одной валютой и будет хранить денежную информацию в одном поле integer, представляющим собой количество центов (или рублей, неважно). Через несколько лет биржа может стать международной и нужно будет добавить поддержку нескольких валют. В класс Money будет добавлено поле currency и изменена логика. В базу данных добавится поле с используемой валютой, в паре мест, где создаются объекты Money придётся добавить информацию о валюте, но главная логика, которая использует деньги, не будет даже затронута! Она как использовала объект Money, так и будет продолжать.

Это пример принципа Сокрытия информации. Класс Money предоставляет стабильный интерфейс для концепта денег. Методы getAmount():int и getCurrency():string - плохие кандидаты на стабильный интерфейс. В этом случае клиенты класса будут знать слишком многое о внутренней структуре и каждое изменение в ней будет приводить к большому количеству изменений в проекте. Методы equalTo(Money $other), compare(Money $other), plus(Money $other) и multiple(int $amount) - прячут всю информацию о внутренней структуре внутри себя. Такие, прячущие информацию, методы являются намного более стабильным интерфейсом. Его не придётся часто менять. Меньше изменений в интерфейсах - меньше хлопот при поддержке.

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

final class JobDescription
{
    // value object. 
    // Job title, description, estimated budget, etc.
}

final class Job
{
    /** @var Client */
    private $client;

    /** @var JobDescription */
    private $description;

    private function __construct(Client $client, 
        JobDescription $description)
    {
        $this->client = $client;
        $this->description = $description;
    }

    public static function post(Client $client, 
        JobDescription $description): Job
    {
        return new Job($client, $description);
    }
}

Отлично. Базовая структура сущностей создана. Давайте, добавим немного логики. Фрилансер может заявиться делать этот проект. Заявка фрилансера содержит сопроводительное письмо (или просто сообщение заказчику) и его текущую ставку. Он может в будущем поменять свою ставку, но это не должно изменить её в заявках.

final class Proposal
{
    /**
     * @var Job
     */
    private $job;
        
    /**
     * @var Freelancer
     */
    private $freelancer;

    /**
     * @var Money
     */
    private $hourRate;

    /**
     * @var string
     */
    private $coverLetter;

    public function __construct(Job $job, 
        Freelancer $freelancer, Money $hourRate, string $coverLetter)
    {
        $this->job = $job;
        $this->freelancer = $freelancer;
        $this->hourRate = $hourRate;
        $this->coverLetter = $coverLetter;
    }
}

final class Job
{
    //...

    /**
     * @var Proposal[]
     */
    private $proposals;

    protected function __construct(
        Client $client, JobDescription $description)
    {
        $this->client = $client;
        $this->description = $description;
        $this->proposals = [];
    }
    
    public function addProposal(Freelancer $freelancer, 
        Money $hourRate, string $coverLetter)
    {
        $this->proposals[] = new Proposal($this, 
            $freelancer, $hourRate, $coverLetter);
    }
}

final class Freelancer
{
    //...
    
    public function apply(Job $job, string $coverLetter)
    {
        $job->addProposal($this, $this->hourRate, $coverLetter);
    }
}

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

Фрилансер не может добавить новую заявку. Он должен поменять старую для этого. Далее, в базе данных, вероятно, будет добавлен некий уникальный индекс на поля job_id и freelancer_id в таблице proposals, но это требование должно быть реализовано в доменной логике тоже:

final class Proposal
{
    // ..
    
    public function getFreelancer(): Freelancer
    {
        return $this->freelancer;
    }
}

final class Freelancer
{
    public function equals(Freelancer $other): bool
    {
        return $this->email->equals($other->email);
    }
}

final class Job
{
    //...
    
    public function addProposal(Freelancer $freelancer, 
        Money $hourRate, string $coverLetter)
    {
        $newProposal = new Proposal($this, 
            $freelancer, $hourRate, $coverLetter);
            
        foreach($this->proposals as $proposal) {
            if($proposal->getFreelancer()
                   ->equals($newProposal->getFreelancer())) {
                throw new BusinessException(
                    'Этот фрилансер уже оставлял заявку');
            }
        }
        
        $this->proposals[] = $newProposal;
    }
}

Я добавил метод equals() в класс Freelancer. Как я уже говорил, email нужен для идентификации, поэтому если у двух объектов Фрилансер одинаковые email - то один и тот же фрилансер. Класс Job начинает знать слишком многое про класс Proposal. Весь этот foreach - это копание во внутренностях заявки. Мартин Фаулер назвал эту проблему "завистливый метод" (или Feature Envy в оригинале). Решение простое - перенести эту логику в класс Proposal:

final class Proposal
{
    //...
    
    /**
     * @param Proposal $other
     * @throws BusinessException
     */
    public function checkCompatibility(Proposal $other)
    {
        if($this->freelancer->equals($other->freelancer)) {
            throw new BusinessException(
                'Этот фрилансер уже оставлял заявку');
        }
    }
}

final class Job
{
    /**
     * ...
     * @throws BusinessException
     */
    public function addProposal(Freelancer $freelancer, 
        Money $hourRate, string $coverLetter)
    {
        $newProposal = new Proposal($this, 
            $freelancer, $hourRate, $coverLetter);
        
        foreach($this->proposals as $proposal) {
            $proposal->checkCompatibility($newProposal);
        }

        $this->proposals[] = $newProposal;
    }
}

Заявка теперь сама проверяет совместима ли она с новой заявкой. А метод Proposal::getFreelancer() больше не используется и может быть удалён.

Инкапсуляцию, которая весьма близка сокрытию информации, называют одним из трёх китов объектно-ориентированного программирования, но я постоянно вижу неверную её интерпретацию. В стиле "public $name - это не инкапсуляция, а private $name и методы getName и setName, банально имитирующие это публичное поле - инкапсуляция, потому, что в будущем можно будет переопределить поведение setName и getName". Не знаю как именно можно переопределить эти методы геттера и сеттера, но даже в этом случае всё приложение видит, что у этого класса есть свойство name и его можно как прочитать, так и записать, соответственно, будет использовать его везде и интерфейс этого класса не будет стабильным никогда.

Для некоторых классов, таких как DTO, выставлять напоказ своё внутреннее устройство - это нормально, поскольку хранение данных - это единственное предназначение DTO. Но объектно-ориентированное программирование предполагает, что объекты будут работать в основном со своими данными, а не с чужими. Это делает объекты более независимыми, соответственно, менее связанными с другими объектами, и, как следствие, более лёгкими в поддержке.

Я уже успел написать некоторую логику, но совершенно забыл о тестах!

class ClientTest extends TestCase
{
    public function testRegister()
    {
        $client = Client::register(Email::create('some@email.com'));

        // Что здесь проверять?
    }
}

Сущность клиента не имеет никаких публичных полей и геттеров. Unit-тесты не могут проверить что-либо. PHPUnit не любит когда проверки отсутствуют, он вернёт ошибку: "This test did not perform any assertions". Я мог бы создать геттер-метод getEmail() и проверить, что сущность есть и у неё email тот же, который мы передали методу register, но этот геттер-метод, который благодаря принципу сокрытия информации не потребовался в реализации бизнес-логики, будет использоваться только в тестах, что меня совсем не устраивает. Будучи добавленным, он может соблазнить слабого духом разработчика использовать его и в логике, что нарушит гармонию данного класса.

Доменные события

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

Когда вся бизнес-логика лежала в слое приложения, сервисный класс спокойно кидал события напрямую в класс Dispatcher, когда ему требуется. Доменный объект так делать не может, поскольку про объект Dispatcher он ничего не знает. Этот dispatcher можно доменным объектам предоставлять, но это может разрушить иллюзию того, что мы моделируем чистую логику. Как вы заметили, в доменных объектах, которые мы реализовали, речь идёт только о клиентах, фрилансерах и заказах, никаких баз данных, очередей и веб-контроллеров.

Поэтому, более популярным решением является простое агрегирование событий внутри доменных объектов. Простейший вариант:

final class Client
{
    //...
    
    private $events = [];
 
    public function releaseEvents(): array
    {
        $events = $this->events;
        $this->events = [];

        return $events;
    }

    protected function record($event)
    {
        $this->events[] = $event;
    }
}

Сущность записывает события, которые с ней происходят, используя метод record. Метод releaseEvents и возвращает их разом и очищает этот буфер, чтобы случайно одно событие не было обработано дважды.

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

Самым популярным решением для идентификации сущностей является целочисленное поле с авто-инкрементным значением, реализованным в движках баз данных. Оно простое, удобное, но выглядит логичным только если доменный слой не отделен полностью от базы данных. Одним из преимуществ чистых доменных объектов является консистентность (или постоянное соответствие инвариантам), т.е. например если у нас есть объект Client, то мы можем быть уверены, что у него непустой и корректный email, если требования к приложению это предполагают. Любое приложение будет предполагать, что у объекта всегда есть непустой и корректный идентификатор, однако в момент, когда сущность будет создана, но еще не будет сохранена в базе, у неё будет пустой идентификатор, а это не даст, например, сгенерировать правильные события, да и другие проблемы могут проявиться.

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

Код с предоставлением id для сущности:

final class Client
{
    private $id;
    
    /**
     * @var Email
     */
    private $email;

    protected function __construct($id, Email $email)
    {
        $this->id = $id;
        $this->email = $email;
    }
    
    public static function register($id, Email $email): Client
    {
        $client = new Client($id, $email);
        $client->record(new ClientRegistered($client->id));

        return $client;
    }
}

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

final class Client
{
    public static function importFromCsv($id, Email $email): Client
    {
        $client = new Client($id, $email);
        $client->record(new ClientImportedFromCsv($client->id));

        return $client;
    }
}

Генерация идентификатора

Наш код теперь требует, чтобы идентификатор был предоставлен сущностям извне, но как генерировать их? Авто-инкрементная колонка в базе данных делала свою работу идеально и будет трудно заменить её. Продолжать использовать авто-инкрементные значения, реализованные через Redis или Memcached - не самая лучшая идея, поскольку это добавляет новую и довольно большую возможную точку отказа (single point of failure) в приложении.

Наиболее популярный не-авто-инкрементный алгоритм для идентификаторов это Универсальный уникальный идентификатор (Universally unique identifier, UUID). Это 128-битное значение, которое генерируется одним из стандартных алгоритмов, описанных в RFC 4122. Вероятность генерации двух одинаковых значений стремится к нулю. В php есть пакет для работы с UUID - ramsey/uuid. Там реализованы некоторые алгоритмы из стандарта RFC 4122. Теперь можно писать тесты:

final class Client
{
    public static function register(
        UuidInterface $id, 
        Email $email): Client
    {
        $client = new Client($id, $email);
        $client->record(new ClientRegistered($client->id));

        return $client;
    }
}

trait CreationTrait
{
    private function createUuid(): UuidInterface
    {
        return Uuid::uuid4();
    }

    private function createEmail(): Email
    {
        static $i = 0;

        return Email::create("test" . $i++ . "@t.com");
    }
}

class ClientTest extends UnitTestCase
{
    use CreationTrait;

    public function testRegister()
    {
        $client = Client::register($this->createUuid(), 
            $this->createEmail());

        $this->assertEventsHas(ClientRegistered::class, 
            $client->releaseEvents());
    }
}

Я реализовал простую проверку assertEventsHas в базовом классе UnitTestCase, которая просто проверяет что событие определенного класса присутствует в массиве. Как вы заметили, в коде тестов используются трейты и вынесение функциональности в базовые классы, тем самым попирая всё то, о чем я писал ранее, но код unit-тестов живёт немного по другим правилам. По своей природе они должны быть намного более стабильными, чем реальный код приложения. Также этот код намного более статичный: вряд ли понадобится какой-то другой способ генерации email или проверки идентичности значений. Поэтому некоторые вольности, которые желательно пресекать в реальном коде, здесь вполне уместны.

Помните тестирование методом черного и белого ящиков? Это пример тестирования методом черного ящика. Тест ничего не знает о внутренностях объекта. Не лезет читать его email. Он просто проверяет, что на данную команду в сущности сгенерировалось ожидаемое событие. Другой тест:

trait CreationTrait
{
    private function createClient(): Client
    {
        return Client::register($this->createEmail());
    }
    
    private function createFreelancer(): Freelancer
    {
        return Freelancer::register(
                    $this->createEmail(), ...);
    }
}

class JobApplyTest extends UnitTestCase
{
    use CreationTrait;

    public function testApply()
    {
        $job = $this->createJob();
        $freelancer = $this->createFreelancer();

        $freelancer->apply($job, 'cover letter');

        $this->assertEventsHas(FreelancerAppliedForJob::class, 
            $freelancer->releaseEvents());
    }

    public function testApplySameFreelancer()
    {
        $job = $this->createJob();
        $freelancer = $this->createFreelancer();

        $freelancer->apply($job, 'cover letter');

        $this->expectException(
            SameFreelancerProposalException::class);

        $freelancer->apply($job, 'another cover letter');
    }

    private function createJob(): Job
    {
        return Job::post(
            $this->createUuid(),
            $this->createClient(),
            JobDescription::create('Simple job', 'Do nothing'));
    }
}

Эти тесты просто описывают требования к бизнес-логике системы, проверяя стандартные сценарии: заявка фрилансера на проект, повторная заявка фрилансера на проект. Никаких баз данных, моков, стабов... Никаких попыток залезть внутрь, допустим массива заявок на проект и проверить что-то там. Можно попробовать попросить обычного человека, знающего английский язык, но не разработчика, почитать данные тесты и большинство вещей он поймёт без дополнительной помощи! Такие тесты легко писать и не составит больших усилий писать их вместе с кодом, как того требуют практики TDD.

Я понимаю, что данная логика слишком проста, чтобы показать преимущества отдельного, чисто доменного кода и удобного unit-тестирования, но попробуйте представить себе игру Монополия и её реализацию. Это может помочь ощутить разницу подходов. Сложную бизнес-логику намного проще писать и поддерживать, если рядом верные unit-тесты, а инфраструктурная составляющая приложения (база данных, HTTP, очереди т .д.) хорошо отделена от главной логики.

Создание хорошей модели предметной области весьма нетривиальная задача. Я могу порекомендовать две книги: красную и синюю. Выбирать не надо. Можно прочитать обе. "Domain-Driven Design: Tackling Complexity in the Heart of Software" от Eric Evans и "Implementing Domain-Driven Design" от Vaughn Vernon. На русском они тоже есть. Книги эти довольно трудны в освоении, но там приводится довольно много примеров из реальной практики, которые могут поменять ваше представление о том, как надо строить модели предметной области. Новые понятия, такие как Aggregate root, Ubiquitous language и Bounded context помогут вам по-другому взглянуть на свой код, хотя первые два из них я совсем немного рассмотрел и в этой книге.

Маппинг модели в базу данных

После построения модели самое время подумать о базе данных, в которой нам надо красиво хранить наши сущности и объекты-значения. Я буду использовать ORM-библиотеку Doctrine для этого. Для Laravel есть пакет laravel-doctrine/orm, который содержит всё необходимое для использования её в проектах.

Доктрина позволяет использовать разные пути конфигурации маппинга. Пример с обычным массивом:

return [
    'App\Article' => [
        'type'   => 'entity',
        'table'  => 'articles',
        'id'     => [
            'id' => [
                'type'     => 'integer',
                'generator' => [
                    'strategy' => 'auto'
                ]
            ],
        ],
        'fields' => [
            'title' => [
                'type' => 'string'
            ]
        ]
    ]
];

Некоторые разработчики предпочитают держать код сущностей как можно более чистым и такая внешняя конфигурация отлично подходит. Но большинство разработчиков используют аннотации - теги в phpDoc-комментариях. В языках, типа java, аннотации поддерживаются на уровне языка, а в PHP приходится изворачиваться и анализировать их вручную (для этого есть пакет doctrine/annotations). Пример с аннотациями:

use Doctrine\ORM\Mapping AS ORM;

/** @ORM\Embeddable */
final class Email
{
    /**
     * @var string
     * @ORM\Column(type="string")
     */
    private $email;   
    //...
}

/** @ORM\Embeddable */
final class Money
{
    /**
     * @var int
     * @ORM\Column(type="integer")
     */
    private $amount;
    // ...
}

/** @ORM\Entity */
final class Freelancer
{
    /**
     * @var Email
     * @ORM\Embedded(class = "Email", columnPrefix = false)
     */
    private $email;

    /**
     * @var Money
     * @ORM\Embedded(class = "Money")
     */
    private $hourRate;
}

/**
 * @ORM\Entity()
 */
final class Job
{
    /**
     * @var Client
     * @ORM\ManyToOne(targetEntity="Client")
     * @ORM\JoinColumn(nullable=false)
     */
    private $client;
    
    //...
}

Я использую аннотацию Embeddable для объектов значений и аннотацию Embedded, чтобы использовать их в других классах. Каждая аннотация имеет параметры. Аннотация Embedded требует имя класса, и опциональный параметр columnPrefix, который полезен для генерации колонок (отлично подходит для случаев адресов fromCountry, fromCity, ... - значение from, и to для, соответственно, toCountry, toCity, ...).

Есть также аннотации для различных отношений: один ко многим (one to many), многие ко многим (many to many), и т.д.

/** @ORM\Entity */
final class Freelancer
{
    /**
     * @var UuidInterface
     * @ORM\Id
     * @ORM\Column(type="uuid", unique=true)
     * @ORM\GeneratedValue(strategy="NONE")
     */
    private $id;
}

/** @ORM\Entity */
final class Proposal
{
    /**
     * @var int
     * @ORM\Id
     * @ORM\Column(type="integer", unique=true)
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
}

Каждая сущность имеет поле с идентификатором. Тип uuid означает строку в 36 символов, в которой UUID будет храниться в своём стандартном строковом представлении, например e4eaaaf2-d142-11e1-b3e4-080027620cdd. Альтернативный вариант (немного более оптимальный по скорости): 16-байтное бинарное представление. Можно добавить и настроить пакет ramsey/uuid-doctrine для этого.

Сущность Proposal (заявка) является частью модуля Job (проект) и они никогда не будут создаваться как-либо отдельно. По большому счёту, им не нужен id, но Доктрина требует его для каждой сущности, поэтому можно добавить авто-инкрементное значение здесь.

Когда Доктрина достаёт сущность из базы данных, она создаёт объект требуемого класса без использования конструктора и просто заполняет его значениями из базы данных. Всё это работает через механизм рефлексии PHP. Как результат: объект "не чувствует" базу данных. Его жизненный цикл - естественный.

$freelancer = new Freelancer($id, $email);

$freelancer->apply($job, 'some letter');

$freelancer->changeEmail($anotherEmail);

$freelancer->apply($anotherJob, 'another letter');

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

Миграции

Пора создать нашу базу данных. Можно использовать миграции Laravel, но Доктрина предлагает еще немного магии: свои миграции. После установки пакета laravel-doctrine/migrations и запуска команды php artisan doctrine:migrations:diff, будет создана миграция:

class Version20190111125641 extends AbstractMigration
{
public function up(Schema $schema)
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'sqlite', 
    'Migration can only be executed safely on \'sqlite\'.');

$this->addSql('CREATE TABLE clients (id CHAR(36) NOT NULL --(DC2Type:uuid)
, email VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE TABLE freelancers (id CHAR(36) NOT NULL --(DC2Type:uuid)
, email VARCHAR(255) NOT NULL, hourRate_amount INTEGER NOT NULL, 
PRIMARY KEY(id))');
$this->addSql('CREATE TABLE jobs (id CHAR(36) NOT NULL --(DC2Type:uuid)
, client_id CHAR(36) NOT NULL --(DC2Type:uuid)
, title VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL, 
PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_A8936DC519EB6921 ON jobs (client_id)');
$this->addSql('CREATE TABLE proposals (id INTEGER PRIMARY KEY 
AUTOINCREMENT NOT NULL
, job_id CHAR(36) DEFAULT NULL --(DC2Type:uuid)
, freelancer_id CHAR(36) NOT NULL --(DC2Type:uuid)
, cover_letter VARCHAR(255) NOT NULL, hourRate_amount INTEGER NOT NULL)');
$this->addSql('CREATE INDEX IDX_A5BA3A8FBE04EA9 ON proposals (job_id)');
$this->addSql('CREATE INDEX IDX_A5BA3A8F8545BDF5 ON proposals (freelancer_id)');
}

public function down(Schema $schema)
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'sqlite', 
    'Migration can only be executed safely on \'sqlite\'.');

$this->addSql('DROP TABLE clients');
$this->addSql('DROP TABLE freelancers');
$this->addSql('DROP TABLE jobs');
$this->addSql('DROP TABLE proposals');
}
}

Я использовал sqlite для этого тестового проекта. Да, выглядит уродливо по сравнению с красивыми и стройными миграциями Laravel, но Доктрина может создавать их автоматически! Команда doctrine:migrations:diff анализирует текущую базу данных и мета-данные сущностей и генерирует миграцию, которая приведёт базу данных в состояние, когда она будет содержать все нужные таблицы и поля.

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

final class FreelancersService
{
    /** @var ObjectManager */
    private $objectManager;
    
    /** @var MultiDispatcher */
    private $dispatcher;

    public function __construct(
        ObjectManager $objectManager, MultiDispatcher $dispatcher)
    {
        $this->objectManager = $objectManager;
        $this->dispatcher = $dispatcher;
    }

    /**
     * Return freelancers's id.
     *
     * @param \App\Domain\ValueObjects\Email $email
     * @param \App\Domain\ValueObjects\Money $hourRate
     * @return UuidInterface
     */
    public function register(
        Email $email, Money $hourRate): UuidInterface
    {
        $freelancer = Freelancer::register(
            Uuid::uuid4(), $email, $hourRate);

        $this->objectManager->persist($freelancer);
        $this->objectManager->flush();

        $this->dispatcher->multiDispatch(
            $freelancer->releaseEvents());

        return $freelancer->getId();
    }
    //...
}

Здесь UUID генерируется в слое приложения. Это может произойти и немного ранее. Я слышал о проектах, которые просят клиентов их API сгенерировать идентификатор для новых сущностей.

POST /api/freelancers/register
{
    "uuid": "e4eaaaf2-d142-11e1-b3e4-080027620cdd",
    "email": "some@email.com"
}

Этот подход выглядит каноничным. Клиенты просто просят приложение сделать определённое действие и предоставляют все необходимые для этого действия данные. Просто "200 Ok" ответа достаточно. Значение идентификатора у них уже есть, они могут продолжить работу.

ObjectManager::persist кладёт сущность в очередь на сохранение в базу данных. ObjectManager::flush сохраняет все объекты в очереди. Посмотрим на несоздающее действие:

interface StrictObjectManager extends ObjectManager
{
    /**
     * @param string $entityName
     * @param $id
     * @return null|object
     */
    public function findOrFail(string $entityName, $id);
}

final class DoctrineStrictObjectManager 
    extends EntityManagerDecorator 
    implements StrictObjectManager
{
    /**
     * @param string $entityName
     * @param $id
     * @return null|object
     */
    public function findOrFail(string $entityName, $id)
    {
        $entity = $this->wrapped->find($entityName, $id);

        if($entity === null)
        {
            throw new ServiceException(...);
        }

        return $entity;
    }
}

Здесь я унаследовал стандартный ObjectManager Доктрины, добавив в него метод findOrFail, который делает то же самое, что и Eloquent-вариант. Разница лишь в объектах исключений. Eloquent генерирует исключение EntityNotFound, которое трансформируется в 404 ошибку. Мой StrictObjectManager будет использоваться только для операций записи, поэтому если какая-то сущность не найдена, то это больше ошибка валидации, а не ошибка, которая должна вернуть 404 страницу.

abstract class JsonRequest extends FormRequest
{
    public function authorize()
    {
        // не проверяем авторизацию в классах-запросах
        return true;
    }

    /**
     * Get data to be validated from the request.
     *
     * @return array
     */
    protected function validationData()
    {
        return $this->json()->all();
    }
}

final class JobApplyDto
{
    /** @var UuidInterface */
    private $jobId;

    /** @var UuidInterface */
    private $freelancerId;

    /** @var string */
    private $coverLetter;

    public function __construct(UuidInterface $jobId, 
        UuidInterface $freelancerId, string $coverLetter)
    {
        $this->jobId = $jobId;
        $this->freelancerId = $freelancerId;
        $this->coverLetter = $coverLetter;
    }

    public function getJobId(): UuidInterface
    {
        return $this->jobId;
    }

    public function getFreelancerId(): UuidInterface
    {
        return $this->freelancerId;
    }

    public function getCoverLetter(): string
    {
        return $this->coverLetter;
    }
}

final class JobApplyRequest extends JsonRequest
{
    public function rules()
    {
        return [
            'jobId' => 'required|uuid',
            'freelancerId' => 'required|uuid',
            //'coverLetter' => optional
        ];
    }

    public function getDto(): JobApplyDto
    {
        return new JobApplyDto(
            Uuid::fromString($this['jobId']),
            Uuid::fromString($this['freelancerId']),
            $this->get('coverLetter', '')
        );
    }
}

JsonRequest - базовый класс для всех запросов, в которых данные лежат в теле в формате JSON. JobApplyDto - простое DTO для заявки на проект. JobApplyRequest - это JsonRequest, который производит необходимую валидацию и создаёт объект JobApplyDto.

final class FreelancersController extends Controller
{
    /** @var FreelancersService */
    private $service;

    public function __construct(FreelancersService $service)
    {
        $this->service = $service;
    }

    public function apply(JobApplyRequest $request)
    {
        $this->service->apply($request->getDto());

        return ['ok' => 1];
    }
}

Контроллеры очень просты. Они лишь передают данные из объектов-запросов в сервисные классы.

final class FreelancersService
{
    public function apply(JobApplyDto $dto)
    {
        /** @var Freelancer $freelancer */
        $freelancer = $this->objectManager
            ->findOrFail(Freelancer::class, $dto->getFreelancerId());

        /** @var Job $job */
        $job = $this->objectManager
            ->findOrFail(Job::class, $dto->getJobId());

        $freelancer->apply($job, $dto->getCoverLetter());

        $this->dispatcher->multiDispatch(
                    $freelancer->releaseEvents());

        $this->objectManager->flush();
    }
}

Слой приложения тоже довольно простой. Он просит объект objectManager достать необходимую сущность из базы данных и производит необходимое бизнес-действие. Главная разница с Eloquent - это метод flush. Слой приложения не должен просить сохранить каждый изменённый объект в базу. Доктрина запоминает все объекты, которые она достала из базы и способна распознать какие изменения произошли с момента загрузки. Метод flush анализирует эти изменения и сохраняет все измененные объекты в базу.

В этом конкретном случае Доктрина находит, что новый объект был добавлен в свойство proposals и создаст новую строку в нужной таблице с нужными данными. Эта магия позволяет нам писать без оглядки на базу данных. Можно просто изменять значения полей, добавлять или удалять объекты из отношений, даже глубоко внутри первично загруженного объекта - всё это будет сохранено в методе flush.

Разумеется, всё имеет свою цену. Код Доктрины намного более сложный, чем код Eloquent. Когда я работаю с последним, при любых проблемах я могу посмотреть его код и найти ответы там. Не могу сказать подобного про Доктрину. Её более сложная конфигурация и непростые запросы посложнее обычного "найти по идентификатору", не позволяют утверждать, что она однозначно лучше Eloquent. Не забывайте, что это разные ORM, написанные с разными целями.

Вы можете ознакомиться с полным исходным кодом данного примера здесь:

https://github.com/adelf/freelance-example

Там реализован шаблон CQRS, поэтому лучше сначала прочитать следующую главу.

Обработка ошибок в доменном слое

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

// BusinessException превращается в проверяемое
abstract class BusinessException 
    extends \Exception {...}

final class SameFreelancerProposalException 
    extends BusinessException
{
    public function __construct()
    {
        parent::__construct(
            'Этот фрилансер уже оставлял заявку');
    }
}

final class Proposal
{
    //...
    
    /**
     * @param Proposal $other
     * @throws SameFreelancerProposalException
     */
    public function checkCompatibility(Proposal $other)
    {
        if($this->freelancer->equals($other->freelancer)) {
            throw new SameFreelancerProposalException();
        }
    }
}

final class Job
{
    //...
    
    /**
     * @param Proposal $newProposal
     * @throws SameFreelancerProposalException
     */
    public function addProposal(Proposal $newProposal)
    {
        foreach($this->proposals as $proposal)
        {
            $proposal->checkCompatibility($newProposal);
        }

        $this->proposals[] = $newProposal;
    }
}

Другое условие может добавиться с новыми требованиями: делать некий аукцион и не позволять фрилансерам добавлять заявку с часовой ставкой выше, чем текущая ( не самое умное требование, но бывает и хуже). Новое исключение будет добавлено в теги @throws. Это приведет к каскаду изменений в вызывающем коде.

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

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

Пара слов в конце главы

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

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

Как вы, вероятно, заметили методы геттеры (getEmail, getHourRate) оказались совсем не нужны для описания модели, поскольку принцип сокрытия информации не предполагает их. Если геттер класса А используется в классе B, то класс B начинает знать слишком многое о классе A и изменения там часто приводят к каскадным изменениям в классе B, а также многих других. Не стоит этим увлекаться.

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