Spring Boot Externalized Configuration
Learn Java Microservices File Handling, State, Configuration and Secret Management - Part 036
Spring Boot Externalized Configuration secara production-grade: PropertySource order, Environment, profiles, ConfigData, @ConfigurationProperties, validation, precedence, testing, observability, dan failure modeling.
Part 036 — Spring Boot Externalized Configuration
Spring Boot makes configuration easy.
Production makes configuration dangerous.
Spring Boot punya sistem externalized configuration yang sangat powerful. Value bisa datang dari banyak tempat: default properties, application.yml, profile-specific files, environment variables, system properties, command-line arguments, config tree, imported config, test properties, dan lainnya.
Powerful berarti fleksibel. Fleksibel berarti ada risiko precedence surprise.
Contoh incident yang sering terjadi:
application-prod.yml:
evidence.file.max-upload-size-mb: 100
Deployment env:
EVIDENCE_FILE_MAX_UPLOAD_SIZE_MB=10
Runtime behavior:
max upload size = 10 MB
Engineer membaca YAML dan yakin limit 100 MB. Service menolak upload 20 MB. Akar masalahnya bukan Java logic. Akar masalahnya adalah effective configuration berbeda dari asumsi.
Part ini membahas Spring Boot configuration dari sudut production:
- property source;
- precedence;
Environment;- profile;
@Valuevs@ConfigurationProperties;- validation;
- config data;
- environment variable binding;
- testing;
- observability;
- failure model.
1. Spring Boot Configuration Mental Model
Spring Boot mengumpulkan configuration ke dalam Environment.
Aplikasi membaca value dari Environment secara langsung atau tidak langsung melalui:
@Value;@ConfigurationProperties;- auto-configuration;
- condition annotations;
- custom bean initialization;
- framework integration.
Simplified model:
Yang penting:
Your application does not read application.yml.
Your application reads the effective Environment after property source resolution.
2. PropertySource and Precedence
Spring Boot documentation menyatakan bahwa property source punya urutan tertentu agar value bisa dioverride secara masuk akal. Later property sources dapat override earlier property sources.
Secara praktis, urutan yang sering perlu diingat:
lower precedence
packaged defaults
application.properties / application.yml
profile-specific application-{profile}.yml
imported config / config data depending on location/import
OS environment variables
Java system properties
command-line arguments
higher precedence
Detail resmi bisa berubah antar versi, jadi selalu cek dokumentasi versi Spring Boot yang dipakai. Tetapi mental model-nya tetap:
There are many sources. The final value is selected by precedence.
2.1 Precedence Example
application.yml:
evidence:
file:
max-upload-size-mb: 100
Environment variable:
EVIDENCE_FILE_MAX_UPLOAD_SIZE_MB=50
Command-line argument:
--evidence.file.max-upload-size-mb=25
Effective value:
25
Karena command-line argument punya precedence lebih tinggi dari environment variable dan YAML.
3. @Value vs @ConfigurationProperties
3.1 @Value
@Value cocok untuk value kecil, lokal, dan tidak membentuk config domain.
Contoh:
@Component
public class BuildInfoLogger {
public BuildInfoLogger(@Value("${app.version:unknown}") String version) {
log.info("Application version={}", version);
}
}
Tetapi @Value buruk jika dipakai untuk domain configuration besar.
Buruk:
@Service
public class EvidenceUploadService {
@Value("${evidence.file.max-upload-size-mb}")
private long maxUploadSizeMb;
@Value("${evidence.file.scan-timeout}")
private Duration scanTimeout;
@Value("${evidence.file.quarantine-bucket}")
private String quarantineBucket;
}
Masalah:
- config tersebar;
- tidak ada group-level invariant;
- sulit dites;
- sulit dokumentasi;
- raw key string muncul di banyak tempat;
- refactor rentan;
- validation tidak natural.
3.2 @ConfigurationProperties
Gunakan @ConfigurationProperties untuk config domain.
@ConfigurationProperties(prefix = "evidence.file")
@Validated
public record EvidenceFileProperties(
@Min(1) @Max(1024) long maxUploadSizeMb,
@NotNull Duration scanTimeout,
@NotBlank String quarantineBucket,
@NotBlank String acceptedBucket,
boolean scanRequired,
boolean directUploadEnabled
) {
public EvidenceFileProperties {
if (quarantineBucket.equals(acceptedBucket)) {
throw new IllegalArgumentException(
"quarantineBucket and acceptedBucket must differ"
);
}
}
}
Enable scanning:
@SpringBootApplication
@ConfigurationPropertiesScan
public class EvidenceApplication {
public static void main(String[] args) {
SpringApplication.run(EvidenceApplication.class, args);
}
}
Use it:
@Service
public class EvidenceUploadPolicy {
private final EvidenceFileProperties properties;
public EvidenceUploadPolicy(EvidenceFileProperties properties) {
this.properties = properties;
}
public void validateUpload(long sizeBytes) {
long maxBytes = properties.maxUploadSizeMb() * 1024 * 1024;
if (sizeBytes > maxBytes) {
throw new UploadTooLargeException(sizeBytes, maxBytes);
}
}
}
Keuntungan:
- typed;
- validated;
- discoverable;
- testable;
- group invariant;
- metadata generation;
- central ownership;
- safer refactoring.
4. Binding Relaxed Names
Spring Boot supports relaxed binding. Artinya property dengan style berbeda bisa bind ke field yang sama.
Contoh target:
long maxUploadSizeMb;
Bisa berasal dari:
evidence:
file:
max-upload-size-mb: 100
atau properties:
evidence.file.max-upload-size-mb=100
atau environment variable:
EVIDENCE_FILE_MAX_UPLOAD_SIZE_MB=100
Ini membantu di Kubernetes karena environment variable biasanya uppercase dengan underscore.
Tetapi relaxed binding juga bisa membuat duplicate key tidak terlihat.
Guideline:
Use one canonical key style in application config documentation:
kebab-case YAML/properties key.
Contoh canonical:
evidence.file.max-upload-size-mb
5. Profiles
Spring profile memuat config berbeda untuk environment atau mode tertentu.
Contoh:
# application.yml
evidence:
file:
scan-required: true
max-upload-size-mb: 10
# application-prod.yml
evidence:
file:
max-upload-size-mb: 100
Run:
SPRING_PROFILES_ACTIVE=prod
Effective:
evidence:
file:
scan-required: true
max-upload-size-mb: 100
5.1 Profile Anti-Patterns
Anti-pattern:
application-prod.yml contains secrets.
Anti-pattern:
Profile controls business behavior that should be tenant/domain policy.
Anti-pattern:
Too many profiles: prod, prod2, prod-new, prod-legacy, prod-test, prod-hotfix.
Profiles cocok untuk environment-level differences, bukan runtime decision matrix.
5.2 Profile Naming
Gunakan nama yang stabil:
local
test
staging
prod
kubernetes
cloud
Hindari profile yang merepresentasikan temporary feature:
new-upload-flow
customer-a-special
skip-scan
Itu seharusnya feature flag atau policy config.
6. Config Data and Imports
Spring Boot modern menggunakan Config Data API untuk loading external config. Config bisa diimport dengan spring.config.import.
Contoh:
spring:
config:
import: optional:file:/etc/evidence-service/application.yml
Atau config tree style untuk mounted directory:
spring:
config:
import: optional:configtree:/etc/config/
Config tree berguna saat Kubernetes mount key sebagai file di directory.
Misalnya directory:
/etc/config/evidence.file.max-upload-size-mb
/etc/config/evidence.file.scan-timeout
Application bisa mengimportnya sebagai property source.
Catatan production:
optional:hanya untuk value yang benar-benar optional;- jangan membuat required config optional karena ingin startup tidak gagal;
- log source/config revision;
- validate setelah import;
- dokumentasikan precedence.
Part 037 akan membahas Config Data API lebih dalam.
7. Environment Variables in Kubernetes
Di Kubernetes, config sering diinject sebagai environment variable:
apiVersion: apps/v1
kind: Deployment
metadata:
name: evidence-service
spec:
template:
spec:
containers:
- name: app
image: evidence-service:1.18.3
env:
- name: EVIDENCE_FILE_MAX_UPLOAD_SIZE_MB
valueFrom:
configMapKeyRef:
name: evidence-service-config
key: evidence.file.max-upload-size-mb
Atau bulk:
envFrom:
- configMapRef:
name: evidence-service-config
7.1 Env Var Limitation
Environment variable dibaca saat process start. Jika ConfigMap berubah, environment variable di process yang sudah berjalan tidak otomatis berubah.
Konsekuensi:
ConfigMap update does not mean running Spring Boot process sees new env var value.
Biasanya butuh rollout restart.
7.2 Env Var Naming
Spring relaxed binding memungkinkan:
EVIDENCE_FILE_MAX_UPLOAD_SIZE_MB
bind ke:
evidence.file.max-upload-size-mb
Tetapi environment variable flat. Untuk config kompleks, mounted file/config tree kadang lebih jelas.
8. YAML Structure
Gunakan hierarchy yang mencerminkan domain.
Baik:
evidence:
file:
upload:
max-size: 100MB
direct-upload-enabled: true
scan:
required: true
timeout: 30s
worker-concurrency: 8
storage:
quarantine-bucket: regulator-prod-evidence-quarantine
accepted-bucket: regulator-prod-evidence-accepted
Properties class:
@ConfigurationProperties(prefix = "evidence.file")
@Validated
public record EvidenceFileProperties(
@Valid Upload upload,
@Valid Scan scan,
@Valid Storage storage
) {
public record Upload(
@NotNull DataSize maxSize,
boolean directUploadEnabled
) {}
public record Scan(
boolean required,
@NotNull Duration timeout,
@Min(1) int workerConcurrency
) {}
public record Storage(
@NotBlank String quarantineBucket,
@NotBlank String acceptedBucket
) {}
}
Nested config membuat ownership dan validation lebih jelas.
9. Validation Patterns
9.1 Field Validation
@ConfigurationProperties(prefix = "evidence.upload")
@Validated
public record UploadProperties(
@NotNull DataSize maxSize,
@Min(1) int maxFileCount,
@NotEmpty List<String> allowedContentTypes
) {}
9.2 Cross-Field Validation
@ConfigurationProperties(prefix = "evidence.storage")
@Validated
public record StorageProperties(
@NotBlank String quarantineBucket,
@NotBlank String acceptedBucket,
@NotBlank String quarantinePrefix,
@NotBlank String acceptedPrefix
) {
public StorageProperties {
if (quarantineBucket.equals(acceptedBucket)
&& quarantinePrefix.equals(acceptedPrefix)) {
throw new IllegalArgumentException(
"Quarantine and accepted storage locations must differ"
);
}
}
}
9.3 Semantic Validation
Field valid bukan berarti config aman.
Contoh:
retry:
max-attempts: 100
backoff: 1ms
Tipe valid. Range mungkin valid. Tapi secara operasional berbahaya.
Tambahkan semantic validation:
public record RetryProperties(
@Min(0) @Max(10) int maxAttempts,
@NotNull Duration initialBackoff,
@NotNull Duration maxBackoff
) {
public RetryProperties {
if (initialBackoff.compareTo(Duration.ofMillis(50)) < 0) {
throw new IllegalArgumentException("initialBackoff too aggressive");
}
if (maxBackoff.compareTo(initialBackoff) < 0) {
throw new IllegalArgumentException("maxBackoff must be >= initialBackoff");
}
}
}
10. Startup Invariant Checker
Typed validation tidak selalu cukup karena beberapa invariant butuh dependency.
Contoh:
- bucket exists;
- topic exists;
- external endpoint reachable;
- config version compatible;
- region matches bucket;
- DB schema version compatible.
Gunakan startup checker, tetapi hati-hati agar tidak membuat startup terlalu fragile terhadap transient dependency.
@Component
public final class EvidenceStartupInvariantChecker implements ApplicationRunner {
private final EvidenceFileProperties properties;
private final ObjectStorage objectStorage;
public EvidenceStartupInvariantChecker(
EvidenceFileProperties properties,
ObjectStorage objectStorage
) {
this.properties = properties;
this.objectStorage = objectStorage;
}
@Override
public void run(ApplicationArguments args) {
if (properties.scan().required() && properties.scan().timeout().isZero()) {
throw new IllegalStateException("Scan timeout must be positive when scan is required");
}
objectStorage.assertWritable(properties.storage().quarantineBucket());
objectStorage.assertWritable(properties.storage().acceptedBucket());
}
}
Production nuance:
- required structural dependency failure may fail startup;
- optional dependency failure may mark readiness false;
- external check must have timeout;
- avoid long blocking startup;
- log redacted context.
11. Configuration Metadata
Spring Boot can generate configuration metadata for IDE assistance if you include the configuration processor.
Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
This helps developers discover keys, types, and documentation in IDE.
Tambahkan Javadoc ke properties class:
@ConfigurationProperties(prefix = "evidence.file")
public record EvidenceFileProperties(
/** Maximum upload size accepted by the evidence API. */
DataSize maxUploadSize,
/** Whether malware scanning is mandatory before acceptance. */
boolean scanRequired
) {}
Config metadata tidak menggantikan runtime validation, tetapi membantu mencegah typo dan misconfiguration saat development.
12. Avoid Scattered Environment Reads
Buruk:
@Service
public class EvidenceService {
private final Environment environment;
public EvidenceService(Environment environment) {
this.environment = environment;
}
public void upload() {
String bucket = environment.getProperty("evidence.file.bucket");
// ...
}
}
Masalah:
- no validation;
- late failure;
- hidden dependency;
- hard to test;
- keys invisible;
- behavior can vary per call if environment mutable.
Lebih baik:
@Service
public class EvidenceService {
private final EvidenceFileProperties properties;
public EvidenceService(EvidenceFileProperties properties) {
this.properties = properties;
}
}
Use Environment directly only for infrastructure/framework integration where dynamic lookup is truly needed.
13. Config Object Immutability
Prefer immutable config object.
Record-based config:
@ConfigurationProperties(prefix = "evidence.file")
public record EvidenceFileProperties(
DataSize maxUploadSize,
Duration scanTimeout
) {}
Advantages:
- thread-safe after creation;
- explicit construction;
- no partial mutation;
- easier tests;
- natural snapshot semantics.
For reloadable config, prefer atomic replacement of immutable snapshot.
public final class RuntimeConfigHolder<T> {
private final AtomicReference<T> current;
public RuntimeConfigHolder(T initial) {
this.current = new AtomicReference<>(initial);
}
public T current() {
return current.get();
}
public void replace(T next) {
current.set(next);
}
}
Do not mutate a shared config object field-by-field while requests are reading it.
14. Production-safe Bean Wiring
Config should be injected into boundary components, not everywhere.
Controller tidak perlu tahu semua config. Domain service juga tidak perlu tahu raw bucket jika sudah di-wrap storage adapter.
Pattern:
- config → policy object;
- config → client factory;
- config → worker settings;
- business service uses policy/adapter, not raw config everywhere.
15. Example: Upload Policy from Config
@Component
public final class UploadPolicy {
private final EvidenceFileProperties properties;
public UploadPolicy(EvidenceFileProperties properties) {
this.properties = properties;
}
public void assertAllowed(UploadRequest request) {
DataSize maxSize = properties.upload().maxSize();
if (request.sizeBytes() > maxSize.toBytes()) {
throw new UploadTooLargeException(request.sizeBytes(), maxSize.toBytes());
}
if (!properties.upload().allowedContentTypes().contains(request.contentType())) {
throw new UnsupportedContentTypeException(request.contentType());
}
}
}
YAML:
evidence:
file:
upload:
max-size: 100MB
allowed-content-types:
- application/pdf
- image/jpeg
- image/png
Invariant:
Upload decision is made through UploadPolicy, not scattered config checks.
16. Testing Configuration
16.1 Bind Properties Test
Use ApplicationContextRunner for lightweight config tests.
class EvidenceFilePropertiesTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestConfig.class)
.withPropertyValues(
"evidence.file.upload.max-size=100MB",
"evidence.file.scan.required=true",
"evidence.file.scan.timeout=30s",
"evidence.file.scan.worker-concurrency=4",
"evidence.file.storage.quarantine-bucket=q-bucket",
"evidence.file.storage.accepted-bucket=a-bucket"
);
@Test
void bindsValidProperties() {
contextRunner.run(context -> {
EvidenceFileProperties props = context.getBean(EvidenceFileProperties.class);
assertThat(props.upload().maxSize().toMegabytes()).isEqualTo(100);
});
}
@Configuration
@EnableConfigurationProperties(EvidenceFileProperties.class)
static class TestConfig {}
}
16.2 Invalid Config Test
@Test
void failsWhenWorkerConcurrencyIsZero() {
new ApplicationContextRunner()
.withUserConfiguration(TestConfig.class)
.withPropertyValues(
"evidence.file.upload.max-size=100MB",
"evidence.file.scan.required=true",
"evidence.file.scan.timeout=30s",
"evidence.file.scan.worker-concurrency=0",
"evidence.file.storage.quarantine-bucket=q-bucket",
"evidence.file.storage.accepted-bucket=a-bucket"
)
.run(context -> assertThat(context).hasFailed());
}
16.3 Profile Test
@SpringBootTest(properties = {
"spring.profiles.active=test",
"evidence.file.upload.max-size=10MB"
})
class EvidenceConfigProfileTest {
@Autowired EvidenceFileProperties properties;
@Test
void usesTestUploadLimit() {
assertThat(properties.upload().maxSize().toMegabytes()).isEqualTo(10);
}
}
16.4 Deployment Config Test
CI should test rendered manifests too.
Examples:
- Helm template validation;
- Kustomize build validation;
- check required ConfigMap keys;
- check value ranges;
- check no secret-looking values in ConfigMap;
- check environment variable names match Spring relaxed binding.
17. Configuration Observability in Spring Boot
Spring Boot Actuator can expose environment/config-related information, but be careful.
Do not expose sensitive environment endpoints publicly.
Production-safe approach:
- actuator endpoints behind admin auth/network;
- sanitize sensitive keys;
- expose config version metric;
- expose selected redacted config summary;
- log config source/version at startup;
- avoid dumping all env vars.
Example custom info contributor:
@Component
public final class ConfigInfoContributor implements InfoContributor {
private final ConfigVersionProperties configVersion;
public ConfigInfoContributor(ConfigVersionProperties configVersion) {
this.configVersion = configVersion;
}
@Override
public void contribute(Info.Builder builder) {
builder.withDetail("config", Map.of(
"schemaVersion", configVersion.schemaVersion(),
"revision", configVersion.revision()
));
}
}
Metric:
@Component
public final class ConfigMetrics {
public ConfigMetrics(MeterRegistry registry, ConfigVersionProperties version) {
Gauge.builder("app_config_schema_version", version, ConfigVersionProperties::schemaVersion)
.register(registry);
}
}
18. Failure Modeling
18.1 Missing Config
Expected behavior:
Required structural config missing -> fail startup.
Optional tuning config missing -> use safe default.
18.2 Invalid Config
Expected behavior:
Startup invalid -> fail startup.
Runtime reload invalid -> reject new config and keep previous valid snapshot.
18.3 Config Source Unavailable
If config source unavailable at startup:
- fail if required;
- start with packaged safe defaults only if explicitly allowed;
- mark readiness false if critical dependency unavailable;
- log provenance clearly.
18.4 Precedence Surprise
Mitigation:
- startup log effective config summary;
- tests for property override;
- avoid same key across too many layers;
- prohibit command-line overrides in prod unless intentional.
18.5 Partial Rollout
If ConfigMap env var changed and rollout restart is in progress:
Pod A sees config version 10.
Pod B sees config version 9.
If this matters, enforce:
- config version metric;
- canary analysis;
- readiness check for expected version;
- routing by version if necessary;
- avoid partial rollout for strongly consistent config.
19. Configuration Anti-Patterns
19.1 @Value Everywhere
Symptom:
- raw keys scattered;
- no schema;
- hidden dependencies.
Fix:
- grouped
@ConfigurationProperties; - policy/client factory abstractions.
19.2 Secret in ConfigMap
Symptom:
database.password: secret123
Fix:
- secret manager;
- Kubernetes Secret only if threat model accepts it;
- external secrets operator or Vault integration;
- redaction.
19.3 Unsafe Defaults
Symptom:
scanRequired = false
Fix:
- fail closed;
- explicit production config;
- tests for default behavior.
19.4 Environment-Specific Artifact
Symptom:
Build prod jar and staging jar separately only because config differs.
Fix:
- same artifact promoted;
- externalize deploy-time config;
- provenance.
19.5 Runtime Reload Everything
Symptom:
- bucket changes live;
- DB URL changes live;
- auth issuer changes live.
Fix:
- classify reload-safe;
- restart/rollout structural config;
- use dynamic config only for tuning/flags.
19.6 Configuration as Domain Database
Symptom:
- tenant policy in YAML;
- per-case exception in ConfigMap;
- allowlist managed by redeploy.
Fix:
- domain policy service/table;
- audit trail;
- admin workflow.
20. Reference Implementation Structure
Suggested package structure:
com.company.evidence
config
EvidenceFileProperties.java
EvidenceStorageProperties.java
EvidenceWorkerProperties.java
StartupInvariantChecker.java
ConfigVersionProperties.java
upload
UploadPolicy.java
EvidenceUploadService.java
storage
ObjectStorageClientFactory.java
EvidenceObjectStorage.java
Config classes:
@ConfigurationProperties(prefix = "app.config")
public record ConfigVersionProperties(
int schemaVersion,
String revision
) {}
YAML:
app:
config:
schema-version: 5
revision: ${CONFIG_REVISION:local}
Kubernetes env:
env:
- name: CONFIG_REVISION
value: "9f1c2a4"
This gives runtime evidence of which config revision the pod consumed.
21. Production Checklist
Before shipping Spring Boot config:
- Use
@ConfigurationPropertiesfor grouped config. - Use validation annotations.
- Add cross-field validation for semantic invariants.
- Avoid raw
Environmentreads in business code. - Avoid scattered
@Valuefor domain config. - Use explicit units for duration and size.
- Use safe defaults.
- Fail startup for missing required structural/security config.
- Classify reload-safe vs startup-only config.
- Test invalid config.
- Test profile-specific config.
- Validate rendered Kubernetes manifests.
- Redact secrets from logs and actuator.
- Expose config version/revision metric.
- Document owner and blast radius per config key.
- Avoid storing secrets in ordinary config.
22. Key Takeaways
Spring Boot externalized configuration is excellent for microservices, but only if treated as a typed, validated runtime contract.
Key points:
- Application reads the effective Environment, not simply
application.yml. - Property source precedence can override your assumptions.
- Prefer
@ConfigurationPropertiesover scattered@Value. - Use validation and cross-field invariants.
- Profiles are for environment modes, not business policy sprawl.
- Env var config in Kubernetes usually requires restart to change running process behavior.
- Config Data and config tree are powerful, but optional imports must not hide required config.
- Expose config provenance/version safely.
- Test configuration as production code.
- Do not smuggle secrets or domain state into config.
Di part berikutnya kita akan masuk lebih detail ke Spring Boot Config Data API and Imports: spring.config.import, optional import, config tree, external files, failure behavior, and fail-fast strategy.
References
- Spring Boot Externalized Configuration: https://docs.spring.io/spring-boot/reference/features/external-config.html
- Spring Boot
@ConfigurationPropertiesAPI: https://docs.spring.io/spring-boot/api/java/org/springframework/boot/context/properties/ConfigurationProperties.html - Spring Boot Properties and Configuration HOWTO: https://docs.spring.io/spring-boot/how-to/properties-and-configuration.html
- Kubernetes ConfigMaps: https://kubernetes.io/docs/concepts/configuration/configmap/
- Kubernetes Configure a Pod to Use a ConfigMap: https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/
You just completed lesson 36 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.