package com.example.storage;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "app.storage")
public class StorageProperties {
@NotBlank
private String endpoint;
@NotBlank
@Pattern(regexp = "[a-z0-9.\\-]{3,63}", message = "bucket must be a valid DNS-style name")
private String bucket;
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout = Duration.ofSeconds(10);
@Valid
private final Retry retry = new Retry();
public String getEndpoint() { return endpoint; }
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
public String getBucket() { return bucket; }
public void setBucket(String bucket) { this.bucket = bucket; }
public Duration getTimeout() { return timeout; }
public void setTimeout(Duration timeout) { this.timeout = timeout; }
public Retry getRetry() { return retry; }
public static class Retry {
@Min(1)
private int maxAttempts = 3;
@DurationUnit(ChronoUnit.MILLIS)
private Duration initialBackoff = Duration.ofMillis(100);
public int getMaxAttempts() { return maxAttempts; }
public void setMaxAttempts(int maxAttempts) { this.maxAttempts = maxAttempts; }
public Duration getInitialBackoff() { return initialBackoff; }
public void setInitialBackoff(Duration initialBackoff) { this.initialBackoff = initialBackoff; }
}
}
package com.example.storage;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(StorageProperties.class)
public class StorageConfig {
@Bean
public StorageClient storageClient(StorageProperties props) {
return StorageClient.builder()
.endpoint(props.getEndpoint())
.bucket(props.getBucket())
.requestTimeout(props.getTimeout())
.maxAttempts(props.getRetry().getMaxAttempts())
.initialBackoff(props.getRetry().getInitialBackoff())
.build();
}
}
app:
storage:
endpoint: https://s3.eu-west-1.amazonaws.com
bucket: reports-prod-eu
timeout: 5s
retry:
max-attempts: 5
initial-backoff: 200ms
This snippet shows how Spring Boot binds a tree of related settings into a single type-safe object and fails fast at startup when the configuration is wrong. The pattern replaces scattered @Value injections with one cohesive, validated class, which is the recommended approach once a group of properties starts to grow related sub-settings.
In StorageProperties, the class is annotated with @ConfigurationProperties(prefix = "app.storage") and @Validated. The @Validated annotation is what activates JSR-380 constraint checking on the bound object; without it the annotations are inert. Constraints like @NotBlank, @Min, and @Pattern describe the contract the configuration must satisfy, and @DurationUnit lets a plain number in YAML be interpreted as seconds and bound to a java.time.Duration. Nested settings live in the inner Retry type, exposed through a getter so binding recurses into app.storage.retry.*; @Valid on that field cascades validation into the nested object, a step developers frequently forget.
The @ConstructorBinding-free, mutable-getter style is used here for compatibility with older Spring Boot versions, though modern versions also support immutable constructor binding.
In StorageConfig, @EnableConfigurationProperties(StorageProperties.class) registers the properties class as a bean so it can be injected anywhere, and a StorageClient bean is wired from the already-validated settings. Because validation runs during bean creation, an invalid bucket or a negative maxAttempts aborts the context refresh with a clear BindValidationException rather than surfacing as a runtime NullPointerException deep in a request.
In application.yml, the app.storage block mirrors the property structure exactly, including the human-friendly timeout: 5s and initial-backoff: 200ms values that Spring parses into Duration. Note the relaxed binding: kebab-case keys map to camelCase fields automatically.
The trade-off is a little boilerplate up front in exchange for one authoritative, documented, validated settings object. The main pitfall is forgetting @Validated or @Valid, which silently skips the checks. This approach is worth reaching for as soon as configuration becomes structured or safety-critical.
Related snips
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
package com.example.demo.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
Messaging with Apache Kafka
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
Share this code
Here's the card — post it anywhere.