package com.example.billing.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(PaymentProperties.class)
public class AppConfig {
}
package com.example.billing.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
@Validated
@ConfigurationProperties(prefix = "payment")
public record PaymentProperties(
@NotBlank String apiKey,
@NotBlank String defaultCurrency,
@Positive int timeoutSeconds,
@NotNull @Valid Retry retry
) {
public record Retry(
@Min(1) @Max(10) int maxAttempts,
@NotNull Duration backoff
) {
public Retry {
if (backoff == null) {
backoff = Duration.ofMillis(200);
}
}
}
}
package com.example.billing.service;
import com.example.billing.config.PaymentProperties;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class PaymentService {
private final PaymentProperties properties;
public PaymentService(PaymentProperties properties) {
this.properties = properties;
}
public String charge(long amountMinorUnits) {
int attempts = properties.retry().maxAttempts();
Duration backoff = properties.retry().backoff();
for (int attempt = 1; attempt <= attempts; attempt++) {
if (send(amountMinorUnits)) {
return "charged " + amountMinorUnits + " " + properties.defaultCurrency();
}
sleepQuietly(backoff.multipliedBy(attempt));
}
throw new IllegalStateException("payment failed after " + attempts + " attempts");
}
private boolean send(long amount) {
// uses properties.apiKey() and properties.timeoutSeconds() against the gateway
return amount > 0;
}
private void sleepQuietly(Duration d) {
try {
Thread.sleep(d.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
payment:
api-key: ${PAYMENT_API_KEY}
default-currency: USD
timeout-seconds: 30
retry:
max-attempts: 4
backoff: 250ms
This snippet shows how a modern Spring Boot application binds external configuration into an immutable Java record and validates it at startup rather than discovering bad values deep in a request path. The core idea behind @ConfigurationProperties is to group related settings under a common prefix and let Spring's relaxed binding map keys like payment.default-currency onto strongly typed fields, replacing scattered @Value("${...}") injections with a single cohesive object.
In PaymentProperties, the configuration is declared as a record annotated with @ConfigurationProperties(prefix = "payment") and @Validated. Using a record makes the bound object immutable and gives constructor binding for free: Spring calls the canonical constructor with resolved values. Nested settings are modelled with a nested Retry record, so payment.retry.max-attempts binds cleanly and stays type-safe. Bean Validation constraints (@NotBlank, @Positive, @Min, @Max, @NotNull, @Valid) live right on the components, so the schema of valid configuration is documented in code. A compact constructor supplies a sensible default Duration when the property is absent, showing how defaults coexist with validation.
Because the class carries @Validated, any constraint violation is thrown during context startup as a BindValidationException, which fails fast — a misconfigured deployment never reaches production traffic. The @Valid on the nested field is what makes Spring cascade validation into Retry; without it the inner constraints would be silently skipped, a common pitfall.
In AppConfig, @EnableConfigurationProperties(PaymentProperties.class) registers the record as a bean so it can be injected anywhere. This is the recommended alternative to @Component scanning for records and keeps the properties type free of Spring stereotypes.
In PaymentService, the validated PaymentProperties is injected through the constructor and its fields are read directly, with no null checks or parsing — the type system and startup validation already guarantee the values are present and sane. The application.yml tab shows the matching source, including kebab-case keys and the nested retry block. The trade-off is that all validation is startup-time and static; values that must change at runtime need @RefreshScope or a different mechanism. For the common case of fixed deployment config, this pattern yields safer, self-documenting, and easily testable settings.
Related snips
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
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.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
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
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
Share this code
Here's the card — post it anywhere.