Обратите внимание, что хотя пост написан от первого лица, это перевод статьи из блога 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).

Комментарии (31)


  1. qw1
    03.05.2016 17:42
    +1

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

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


    1. m_a_d
      03.05.2016 18:06

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

      Основная мысль здесь в том, что даже если вернется несколько ошибок одновременно, непонятно, как сопоставить ошибки (привязанные к сущности) и поля интерфейса. Например, на форме могут быть раздельные поля на день, месяц и год даты рождения, а в сущности это будет единое поле типа «дата».


      1. timramone
        03.05.2016 18:08

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


        1. m_a_d
          03.05.2016 18:10

          Можете пояснить свою мысль на примере?


          1. timramone
            03.05.2016 18:21

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


            1. m_a_d
              03.05.2016 18:27

              А что при этом увидит пользователь? Аналог CallStack, но для валидации?


              1. timramone
                03.05.2016 18:31

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


                1. m_a_d
                  03.05.2016 18:47

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


                  1. timramone
                    03.05.2016 18:52
                    +1

                    Понятно.
                    В принципе я совсем чуть-чуть рассказал в конце последней статьи (в разделе «Валидация»).
                    Но конечно тема очень большая, потому что требований к фреймворку валидации было довольно много. Надо собраться как-нибудь и описать это всё :)


      1. qw1
        03.05.2016 21:41

        непонятно, как сопоставить ошибки (привязанные к сущности) и поля интерфейса. Например, на форме могут быть раздельные поля

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


    1. msc
      03.05.2016 20:20

      Автор призывает проверять не отдельные сущности, а переход состояний. Команды из CQRS очень наглядно это показывают, так как могут изменять систему в целом. На месте команды может быть метод из Controller/Manager/… Таким образом исключается проблема, так сказать «из коробки», когда две сущности по отдельности верны, но вместе приводят систему в неправильное состояние. А каким образом и куда они попадают — это уже вопрос вторичный.


      1. timramone
        03.05.2016 20:31
        +1

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


        1. qw1
          03.05.2016 21:50

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

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


        1. qw1
          03.05.2016 21:56
          +1

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


      1. qw1
        03.05.2016 21:43
        +1

        И я о том же. Если архитектура — CQRS, нафиг нужен валидатор для сущностей.
        Сначала надо выбрать архитектуру и выбор, на что писать валидатор, станет ненужным.


    1. VolCh
      03.05.2016 21:59
      +1

      CU — вырожденный случай команды, когда тело команды и сущность идентичны по структуре и по KISS нет необходимости вводить отдельный класс команды.


    1. babylon
      03.05.2016 22:46

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


      1. VolCh
        03.05.2016 23:59

        Валидация — это не набор событий, это декларативное описание (не)соответствия некоторой структуры данных некоторому набору событий. Генерировать события, включая исключения, или нет определяется на уровне вызова валидации и анализа её результатов, а не внутри неё.

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


  1. Nagg
    03.05.2016 21:07
    +1

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


    1. whitedemon9
      03.05.2016 22:14
      +1

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


    1. InWake
      04.05.2016 00:12
      +1

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

      к какой строчке формы какое сообщение прикрепить


  1. webmasterx
    04.05.2016 05:19
    +1

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

    Почему никак? можно же простым if сделать проверку, и в случае ошибки добавить ошибку в массив ошибок


    1. InWake
      04.05.2016 09:02

      Никак
      относится к сопоставлению ошибок с полями формы. Рассматривается очень простой пример, если рассматривать более интересные варианты
      Например, на форме могут быть раздельные поля на день, месяц и год даты рождения, а в сущности это будет единое поле типа «дата».
      как тут поможет любимчик if?


      1. webmasterx
        04.05.2016 09:20

        Так ошибка будет относиться к полю типа «дата», а не к каждому отдельному полю. И сообщение будет относиться ко всем полям, и выведено тоже под ними. Возможно придется отдельное место прописывать для ошибки. Или для каждого отдельного поля продублировать сообщение (не знаю как лучше)


        1. InWake
          04.05.2016 09:51

          Отлично, допустим, я проектировщик интерфейсов, по моему проекту на форме ввода есть 3 поля для ввода даты рождения и я хочу, что бы подсвечивались только не правильные поля.

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


  1. alemiks
    04.05.2016 11:15
    +1

    мне одному показалось, что Customer и ChangeNameCommand это копипаста (полей)?


  1. atc
    04.05.2016 13:43

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

    Как валидация команд решает следующий таск?

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

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


  1. Vestild
    04.05.2016 15:36

    Что делать, если валидная команда может перевести валидное состояние сущности в невалидное?


  1. vyatsek
    04.05.2016 16:36
    +1

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

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

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

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

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


  1. gandjustas
    04.05.2016 19:10

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


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


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


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


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


  1. leremin
    04.05.2016 20:46

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