Spring Boot
Centralized Configuration
Centralised configuration manages all service properties from a single source of truth, eliminating configuration drift across environments and instances. Spring Cloud Config, Kubernetes ConfigMaps, and environment variables are the three primary strategies. This entry covers environment-specific profiles, configuration precedence, @ConfigurationProperties, feature flags, secrets management, and configuration validation.
Configuration Precedence and Property Sources
Spring Boot evaluates property sources in a fixed precedence order — higher in the list overrides lower. Understanding precedence is essential for predictable behaviour across environments. Environment variables beat application.yml; config server beats local files; command-line arguments beat everything.
yaml
# ── Spring Boot property source precedence (highest to lowest) ────────
# 1. Command-line arguments --server.port=9090
# 2. SPRING_APPLICATION_JSON env var {"server.port":9090}
# 3. Servlet context init params
# 4. JNDI attributes
# 5. Java System properties -Dserver.port=9090
# 6. OS environment variables SERVER_PORT=9090
# 7. Config server (remote) application.yml in Git
# 8. Profile-specific files application-prod.yml
# 9. Application properties application.yml
# 10. @PropertySource annotations
# 11. Default properties
# ── Relaxed binding — all equivalent ────────────────────────────────
# spring.datasource.url=... (kebab-case — recommended)
# spring.datasource.url=... (camelCase)
# SPRING_DATASOURCE_URL=... (SCREAMING_SNAKE — env var)
# spring.datasource.url=... (dot notation)
# ── Profile-specific files ────────────────────────────────────────────
# application.yml → always loaded
# application-dev.yml → loaded when profile=dev
# application-prod.yml → loaded when profile=prod
# application-test.yml → loaded when profile=test
#
# Profile-specific values override application.yml values.
# Multiple profiles: SPRING_PROFILES_ACTIVE=prod,eu-west
# ── Standard profile structure ────────────────────────────────────────
# application.yml (base — shared by all environments)
spring:
application:
name: order-service
datasource:
driver-class-name: org.postgresql.Driver
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
app:
pagination:
default-page-size: 20
max-page-size: 100
---
# application-dev.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/orders_dev
username: dev
password: dev
jpa:
show-sql: true
---
# application-prod.yml
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USER}
password: ${DATABASE_PASSWORD}
jpa:
show-sql: false@ConfigurationProperties
@ConfigurationProperties binds a group of related properties to a typed, validated Java object. It is the preferred way to read configuration — it documents the structure explicitly, supports IDE autocompletion, and validates values at startup. Group related properties by prefix rather than injecting them individually with @Value.
Java
// ── Typed configuration properties ────────────────────────────────────
@ConfigurationProperties(prefix = "app.payments")
@Validated
@Getter @Setter
public class PaymentProperties {
// ── Gateway settings ──────────────────────────────────────────────
@NotBlank
private String gatewayUrl;
@NotBlank
private String apiKey;
@DurationMin(seconds = 1)
@DurationMax(seconds = 60)
private Duration timeout = Duration.ofSeconds(30);
private boolean sandboxMode = false;
// ── Retry configuration ───────────────────────────────────────────
@Valid
@NotNull
private Retry retry = new Retry();
@Getter @Setter
public static class Retry {
@Min(0) @Max(10)
private int maxAttempts = 3;
private Duration initialDelay = Duration.ofMillis(100);
private double multiplier = 2.0;
}
// ── Supported currencies ──────────────────────────────────────────
@NotEmpty
private List<String> supportedCurrencies =
List.of("USD", "EUR", "GBP");
}
// ── Register as @Bean (Spring Boot 2.2+) ─────────────────────────────
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// In any @Configuration class:
@EnableConfigurationProperties(PaymentProperties.class)
// ── application.yml ───────────────────────────────────────────────────
app:
payments:
gateway-url: https://payments.example.com/v2
api-key: ${PAYMENT_API_KEY}
timeout: 15s
sandbox-mode: false
retry:
max-attempts: 3
initial-delay: 200ms
multiplier: 2.0
supported-currencies:
- USD
- EUR
- GBP
// ── Use in service ────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentProperties config;
public PaymentResponse charge(BigDecimal amount, String currency) {
if (!config.getSupportedCurrencies().contains(currency)) {
throw new UnsupportedCurrencyException(currency);
}
// Use config.getGatewayUrl(), config.getTimeout(), etc.
}
}Feature Flags
Feature flags decouple deployment from release. A feature is deployed but disabled — turned on when ready without a new deployment. Implement feature flags as @ConfigurationProperties beans combined with @RefreshScope so they update at runtime from the config server without restart.
Java
// ── Feature flag configuration ────────────────────────────────────────
@ConfigurationProperties(prefix = "app.features")
@RefreshScope
@Getter @Setter
public class FeatureFlags {
// Individual flags
private boolean newCheckoutFlow = false;
private boolean recommendationEngine = false;
private boolean darkMode = false;
// Percentage rollout (0-100)
private int newCheckoutRollout = 0;
// Allowlist for early access
private Set<String> betaUsers = new HashSet<>();
}
// ── Feature flag service ───────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class FeatureFlagService {
private final FeatureFlags flags;
public boolean isEnabled(String flagName, Long userId) {
return switch (flagName) {
case "newCheckout" -> isNewCheckoutEnabled(userId);
case "recommendations" -> flags.isRecommendationEngine();
default -> {
log.warn("Unknown feature flag: {}", flagName);
yield false;
}
};
}
private boolean isNewCheckoutEnabled(Long userId) {
// Full feature flag — all users
if (flags.isNewCheckoutFlow()) return true;
// Beta allowlist
if (flags.getBetaUsers()
.contains(String.valueOf(userId))) return true;
// Percentage rollout — deterministic per user
int rollout = flags.getNewCheckoutRollout();
if (rollout <= 0) return false;
return Math.abs(userId.hashCode() % 100) < rollout;
}
}
// ── Controller using feature flag ─────────────────────────────────────
@RestController
@RequestMapping("/api/v1/checkout")
@RequiredArgsConstructor
public class CheckoutController {
private final FeatureFlagService flags;
private final CheckoutService checkoutService;
@PostMapping
public ResponseEntity<CheckoutResponse> checkout(
@RequestBody @Valid CheckoutRequest request,
@AuthenticationPrincipal AppUser principal) {
if (flags.isEnabled("newCheckout", principal.getId())) {
return ResponseEntity.ok(
checkoutService.processNew(request));
}
return ResponseEntity.ok(
checkoutService.processLegacy(request));
}
}
# ── Toggle via config server (no restart) ─────────────────────────────
# order-service.yml in Git repo:
app:
features:
new-checkout-flow: true # enable for all users
new-checkout-rollout: 25 # or 25% rollout
beta-users:
- "1001"
- "1002"Kubernetes ConfigMap and Secret Integration
When running in Kubernetes, inject configuration from ConfigMaps and Secrets as environment variables or mounted volumes. Spring Boot reads them automatically. Spring Cloud Kubernetes provides native ConfigMap watching to reload @RefreshScope beans when a ConfigMap changes without any webhook.
XML
<!-- pom.xml — Spring Cloud Kubernetes config watcher -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-kubernetes-client-config</artifactId>
</dependency>
# ── Kubernetes ConfigMap ──────────────────────────────────────────────
apiVersion: v1
kind: ConfigMap
metadata:
name: order-service-config
namespace: production
data:
application.yml: |
app:
features:
new-checkout-flow: false
new-checkout-rollout: 10
pagination:
default-page-size: 20
payments:
gateway-url: https://payments.prod.example.com
timeout: 15s
---
# ── Kubernetes Secret (for sensitive values) ──────────────────────────
apiVersion: v1
kind: Secret
metadata:
name: order-service-secrets
namespace: production
type: Opaque
stringData:
DATABASE_URL: jdbc:postgresql://db:5432/orders
DATABASE_PASSWORD: supersecret
PAYMENT_API_KEY: pk_live_abc123
---
# ── Deployment — mount ConfigMap and Secret ───────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
containers:
- name: order-service
envFrom:
- secretRef:
name: order-service-secrets
volumeMounts:
- name: config-volume
mountPath: /config
volumes:
- name: config-volume
configMap:
name: order-service-config
# ── application.yml — load mounted ConfigMap ──────────────────────────
spring:
config:
import:
- "configtree:/config/" # reads key=file, value=contents
# ── Spring Cloud Kubernetes — auto-reload on ConfigMap change ─────────
spring:
cloud:
kubernetes:
config:
enabled: true
name: order-service-config
namespace: production
reload:
enabled: true
mode: polling # or event (requires RBAC)
period: 15000 # check every 15 secondsConfiguration Validation
Validate @ConfigurationProperties at startup using Bean Validation annotations and @Validated. Invalid configuration fails fast — the application refuses to start with a clear error message rather than failing at runtime during the first use of the misconfigured property.
Java
// ── Validated configuration ───────────────────────────────────────────
@ConfigurationProperties(prefix = "app")
@Validated
@Getter @Setter
public class AppProperties {
@Valid
@NotNull
private Database database = new Database();
@Valid
@NotNull
private Security security = new Security();
@Valid
@NotNull
private Messaging messaging = new Messaging();
@Getter @Setter
public static class Database {
@NotBlank(message = "Database URL must be configured")
private String url;
@Min(value = 1, message = "Pool size must be at least 1")
@Max(value = 100, message = "Pool size must not exceed 100")
private int poolSize = 10;
@DurationMin(millis = 100)
private Duration connectionTimeout =
Duration.ofSeconds(30);
}
@Getter @Setter
public static class Security {
@NotBlank
@Size(min = 32,
message = "JWT secret must be at least 32 characters")
private String jwtSecret;
@DurationMin(minutes = 1)
@DurationMax(hours = 24)
private Duration accessTokenExpiry =
Duration.ofMinutes(15);
}
@Getter @Setter
public static class Messaging {
@NotBlank
private String brokerUrl;
@Min(1) @Max(20)
private int concurrency = 3;
}
}
// ── Custom validator for cross-field constraints ───────────────────────
@Component
public class AppPropertiesValidator
implements ApplicationListener<ApplicationStartedEvent> {
private final AppProperties properties;
public AppPropertiesValidator(AppProperties properties) {
this.properties = properties;
}
@Override
public void onApplicationEvent(
ApplicationStartedEvent event) {
Security sec = properties.getSecurity();
Duration access = sec.getAccessTokenExpiry();
// Business rule: access token < 1 hour in production
String profile = System.getenv(
"SPRING_PROFILES_ACTIVE");
if ("prod".equals(profile) &&
access.toHours() >= 1) {
throw new IllegalStateException(
"Access token expiry must be < 1 hour in production");
}
}
}