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

Статья рассчитана не на новичков, потому нормально, если по ходу чтения какие-то понятия будут вам неизвестны, я постарался коротко раскрыть их здесь, а также указал ссылки на посты в моём телеграм канале Beer::PHP ?, которые могут чуть подробнее раскрыть то или иное понятие.

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

Валидация Entity

Рано или поздно, пользовательские данные переданные в наше приложение попадают во внутрь Entity.

Entity — это объекты, которые хранят состояние вашего приложения.

Но не "просто хранят". Сущность всегда должна защищать свои доменные инварианты и следить за тем, чтобы она находилась в согласованном состоянии.

Инварианты — это некоторые условия, которые остаются истинными на протяжении всей жизни объекта. Как правило, инварианты передают внутреннее состояние объекта.

Entity не должна существовать в вашем приложении если внутри неполные или невалидные данные.

Как этого добиться?

Проверка данных в конструкторе

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

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

class Order
{
    private Product $product;
    private int $quantity;
    
    public function __construct(Product $product, int $quantity)
    {
        if ($quantity <= 0) {
            throw new MinQuantityException();
        }
    }
}

Но ведь мы же не будем показывать пользователям исключения?

Всё правильно, исключения не для пользователей. Exceptions, трассировка и контекст должны быть видны только разработчикам. Все исключения выброшенные разработчиком должны быть обработаны перед тем как вывести пользователю что-то на экран.

Проверка данных в методе

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

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

$order->getStatus();  
// isn't delivered 
$order->setCancel();

Определите метод cancel(), который будет выполнять проверки внутри сущности и если всё согласовано — менять её состояние.

class Order
{
    // ...
    public function cancel(): void
    {
        if ($this->status === STATUS::DELIVERED) {
            throw new LogicException(
                sprintf(
                    'Order %s has already been delivered', 
                    $this->id->asString()
                )
            );
        }     
        
        $this->status = STATUS::CANCEL;
    }
}

Используйте Value Objects для проверки отдельных значений

Данный подход позволяет и делегировать проверки, и переиспользовать их в дальнейшем в других частях нашего приложения. Для примера возьмем класс Account, который уже валидирует свои данные в конструкторе и в одном из методов:

class Account
{
    private string $accountNumber;
    private float $amount = 0.00;
    private string $currency = 'USD';
    
    const NUMBER_OF_CHARACTERS = 16;
    
    public function __construct(string $accountNumber)
    {
        if (strlen($accountNumber) !== self::NUMBER_OF_CHARACTERS) {
            // throw exception 
        }
        
        $this->accountNumber = $accountNumber;
    }
    
    public function putMoney(float $amount, string $currency)
    {
        if ($amount <= 0) {
            // throw exception 
        }
        
        if ($currency !== $this->currency) {
            //thow exception
        }
        
        $this->amount += $amount;
    }
}

Мы можем отдельно вынести AccountNumber, переместив в него всю валидацию.

Код AccountNumber
class AccountNumer
{
    const NUMBER_OF_CHARACTERS = 16;

    private string $accountNumber;

    public function __construct(string $accountNumber)
    {
        if (strlen($accountNumber) !== self::NUMBER_OF_CHARACTERS) {
            // throw exception
        }
        // another rules

        $this->accountNumber = $accountNumber;
    }

    public function __toString(): string
    {
        return $this->accountNumber;
    }
}

Отдельно выделить Value Object Money который также может взять на себя операцию сложения для логики пополнения счета.

Код Money
class Currency extends SplEnum // Пока не 8.1
{
    const __default = self::USD;

    const USD = 'USD';
    const EUR = 'EUR';

    // etc.
}

class Money
{
    private float $amount;
    private string $currency;

    public function __construct(float $amount, Currency $currency)
    {
        if ($amount <= 0) {
            // throw exception
        }

        $this->amount = $amount;
        $this->currency = $currency;
    }

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

    public function amount(): float
    {
        return $this->amount;
    }

    public function append(Money $money)
    {
        if ($money->currency() !== $this->currency) {
            // throw exception
        }
        $amount = $this->amount + $money->amount();

        return new self($amount, $this->currency);
    }
}

Тогда наша Entity будет иметь примерно следующий вид:

class Account
{
    private AccountNumer $accountNumber;
    private Money $money;

    public function __construct(AccountNumer $accountNumber)
    {
        $this->accountNumber = $accountNumber;
        $this->money = new Money(0.00, 'USD');
    }

    public function putMoney(Money $money)
    {
        $this->money = $this->money->append($money);
    }
}

Так как в основной сущности мы уже работаем с валидными Value Objects, то нет необходимости проверять что-то дополнительно внутри сущности, мы и так всё затайпхинтили.

Whole value concept (Quantity pattern)

Я часто вижу, что этому концепту уделяют мало внимания при проектировании Value Objects, потому решил отдельно на нём остановиться.

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

Идея простая. Представим, что у нас есть геопозиция. Чтобы понять где именно находится точка нам нужна и широта и долгота. Поскольку сами по себе "широта" или "долгота" не имеют смысла друг без друга, значит они должны находиться в одном месте, внутри одного объекта. Другими словами не нужно создавать отдельные VO, если сами по себе они ничего не значат, а только являются составляющей другого объекта.

Наш пример (Money). У нас есть сумма денег, которую нам нужно сложить с другой суммой. Чтобы принять решение можем ли мы сложить две amount, мы должны проверить currency. Поскольку currency напрямую влияет на логику вычислений, то оно должно находиться там-же, где и amount.

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

Eсли у нас есть данные которые влияют на логику - они должны быть частью состояния объекта где эта логика реализована. Да-да, вычисления (логика) также должны находиться внутри (например сложение/вычитание денег или вычисление расстояния в случае с гео).

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

Это не значит, что нужно совсем перестать оборачивать в VO примитивные типы (строки, числа и т.д.). Это значит, что при проектировании стоит задумываться о целесообразности того или иного объекта в вашей предметной области.

Не нужно создавать для Entity сервисы валидации

В доменном слое это усложнит вам жизнь. Вам придется делать бесконечные и ненужные геттеры внутри Entity (ведь для валидатора данные нужно будет как-то извлечь), следить за тем что нужно обновить сервис в случае изменения самой сущности и не забывать его вызвать каждый раз при её создании.

Связь с другой сущностью

Отношения лучше выстраивать с помощью идентификаторов, а не по ссылкам на объект. Таким образом мы понижаем связанность (Low Coupling), а также убираем возможность нежелательных изменений, которые могут происходить внутри связанной сущности.

Если в качестве связи с другой сущностью в метод или в конструктор мы передаём ID, то мы наверняка не можем быть уверены, что Entity с таким ID существует в рамках нашей системы, ведь на входе мы можем убедиться лишь в том, что ID соответствует определенному шаблону (например UUID).

Заключение

Правильное проектирование валидации бизнес логики само по себе сильно упростит вам жизнь. Оперируйте в вашем приложении только полными, валидными и консистентными объектами.

В следующей части мы поговорим с вами о пользовательской валидации и подробнее разберём исключения.

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