Опирается на правила: R-VLD-CFG-1R-VLD-CFG-4 и R-VLD-CFG-X1R-VLD-CFG-X2 из Validation Style Guide → раздел 7. Конфигурация.

Важно знать

  • Каждый @ConfigurationProperties-класс обязан иметь @Validated на классе. Невалидный конфиг → BeanCreationException на старте, не «сервис поднялся с битым флагом».
  • @NotBlank для String-полей (URL, пути, ключи). @NotNull для object-полей (Duration, кастомные объекты).
  • @Min/@Max для числовых лимитов (maxConcurrent, poolSize).
  • Nested@Valid на поле для рекурсивной валидации (AppSettings.database, AppSettings.messaging).
  • Spring валидирует Duration / DataSize по типу сам. Дополнительные @DurationMin/@DurationMax — только когда нужен бизнес-предел (@DurationMax(value = 60, unit = SECONDS) для timeout).
  • @Value("${prop}") для required-конфига — антипаттерн. @Value не валидируется. Используем @ConfigurationProperties (typed + validated) даже для одного поля.
  • Optional-поля — без @NotNull/@NotBlank. Null = «не задано». Тип String (не Optional<String>).

Конфиг — единственное место, где «упасть на старте» лучше, чем «работать с битыми значениями». Невалидный URL в DTO даёт 400 одному клиенту; невалидный URL в client.sber.base-url ломает весь сервис на первом платеже. Fail-fast через @Validated ловит проблему до того, как трафик начнёт приходить. Раскрытие раздела 7 гайда.

@Validated на каждом @ConfigurationProperties

R-VLD-CFG-1: правило простое — нет @Validated — нет валидации.

// ХОРОШО
@ConfigurationProperties("client.sber")
@Validated
public record SberClientSettings(
    @NotBlank String baseUrl,
    @NotNull Duration connectTimeout,
    @NotNull Duration readTimeout,
    @Min(1) @Max(100) int maxConcurrent,
    String apiKey
) {}
# application.yml
client:
  sber:
    base-url: https://api.sber.example
    connect-timeout: 2s
    read-timeout: 10s
    max-concurrent: 20
    api-key: ${SBER_API_KEY:}

Что произойдёт с пустым SBER_API_KEY:

  • api-key — без @NotBlank, optional. Spring подставит пустую строку. Бин создастся, валидация пройдёт.
  • При первом запросе к Sber клиент может упасть с 401 (пустой ключ → отказ от сервера). Это уже runtime — но не «битый конфиг», а «вызов с пустым ключом», что более понятная ситуация.

С опечаткой base-rl: вместо base-url::

  • Spring передаст null в baseUrl.
  • @NotBlank упадёт на старте: BeanCreationException: Failed to bind properties under 'client.sber.base-url': must not be blank.
  • Сервис не стартует. Healthcheck красный, deploy откатывается.

Это и есть fail-fast. Без @Validated сервис стартует, healthcheck зелёный, первый платёж падает с непонятной ошибкой через час.

Required-поля: @NotBlank vs @NotNull

R-VLD-CFG-2: правильный выбор аннотации по типу.

Тип поляАннотацияЗамечания
String (URL, путь, токен)@NotBlankЗащищает от null, "", " "
Duration, DataSize@NotNullSpring парсит 2s, 100MB сам; null = не задано
Custom record/class@NotNull + @Valid (если нужна рекурсивная)См. ниже про nested
int, long, boolean@Min/@Max или без аннотацииПримитивы не могут быть null
Integer, Long, Boolean@NotNull + @Min/@MaxОбёртки могут быть null
@ConfigurationProperties("app.messaging")
@Validated
public record MessagingSettings(
    @NotBlank String bootstrapServers,                // ← String, обязательно
    @NotBlank String consumerGroupId,
    @NotNull Duration pollTimeout,                    // ← Duration, обязательно
    @NotNull Duration sessionTimeout,
    @Min(1) @Max(1000) int batchSize,                 // ← примитив, ноль и отрицательные недопустимы
    @NotNull @Min(1) Integer maxRetries,              // ← Integer (обёртка) + NotNull + Min
    boolean enableMetrics,                            // ← boolean без аннотаций, default false ок
    String sslTruststorePath                          // ← optional, может быть null
) {}

Duration валидируется Spring-ом по типу

R-VLD-CFG-3: для Duration / DataSize дополнительные аннотации обычно не нужны.

client:
  sber:
    connect-timeout: 2s          # парсится в Duration
    read-timeout: 10s
    upload-size-limit: 100MB     # парсится в DataSize
@ConfigurationProperties("client.sber")
@Validated
public record SberClientSettings(
    @NotNull Duration connectTimeout,
    @NotNull Duration readTimeout,
    @NotNull DataSize uploadSizeLimit
) {}

Что валидируется Spring-ом автоматически:

  • Формат строки. 2s, 100ms, 1m30s, PT10S — все парсятся в Duration. 2x — ошибка парсинга, бин не создаётся.
  • Тип. connect-timeout: "two seconds" — не парсится, ошибка.
  • null vs unset. connect-timeout: без значения — null; @NotNull ловит.

Когда нужны дополнительные аннотации — бизнес-предел:

@NotNull @DurationMin(value = 100, unit = ChronoUnit.MILLIS)
@DurationMax(value = 60, unit = ChronoUnit.SECONDS)
Duration connectTimeout

Это говорит: «timeout не может быть < 100 ms (бессмысленно) и > 60 s (слишком долго для нашей системы)». Не валидация формата (Spring справится сам), а business invariant конфига.

@DataSizeMin/@DataSizeMax — для DataSize (@DataSizeMax("500MB")).

Nested — @Valid обязателен

R-VLD-CFG-4: вложенные структуры не валидируются автоматически.

// ХОРОШО — @Valid на nested
@ConfigurationProperties("app")
@Validated
public record AppSettings(
    @Valid @NotNull DatabaseSettings database,
    @Valid @NotNull MessagingSettings messaging,
    @Valid @NotNull List<NotificationChannelSettings> notificationChannels
) {}

public record DatabaseSettings(
    @NotBlank String url,
    @NotBlank String username,
    @NotBlank String password,
    @Min(1) @Max(200) int maxPoolSize
) {}
app:
  database:
    url: jdbc:postgresql://localhost/order
    username: order_user
    password: ${DB_PASSWORD}
    max-pool-size: 20
  messaging:
    bootstrap-servers: kafka:9092
    consumer-group-id: order-service
    # ...
  notification-channels:
    - type: SMS
      provider: sms-aero
    - type: EMAIL
      provider: mailgun

Без @Valid на database:

  • @NotNull проверит, что database не null.
  • Внутрь DatabaseSettings Jakarta не пойдёт. @NotBlank url не сработает.
  • Конфиг с пустым url стартует, падает на первом коннекте.

С @Valid — рекурсия. Каждое поле DatabaseSettings проверяется по своим аннотациям.

То же для List:

@Valid @NotNull List<NotificationChannelSettings> notificationChannels

@Valid заходит в каждый элемент списка. Без него — только проверка, что сам список не null.

@Value для required-конфига — антипаттерн

R-VLD-CFG-X2: @Value("${...}") не проходит через Jakarta Validation.

// ПЛОХО
@Service
class SberClient {

    @Value("${client.sber.base-url}")
    private String baseUrl;                  // ← не валидируется

    @Value("${client.sber.connect-timeout:2s}")
    private Duration connectTimeout;
}

Что не так:

  • Нет валидации. Опечатка в application.yml (base-rl:) → baseUrl == null, сервис стартует.
  • Default через : замаскирует отсутствие значения. ${client.sber.api-key:} → пустая строка, не понятно «не задан» или «явно пустой».
  • Не typed-конфиг. Каждое поле в отдельном @Value — разбросано по сервису. С @ConfigurationProperties всё в одном классе.

Правильно — даже для одного поля:

@ConfigurationProperties("client.sber")
@Validated
public record SberClientSettings(@NotBlank String baseUrl) {}

@Service
@RequiredArgsConstructor
class SberClient {
    private final SberClientSettings settings;     // injected
    // settings.baseUrl() — гарантированно не null/blank
}

@Value остаётся пригодным для тестов (где быстро подменить одно значение) и для тривиальных не-required конфигов (@Value("${app.name:default}") для одной строки). Для production-конфига сервиса — всегда @ConfigurationProperties.

Optional-поля без @NotNull

Поля, которые могут быть не заданы, остаются без @NotNull/@NotBlank:

@ConfigurationProperties("client.sber")
@Validated
public record SberClientSettings(
    @NotBlank String baseUrl,
    @NotNull Duration connectTimeout,
    String apiKeyOverride,                    // ← optional, в проде из секрет-менеджера
    Duration retryDelayOverride               // ← optional, по дефолту берётся из общего конфига
) {}

null — это валидное значение «не задано». Код, который использует, проверяет:

Duration delay = settings.retryDelayOverride() != null
    ? settings.retryDelayOverride()
    : defaultRetryDelay;

Альтернатива — default в YAML (retry-delay-override: 5s), но это лишает смысла optionality.

Тип Optional<String> для конфига не используем — это семантика возвращаемых значений, не полей. Конструктор record-а с Optional<String> — некрасиво. null достаточно.

Что запрещено

АнтипаттернПравилоЧто взамен
@ConfigurationProperties без @ValidatedR-VLD-CFG-X1@Validated на классе
@Value("${prop}") для required-конфигаR-VLD-CFG-X2@ConfigurationProperties typed + validated
@NotNull String (вместо @NotBlank)R-VLD-STD-1@NotBlank для String
Nested без @ValidR-VLD-CFG-4@Valid @NotNull NestedSettings field
@DurationMax без бизнес-причиныR-VLD-CFG-3Spring парсит Duration сам, @NotNull достаточно
Optional<String> в @ConfigurationPropertiesString field (null = не задано)

Куда дальше