Ранее я написал об этом пост, но потом набралось больше материала и я расширил до статьи.

Работая над одним из проектов, который недавно переехал из Framework 4.8 на Core 9, обнаружил множество самых разных вариантов использования модификатора required и атрибута Required, примерно каждый второй из которых был использован неправильно. Я написал это коллегам и хочу поделиться этим здесь. Это не обязательные правила, но сильно упрощают работу с кодом.


Атрибут Required и модификатор required

Атрибут Required нужен для проверки входящих преимущественно строковых данных в эндпоинтах. Возвращает ошибку, если значение null или пустая строка для строк (если не отключено параметром AllowEmptyStrings). Работает в Runtime. Также применяется в Entity Framework в подходе code-first но с включением опции <Nullable> в csproj про эти случаи можно забыть, сделав код чище.

Модификатор required нужен для обязательного указания значений полей при создании класса. Работает в Compile-time.

Примеры использования

// имеем класс с required полем
public class Example
{
    public required string Name { get; set; }
}

// пытаемся создать экземпляр в коде
var example1 = new Example();  // будет ошибка при попытке сборки проекта
var example2 = new Example { Name = string.Empty };  // тут ошибки не будет

// Вывод: модификатор required нужен для разработчика
// имеем класс с полем, у которого атрибут Required
public class Example
{
    [Required]
    public string Name { get; set; }
}

// пытаемся создать экземпляр в коде
var example = new Example();  // проект спокойно собирается

// имеем эндпоинт в контроллере
public IActionResult PostMethod([FromBody] Example model) => Ok();

/* передаём в теле запроса:
{}
или
{"Name": null}
или
{"Name": ""}
или
{"Name": "   "}
Получаем BadRequest с текстом ошибки. */

// передаём в теле запроса: {"Name": "name"}. Получаем OK.

// Вывод: атрибут Required нужен для пользователя

Как стоит и не стоит использовать

public class BadExample
{
    public required string Field1 { get; set; } // 1
    
    public required string? Field2 { get; set; } // 2
    
    [Required]
    public required string Field3 { get; set; } // 3

    [Required]
    public string? Field4 { get; set; } // 4

    [Required]
    public int Field5 { get; set; } // 5

    public required int Field6 { get; set; } = 10; // 6
      
    public required List<int> Field7 { get; set; } // 7
}
  1. Ошибка, если класс используется как входящий параметр в эндпоинте. Соответственно, не стоит использовать, если десериализуем в него. Это создаёт избыточную сложность.

  2. Либо required, либо nullable.

  3. Надо выбрать одно из двух в зависимости от места использования.

  4. Либо Required, либо nullable.

  5. Required используем для строк. Но есть нюанс (*).

  6. Не нужно использовать required со значением по умолчанию.

  7. Не стоит усложнять жизнь, если поле можно проинициализировать при создании класса.

public class GoodExample
{
    public required string Field1 { get; set; } // 1
    
    [Required]
    public string Field2 { get; set; } = null!; // 2
    
    public string? Field3 { get; set; } // 3

    public int Field4 { get; set; } // 4

    public List<string> Field5 { get; set; } = []; // 5
}
  1. Хорошо где угодно за пределами эндпоинтов и десериализации, а значение не может принимать null.

  2. То что нужно для эндпоинта.

  3. Поле nullable. Поэтому никаких required.

  4. Не используем атрибут Required с не строками. Но есть нюанс (*).

  5. Избегаем использование required, проинициализировав коллекцию.

* - если передаётся json, в котором явно указано значение null ({"IntField": null}), то использование атрибута Required вернёт BadRequest с текстом ошибки валидации.
Если же в json поле было опущено, то будет присвоено значение по умолчанию.


Самописные атрибуты

Вот код, который скрывается за базовым атрибутом Required:

public override bool IsValid(object? value)
{
    if (value is null)
        return false;
    return AllowEmptyStrings || value is not string stringValue || !string.IsNullOrWhiteSpace(stringValue);
}

Допустим, я хочу написать свой атрибут, который будет проверять ещё и не nullable коллекции, чтобы там был хотя бы один элемент. Тут всё просто: создаём новый класс, который наследуется от RequiredAttribute и переписывается метод IsValid.

А если мне надо, чтобы атрибут возвращал ошибку не на одном языке, а в зависимости от настроек пользователя? Или мне ещё десяток уникальных атрибутов надо с подобными возможностями? Тут аналогично: наследуем новый класс уже от ValidationAttribute и переписываем чуть больше. А лучше даже создаём класс с общей логикой для всех будущих атрибутов, в которых вся внутренняя логика будет укладываться всего в несколько строк. Красота!

И там CustomRequiredAttribute, и тут CustomRequiredAttribute, "но есть нюанс", Петька. Валидация полей работает как по маслу, пока не решишь применить этот атрибут к параметрам эндпоинтов. Если мы воспользовались вторым вариантом, то внезапно выясним, что на каждый помеченный новым атрибутом параметр прилетает две ошибки, если он null. Перепроверяем, посылая пустую коллекцию - получаем одну ошибку. Перепроверяем с null - снова две.

Начинаем копать и находим такую опцию с очень длинным названием, которое говорит само за себя:

builder.Services.AddControllers(options =>
    {
        options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
    });

Само собой. мы её включаем и пробуем ещё раз, посылая null, ожидая, что всё заработает. И всё работает, но полностью игнорируя и неявный базовый RequiredAttribute, и явный самописный, унаследованный от ValidationAttribute. Какие бы мы манипуляции не делали, с null в значении параметра эндпоинта и опцией контроллера, мы получаем либо две одинаковые ошибки, либо ни одной.

// в .csproj: <Nullable>enable</Nullable>

[HttpPost]
public IActionResult SomeMethod([FromBody, CustomRequired] List<int> ids) => Ok();

Решение оказалось и очевидным, и нет. Чтобы заработало как надо, именно в этом случае самописный атрибут Required нужно наследовать от RequiredAttribute. Тогда ваш самописный атрибут подменяет собой базовую логику. Казалось бы, какая разница? Только вот RequiredAttribute у фреймворка на особом счету, что даже удостоился отдельной опции.
НО! С включением опции SuppressImplicitRequiredAttributeForNonNullableReferenceTypes все места, где RequiredAttribute срабатывал неявно, нужно теперь его или его наследника всегда явно указывать.

// тут всё без изменений
[HttpPost]
public IActionResult SomeMethod([FromBody] string? str) => Ok();

// с выключенной опцией раньше неявно срабатывает RequiredAttribute, если приходит null
[HttpPost]
public IActionResult SomeMethod([FromBody] string str) => Ok();

// со включенной опцией теперь нужно явно указывать, иначе пропустим null
[HttpPost]
public IActionResult SomeMethod([FromBody, Required] string str) => Ok();

Получается такая ситуация с самописными атрибутами:

// подходящий вариант для всего, кроме параметров
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class CustomRequiredAttribute
    : BaseCustomValidationAttribute // базовый для всех самописных со всей общей логикой
{
    public bool AllowEmptyStrings { get; set; }
    public bool AllowEmptyCollections { get; set; }
    
    public CustomRequiredAttribute()
        : base("Default error template")
    {
    }

    public override bool IsValidLogic(object value, ValidationContext validationContext) =>
        value switch
        {
            null => false,
            ICollection c => AllowEmptyCollections || c.Count > 0,
            string s => AllowEmptyStrings || !string.IsNullOrWhiteSpace(s),
            _ => true
        };
}
// подходящий вариант для всего, особенно для параметров
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class CustomRequiredAttribute
    : RequiredAttribute  // стандартный. Поэтому логику придётся писать отдельно
{
    public bool AllowEmptyStrings { get; set; }
    public bool AllowEmptyCollections { get; set; }
    
    public CustomRequiredAttribute()
        : base("Default error template")
    {
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var result = value switch
        {
            null => false,
            ICollection c => AllowEmptyCollections || c.Count > 0,
            string s => AllowEmptyStrings || !string.IsNullOrWhiteSpace(s),
            _ => true
        };
        if (result)
            return ValidationResult.Success;

        // некоторая логика, которая в ином случае была бы в BaseCustomValidationAttribute

        return new ValidationResult(...);
    }
}

Со всеми остальными атрибутами подобных проблем не наблюдалось. Можно спокойно писать свои, для параметров и полей, наследуя их от ValidationAttribute.


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

Надеюсь, она поможет сделать код чище и избежать неоднозначностей.

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


  1. WhiteBehemoth
    09.01.2026 14:02

    1. Required используем для строк. Но есть нюанс (*).

    Это не нюанс, это нюансище. Чтоб опущенный параметр для всяких Int или Guid выкидывал ошибку валидации, а не инициализировался "по умолчанию".

    Будьте внимательны в использовании слова required где бы то ни было в проекте

    Мы в конце концов практически отказались от него. Хлопот больше, чем реальной пользы.


    1. ProgerMan Автор
      09.01.2026 14:02

      Для других типов я предпочитаю использовать более конкретные атрибуты, вроде Range или прочие самописные (например, IsPositive). А для более сложных валидаций пишу валидаторы, которые заполняют ModelState ошибками.


    1. AgentFire
      09.01.2026 14:02

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


      1. ProgerMan Автор
        09.01.2026 14:02

        В своих проектах всё, что можно провалидировать атрибутами, валидирую атрибутами. От этого особенно выигрывают проекты на Blazor, т.к. там это работает, как на сервере, так и на клиенте.


        1. AgentFire
          09.01.2026 14:02

          Простите, а причем тут валидация? Речь про required, не [Required].


      1. WhiteBehemoth
        09.01.2026 14:02

        required в DTO даже не думали. У нас вся валидация через атрибуты. (DTO используется только API на базе ASP.NET). Интересно, если required поле не было в приходящем json объекте, ASP.NET возвращает такой же 400 bad request с пропущенным полем, как и с [Required] атрибутом?
        Хлопоты, про которые я говорил - накладываемые ограничения, типа конструктора без параметров. Мы хотели убрать null! инициализацию для not nullable properties, но оказались не готовы для этого менять логику.


        1. AgentFire
          09.01.2026 14:02

          Все еще непонятно, причем тут какая-то валидация. Ключевое слово required вообще с ней никак не связано.

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

          Но ведь с required конструктор всё еще запросто может быть без параметров. Что я не так понял в вашем сообщении?

          Мы хотели убрать null! инициализацию для not nullable properties, но оказались не готовы для этого менять логику.

          Снова непонятно. Вы осознанно поощряете отсутствие инициализации некоторых полей, но введение required, которое вам бы прогарантировало исправление данной ситуации, вы называете "не готовы менять логику" — т.е. по факту не готовы вручную проинициализировать все пропущенные поля, и сиё утверждение считаете достаточным аргументом против required? Всё так понял, или я снова затупил?


          1. WhiteBehemoth
            09.01.2026 14:02

            Все еще непонятно, причем тут какая-то валидация. Ключевое слово required вообще с ней никак не связано.

            Про валидацию, - потому, что речь о DTO. Проверка атрибута [required] - часть процесса валидации. Смысл там такой же, как и у ключевого слова required - убедиться, что клиент предоставит значение для обозначенного поля. Но атрибут более стандартный, поэтому для нашего ASP.NET API мы использовали его.

            Но ведь с required конструктор всё еще запросто может быть без параметров. Что я не так понял в вашем сообщении?

            Снова непонятно. Вы осознанно поощряете отсутствие инициализации некоторых полей, но введение required, которое вам бы прогарантировало исправление данной ситуации, вы называете "не готовы менять логику" — т.е. по факту не готовы вручную проинициализировать все пропущенные поля, и сиё утверждение считаете достаточным аргументом против required? Всё так понял, или я снова затупил?

            вы так эмоционально не понимаете, будто я вас убеждаю отказаться от использования required в вашем коде.

            Когда это ключевое слово ввели, у нас было уже написано очень много кода и все классы, где поля обязаны инициализироваться клиентом имели соответствующие конструкторы. Была мысль использовать его шире, убрав инициализацию в null not nullable fields and properties, но это требовало менять инициализацию в куче мест. И да, для нас это было достаточным аргументом не внедрять эту фичу языка в существующий проект.


            1. AgentFire
              09.01.2026 14:02

              Простите, но мне стало еще непонятнее.

              Во-первых, валидация с атрибутом [Required] абсолютно никак не связаны с ключевым словом required. У них разные предназначения.

              "убедиться, что клиент предоставит значение для обозначенного поля" - это ближе к атрибуту. А ключевое слово нужно исключительно при создании экземпляров класса, причем именно через new. (Поддержка разными десериализаторами этого ключевого слова и соответствующее [Required]-like поведение является исключительно следствием появления самого required, но никак не наоборот).

              вы так эмоционально не понимаете, будто я вас убеждаю отказаться от использования

              У меня скорее складывается подозрение, что у вас ошибочное представление о том, что такое required и зачем он нужен.

              Была мысль использовать его шире, убрав инициализацию в null not nullable fields and properties, но это требовало менять инициализацию в куче мест.

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

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


              1. WhiteBehemoth
                09.01.2026 14:02

                У меня скорее складывается подозрение, что у вас ошибочное представление о том, что такое required и зачем он нужен.

                В новых проектах, возможно, контракты на обязательность (кроме DTO) будут через required. А может быть так и останемся во тьме конструкторов с параметрами и прочими фабриками вместо продвинутого ключевого слова. "There are different ways to skin a cat", как говорит мой коллега.


  1. AgentFire
    09.01.2026 14:02

    Либо required, либо nullable.

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

    А я вот поделюсь своим личным мнением, для чего оно нужно. На мой взгляд, его единственная цель - это не дать забыть про инициализацию этого поля разработчиком, который пишет new().

    В этом случае как тип, так и nullability поля не имеет значения. На разработчика накладывается обязательство explicitly (-over implicitly) указать изначальное значение, даже если оно null/default.

    Таким образом чтение кода сильно упрощается - в будущем любому читающему не будет непонятно, писатель кода забыл указать значение, или же оно там действительно должно быть проинициализировано значением "null".


    1. ProgerMan Автор
      09.01.2026 14:02

      Я не вижу ни одного реального примера, для которого может понадобиться обязательно указывать null для nullable. Допустим, сделали неявное явным, но как это поможет? На мой взгляд это лишь загромождает код. Если в дальнейшем коде устраивает значение по умолчанию, то модификатор required не нужен.


      1. AgentFire
        09.01.2026 14:02

        Я же русским по темному написал обоснование необходимости и как именно это поможет, аж в двух местах.


      1. WhiteBehemoth
        09.01.2026 14:02

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


    1. voroninp
      09.01.2026 14:02

      +1

      Nullability — это про область определения/множество допустимых значений. То, что null в этом случае соответствует значению по умолчанию не более чем нюанс (иногда, кстати, очень досадный).

      required — про явную инициализацию

      Понятия ортогональные.