Нам — разработчикам — часто приходится писать “код валидации”, то есть проверки данных на корректность.

Сумма платежа не должна быть отрицательной, международный номер телефона должен начинаться с “+7”, и так далее.

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

Куда деть валидацию?

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

К чему относится проверка пользовательского ввода? К приложению, к домену или к инфраструктуре?

Где должна находиться проверка? В контроллере? В модели? В сервисе?

Что должен делать код если проверка не прошла? Выбросить исключение? Выдать ошибку HTTP? Записать лог? Отправить уведомление? Сообщить пользователю?

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

Постараюсь упорядочить и систематизировать знания о валидации чтобы облегчить ответы на все эти вопросы.

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

Описывать типы будем как и в статье “Вывернутый SOLID”: от проблемы к решению.

Задача 1. Хранимые данные должны быть целостными

Когда мы прочитали данные из БД, мы должны быть уверены, что они корректны.

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

В самих движках БД также есть встроенные механизмы проверок целостности, например уникальные индексы или внешние ключи.

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

Пример кода

class Cost extends \yii\db\ActiveRecord
{
    public static function tableName()
    {
        return 'cost';
    }

    public function rules()
    {
        return [
            [['id', 'created_at', 'updated_at', 'deleted_at'], 'safe'],
            [['name', 'cost', 'days', 'tariff_id'], 'required'],
            [['enabled', 'deleted', 'days'], 'integer'],
            [['discount', 'cost'], 'number'],
            [['id', 'created_by', 'updated_by', 'deleted_by', 'tariff_id'], 'string', 'max' => 16],
            [['name'], 'string', 'max' => 255],
            [['tariff_id'], 'exist', 'skipOnError' => true, 'targetClass' => Tariff::class, 'targetAttribute' => ['tariff_id' => 'id']],
        ];
    }
}

В правилах валидации только те ограничения, которые накладываются структурой данных в БД и ничего лишнего.

  • required все поля NOT NULL должны быть заполнены
  • integer в целочисленные колонки допускаются только целые числа
  • number в колонки DECIMAL допускаются только целые числа и десятичные дроби
  • string max => 16 BINARY(16) для UUID ключей
  • string max => 255 VARCHAR(255), ограничиваем длину строки 255 символов
  • exist проверяем по внешнему ключу на наличие записи в связанной таблице, прежде чем сделать вставку

Хорошо бы так и оставить. В модели Active Record оставить только код связанный с БД и не засорять его бизнес-логикой.

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

Валидация на уровне полей позволяет нам сохранить данные целостными.

Валидация на уровне полей напрямую зависит от структуры БД, поэтому это чисто инфраструктурный код. В модели AR ей самое место.

Задача 2. Должны соблюдаться бизнес-ограничения

Правила, нарушение которых говорит о том, что кто-то из разработчиков ошибся в коде.

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

При ошибке такие правила должны прерывать выполнение кода — бросать исключение.

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

Пока бизнес-логика лежит в сервисах, можно выполнять проверки в сервисах.

Где проверяем: Services, Value Objects, Entities.

Пример кода

class Money
{
    private const DECIMAL_SCALE = 2;

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

    public function __construct(string $value)
    {
        $this->value = self::normalize($value);
    }

    private static function normalize(string $value): string
    {
        if (($value === '') || !preg_match('/^(-)?[0-9]+(\\.[0-9]+)?$/', $value)) {
            throw new \LogicException("Неподдерживаемый формат денег: {$value}");
        }

        return bcadd('0', $value, self::DECIMAL_SCALE);
    }
}

Правила бизнеса обеспечивают соответствие кода требованиям бизнеса.

Бизнес-ограничения никак не зависят от кода. Наоборот, это код зависит от них.

Все бизнес-правила относятся к коду домена.

Задача 3. Мы должны получить осмысленные данные от пользователя

Мы должны проверить что данные введённые пользователем адекватны и разрешены системой.

Например, что пользователь ввёл корректный Email, что этот Email не занят другим пользователем.

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

Проверяем поля формы, по полям с ошибками собираем массив сообщений и отдаём пользователю.

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

Пример кода

class FeedbackForm extends Model
{
    /** @var string */
    public $email;

    public function rules()
    {
        return [
            ['email', 'required'],
            ['email', 'email'],
        ];
    }
}

class FeedbackController extends Controller
{
    public function actionFeedback()
    {
        $form = new FeedbackForm();
        $request = \Yii::$app->request;

        if (!$request->isPost) {
            return $this->renderFeedbackForm($form);
        }

        $form->load($request->post());

        if (!$form->validate()) {
            return $this->renderFeedbackForm($form);
        }

        $recipient = User::findByEmail($form->email);

        if ($recipient === null) {
            $form->addError('email', \Yii::t('feedback', 'Не найден пользователь с таким Email'));

            return $this->renderFeedbackForm($form);
        }

        $this->feedback()->sendFeedback($recipient, $form->content);

        return $this->refresh();
    }

    private function renderFeedbackForm(PincodeDebugEmailForm $formModel): string
    {
        return $this->render('feedback', ['model' => $formModel]);
    }
}

Проверка пользовательского ввода относится к логике приложения.