Валидация: внутри сущностей или снаружи?

Автор оригинала: Jimmy Bogard
  • Перевод
Обратите внимание, что хотя пост написан от первого лица, это перевод статьи из блога Jimmy Bogard, автора AutoMapper.

Меня часто спрашивают, особенно в контексте архитектуры вертикальных слоев (vertical slice architecture), где должна происходить валидация? Если вы применяете DDD, вы можете поместить валидацию внутри сущностей. Но лично я считаю, что валидация не очень вписывается в ответственность сущности.

Часто валидация внутри сущностей делается с помощью аннотаций. Допустим, у нас есть Customer и его поля FirstName/LastName обязательны:
public class Customer
{
    [Required]
    public string FirstName { get; set; }
    [Required]
    public string LastName { get; set; }
}

Проблем с таким подходом две:
  • Вы изменяете состояние сущности до валидации, то есть ваша сущность может находиться в невалидном состоянии
  • Неясен контекст операции (что именно пытается сделать пользователь)

И хотя вы можете показать ошибки валидации (обычно генерируемые ORM) пользователю, не так-то просто сопоставить исходные намерения и детали реализации состояния. Как правило, я стараюсь избегать такого подхода.

Однако, если вы придерживаетесь DDD, вы можете обойти проблему изменяющегося состояния, добавив метод:
public class Customer
{
  public string FirstName { get; private set; }
  public string LastName { get; private set; }
    
  public void ChangeName(string firstName, string lastName) {
    if (firstName == null)
      throw new ArgumentNullException(nameof(firstName));
    if (lastName == null)
      throw new ArgumentNullException(nameof(lastName));
      
    FirstName = firstName;
    LastName = lastName;
  }
}

Немного лучше, но лишь немного, потому что исключения — единственный способ показать ошибки валидации. Исключения вы не любите, поэтому берете какой-нибудь вариант результата [выполнения] команды (command result):
public class Customer
{
  public string FirstName { get; private set; }
  public string LastName { get; private set; }
    
  public CommandResult ChangeName(ChangeNameCommand command) {
    if (command.FirstName == null)
      return CommandResult.Fail("First name cannot be empty.");
    if (lastName == null)
      return CommandResult.Fail("Last name cannot be empty.");
      
    FirstName = command.FirstName;
    LastName = command.LastName;
    
    return CommandResult.Success;
  }
}

И опять отображение ошибки пользователю вызывает раздражение, так как возвращается только одна ошибка за раз. Я мог бы вернуть их всем скопом, но как тогда мне сопоставить их с именами полей на экране? Никак. Очевидно, сущности хреново подходят для валидации команды. Однако, для этого прекрасно подходят фреймворки валидации (validation frameworks).

Валидация команды (command validation)


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

При таком подходе моя валидация строится вокруг команд и действий, а не сущностей. Я мог бы сделать что-то типа такого:
public class ChangeNameCommand {
  [Required]
  public string FirstName { get; set; }
  [Required]
  public string LastName { get; set; }
}

public class Customer
{
  public string FirstName { get; private set; }
  public string LastName { get; private set; }
    
  public void ChangeName(ChangeNameCommand command) {
    FirstName = command.FirstName;
    LastName = command.LastName;
  }
}

Мои атрибуты валидации находятся в самой команде, и только при условии валидности команды я смогу применить ее к моим сущностям для перевода их в новое состояние. Внутри сущности я должен просто обработать команду ChangeNameCommand и выполнить переход в новое состояние, будучи уверенным, что выполняются мои инварианты. Во многих проектах я использую FluentValidation:
public class ChangeNameCommand {
  public string FirstName { get; set; }
  public string LastName { get; set; }
}

public class ChangeNameValidator : AbstractValidator<ChangeNameCommand> {
  public ChangeNameValidator() {
    RuleFor(m => m.FirstName).NotNull().Length(3, 50);
    RuleFor(m => m.LastName).NotNull().Length(3, 50);
  }
}

public class Customer
{
  public string FirstName { get; private set; }
  public string LastName { get; private set; }
    
  public void ChangeName(ChangeNameCommand command) {
    FirstName = command.FirstName;
    LastName = command.LastName;
  }
}

Ключевое отличие здесь в том, что я валидирую команду, а не сущность. Сущности сами по себе — не библиотеки для валидации, так что гораздо более правильно (much cleaner) делать валидацию на уровне команд. При этом ошибки валидации прекрасно коррелируют с интерфейсом, так как именно вокруг команды в первую очередь и строился этот интерфейс.

Валидируйте команды, а не сущности, и выполняйте валидацию на границах (perform the validation at the edges).
Поделиться публикацией

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 39

    +1
    Автор ставит телегу (валидацию) впереди паровоза (архитектуры).
    Если у него CRUD, и сущность тупо копируется в UI а потом отредактированная копируется в репозиторий, тут нет никаких команд.
    Если CQRS, логично, что валидировать надо команду, тот объект, который уходит из UI.

    Ну и мелкое лукавство тоже присутствует. Почему «отображение ошибки пользователю вызывает раздражение, так как возвращается только одна ошибка за раз». Так же можно сделать валидатор сущности через RuleFor
      0
      Почему «отображение ошибки пользователю вызывает раздражение, так как возвращается только одна ошибка за раз». Так же можно сделать валидатор сущности через RuleFor

      Основная мысль здесь в том, что даже если вернется несколько ошибок одновременно, непонятно, как сопоставить ошибки (привязанные к сущности) и поля интерфейса. Например, на форме могут быть раздельные поля на день, месяц и год даты рождения, а в сущности это будет единое поле типа «дата».
        0
        И как это связано с необходимостью переносить валидацию в команды? Нужно просто сделать архитектуру валидации немного сложнее, чем представлено, позволяя отслеживать, как данные перемещаются из одних объектов в другие.
          0
          Можете пояснить свою мысль на примере?
            0
            Планировал на эту тему следующую статью писать :)
            У нас фреймворк валидации работает следующим образом. Вся валидация находится в сущностях и частично в репозиториях. Ошибки валидации не висят в воздухе, а привязываются к ключам валидации. Для этого у нас есть класс ValidationKey с наследниками, например EntityPropertyValidationKey, который содержит в себе ссылку на сущность и название свойства, к которому он относится. В процессе сохранения данных в базу клиентский код должен связать валидационный ключ с этими данными. Если мы говорим про Web, то данные у нас приходят в полях вью-моделей. При этом данные могут перемещаться от одного объекта к другому. Например, во вью-модели мы создаём не сущность, а какую-то DTO'шку, которая в последствие передаётся в репозиторий. В такой ситуации мы связываем исходные данные с полями DTO'шки, а затем в репозитории связываем поле DTO'шки с полем сущности.
            Говоря «связываем» я подразумеваю, что у нас где-то во фреймворке валидации просто хранится связь валидационных ключей между собой и валидационных ключей и путей по свойствам вью-моделей.
            Надеюсь, хоть что-то понятно, просто не хотелось бы тут ещё код писать :)
              0
              А что при этом увидит пользователь? Аналог CallStack, но для валидации?
                0
                Ну… У меня, если честно, что-то никаких ассоциаций со стэк-фреймами нет :)
                Пользователь увидит то валидационное сообщение, которое возникло в сущности.
                Тут у нас ещё есть заморочка с тем, что у нас разные приложения работают с одними и теми же сущностями. Например, потребитель может сохраняться через сервисы (значит, на каком-то сайте регистрируется), а может через нашу админку (значит, наш сотрудник создаёт какого-то потребителя зачем-то). И мы должны разные сообщения показывать в таком случае. Так что у нас в коде используются ключи сообщений, а сами тексты хранятся в базе, и различаются для разных приложений.
                  0
                  Я уверен, что у вас есть свои причины сделать такой фреймворк (и да, жду от вас статью на тему :) ), но основной посыл исходной статьи как раз в том, чтобы не делать архитектуру валидации немного сложнее, а наоборот упростить. А также в том, что валидацию нужно делать как можно ближе к тому действию, которое валидируется, т.к. чем ближе, тем понятней контекст и тем более релевантное сообщение получит пользователь.
                    +1
                    Понятно.
                    В принципе я совсем чуть-чуть рассказал в конце последней статьи (в разделе «Валидация»).
                    Но конечно тема очень большая, потому что требований к фреймворку валидации было довольно много. Надо собраться как-нибудь и описать это всё :)
          0
          непонятно, как сопоставить ошибки (привязанные к сущности) и поля интерфейса. Например, на форме могут быть раздельные поля

          Ага, а в команде это будут отдельные поля.
          В принципе понятно, хотя это тоже сомнительная идея — пилить команды под UI. Мне казалось, наоборот — вот есть команды, обеспеченные бизнес-логикой сервера, а фронтэнд должен работать с ними как есть, при необходимости разбивая дату на компоненты в UI, но не в команде.
          0
          Автор призывает проверять не отдельные сущности, а переход состояний. Команды из CQRS очень наглядно это показывают, так как могут изменять систему в целом. На месте команды может быть метод из Controller/Manager/… Таким образом исключается проблема, так сказать «из коробки», когда две сущности по отдельности верны, но вместе приводят систему в неправильное состояние. А каким образом и куда они попадают — это уже вопрос вторичный.
            +1
            1. По моему опыту количество переходов между состояниями много больше, чем количество сущностей. Разумно ли проверять переходы из состояния в состояние?
            2. Валидация в доменной модели подразумевает, что мы валидируем всю модель целиком. Если в результате транзакции оказалось, что модель неконсистентна — значит, мы просто забыли что-то проверить.
              0
              Проверять модель проще, но сложнее дать пользователю адекватное объяснение.
              Особенно, если в операции задействовано несколько сущностей, ошибка будет касаться одной из них, а не операции в целом.

              Например, если на ход не хватает маны, можно в списке выбранных заклинаний отметить как ошибочные те, которые требуют её, а прочие оставить корректными.
                +1
                И ещё соображение — провалидировать всю модель (вызвать универсальный валидатор модели) может быть неприемлемо по вычислительным затратам, а проверить изменения, вызванные только одной командой, приемлемо.
                +1
                И я о том же. Если архитектура — CQRS, нафиг нужен валидатор для сущностей.
                Сначала надо выбрать архитектуру и выбор, на что писать валидатор, станет ненужным.
                +1
                CU — вырожденный случай команды, когда тело команды и сущность идентичны по структуре и по KISS нет необходимости вводить отдельный класс команды.
                  0
                  Валидация — это обработка набора событий, возникаемых при выполнении определенных команд. Должны обрабатываться также как любые эксепшны в пределах приложения. Не вижу разницы.
                    0
                    Валидация — это не набор событий, это декларативное описание (не)соответствия некоторой структуры данных некоторому набору событий. Генерировать события, включая исключения, или нет определяется на уровне вызова валидации и анализа её результатов, а не внутри неё.

                    Не нужно путать валидацию пользовательского ввода и невозможность выполнить команду по причинам недопустимости команды в данном состоянии сущности и/или приложения. Валидация касается только формата запроса, она не касается бизнес-логики.
                  +1

                  Так и не понял что мешает завести типизированный экспшн, в котором будет список не прошедших валидацию полей?
                  Наличие логики валидации в сущности или нет — зависит от того, анемичная модель у вас или нет. Лично я совершенно ничего плохого не вижу, более того считаю другие варианты не красивыми (ну разве что отдельные Domain services).

                    +1
                    Проблема видимо в том, что:
                    1) экскпшены — дорогое удовольствие
                    2) не понятно как связать их с полями модели, грубо говоря к какой строчке формы какое сообщение прикрепить.
                      +1
                      Проверку ограничений лучше не включать в саму сущность, проверка может зависеть от окружения, например для разных пользователей могут быть разные ограничения. По этим причинам кастомный экспшн лишний. Хотя совершенно не ясно, как проверка команд решает проблему определения
                      к какой строчке формы какое сообщение прикрепить
                      +1
                      И опять отображение ошибки пользователю вызывает раздражение, так как возвращается только одна ошибка за раз. Я мог бы вернуть их всем скопом, но как тогда мне сопоставить их с именами полей на экране? Никак.

                      Почему никак? можно же простым if сделать проверку, и в случае ошибки добавить ошибку в массив ошибок
                        0
                        Никак
                        относится к сопоставлению ошибок с полями формы. Рассматривается очень простой пример, если рассматривать более интересные варианты
                        Например, на форме могут быть раздельные поля на день, месяц и год даты рождения, а в сущности это будет единое поле типа «дата».
                        как тут поможет любимчик if?
                          0
                          Так ошибка будет относиться к полю типа «дата», а не к каждому отдельному полю. И сообщение будет относиться ко всем полям, и выведено тоже под ними. Возможно придется отдельное место прописывать для ошибки. Или для каждого отдельного поля продублировать сообщение (не знаю как лучше)
                            0
                            Отлично, допустим, я проектировщик интерфейсов, по моему проекту на форме ввода есть 3 поля для ввода даты рождения и я хочу, что бы подсвечивались только не правильные поля.
                            Возможно придется отдельное место прописывать для ошибки. Или для каждого отдельного поля продублировать сообщение (не знаю как лучше)
                            Попытка адаптации интерфейса под внутреннее устройство, это плохо, изменения в одном не должны особо влиять на другое.
                              0

                              Вы путаетесь немного. Вы говорите о вводе даты дня рождения и хотите описать его 3мя полями. 3 поля это только интерфейс для ввода одной, единой сущности — день рождения.


                              1. Правильней использовать календарь для выбора даты;
                              2. Если вам так хочется использовать 3 отдельных поля то вы должны свернуть их в единое целое в контроллере перед началом валидации. Обычно этим занимаются компоненты форм;
                              3. Валидировать вам всё равно нужно конечную дату в целом, а не её части и выводить вам нужно сообщение об ошибке для всей даты.

                              Пример:


                              • 2017-04-31 — в апреле 30 дней;
                              • 2017-02-29 — не високостный год;
                              • 123-13-44 — не валидная дата впринципе. Нет смысла выводить сообщения об ошибке для каждой части даты;
                              • 3000-01-01 — день рождения не может быть в будущем.
                                0
                                Правильней использовать календарь для выбора даты;

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

                                  0

                                  Согласен. Хотя есть календари которые позволяют быстро пролистывать года.

                                    0

                                    Четыре кнопки нажать обычно быстрее :) Хотя, наверное, очень многое зависит от уровня подготовки пользователя, с одной стороны, и частоты ввода с другой. Если дату надо ввести один раз, то главное наглядность и пойдет календарь, а если за рабочий день он вводит их сотни раз, то или три поля с автопереходом между ними, или вообще одно с традиционным для локали пользователя форматом, пускай даже сначала ему будет непривычно, но потом привыкнет :)

                                      0
                                      одно с традиционным для локали пользователя форматом

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


                                      А вот 4 отдельных поля ввода с автоматическим переходом не всегда удобны.

                                        0

                                        Угу, обычно забывают про нормальную обработку копипаста.

                                          0

                                          Это ладно. Про бекспейс забывают. А еще сталкивался с таким что при вводе 5 числа тебя перекидывают в следующее поле, но указатель ставят перед введеной цифрой нарушая порядок ввода.

                        +1
                        мне одному показалось, что Customer и ChangeNameCommand это копипаста (полей)?
                          0
                          Почему бы не проводить валидацию в модели формы\контроллере, это позволит как соблюсти инварианты, так и снять с модели ответственность за их проверку. В самой же модели можно проверять принципиальную допустимость совершения операции с данными параметрами, тут и выбрасывание исключений было бы вполне уместно.

                          Как валидация команд решает следующий таск?
                          Имеем две формы регистрации, на страницах example.com/en/register и example.com/cn/register, на первой форме валидными считаются номера банковских карт европейских\американских банков-эмитентов, а на второй — только китайских.

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

                            RegisterEnglishCardCommand и RegisterChinaCardCommand не?

                            0
                            Что делать, если валидная команда может перевести валидное состояние сущности в невалидное?
                              +1
                              Посыл автора вроде как понятен, но дизайн выглядит избыточным. Чем это принципиально хуже, чем. Model.ChangeName(Customer, FirstName, LastName), атомарность есть, проблема решена. Непонятно какую проблему решает гибкость, привнося дополнительнуб сложность.

                              Правила валидации могут быть разными. Например «Икс Зетов» валидно с точки зрения данных, но не валидно с точки зрения бизнеса, такой пользователь уже есть. Валидация формата данных может быть сделана в представлении, ограничение по символам, длине и т.п. А вот проверку валидности с точки зрения бизнеса необходимо делать в бизнес логике, при навалидной сущность модель бросает исключение. В интерфейсе модель может содержать метод для проверки валидности пользователя по конкретномому сценарию

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

                              >«При таком подходе моя валидация строится вокруг команд и действий, а не сущностей.»

                              Ни слова не говорится об области видимости: данные могут быть объявлены в интерфейсе, а каждый слой может иметь свою реализацию и оперировать «своим» типом. Но как всегда «все сильно зависит».
                                0

                                Автор забыл как выглядит архитектора приложения.


                                1) Управление попадает в контроллер\view_model\button_click
                                2) Контроллер вызывает метод BL
                                3) BL обращается к данным


                                На первом шаге известен контекст операции и сущностей еще нет. Поэтому обозначенные в посте проблемы — вовсе не проблемы.


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


                                Зачем делать команды, которые будут копировать структуру сущностей и семантику контроллеров — не ясно.

                                  0
                                  Почти в тему. Возможно ли задавать атрибуты аннотаций в runtime? Range, в частности. Избавился бы от велосипеда, но лимиты только в рантайме узнаются.

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

                                  Самое читаемое