Every application has settings that differ between your laptop and production. Spring Boot's answer is a layered configuration system, and the part worth learning properly is which layer wins and how to bind settings to types instead of scattering strings.
Where configuration comes from
Boot reads from many sources and merges them. Later sources override earlier ones, and the practical order, lowest priority first, is:
application.properties(or.yml) in the jarapplication-{profile}.propertiesfor each active profile- An
application.propertiesbeside the jar - OS environment variables
- Java system properties (
-D) - Command-line arguments (
--server.port=9000)
That ordering is the whole design. Defaults ship in the jar; environments override with env vars; a human overrides everything from the command line for one run.
# All three set the same thing. The last one wins.
export SERVER_PORT=8081
java -Dserver.port=8082 -jar app.jar --server.port=8083Environment variables are spelled differently. Boot relaxes the binding, so
pizza.jwt.expiration-minutes is settable as
PIZZA_JWT_EXPIRATIONMINUTES — upper case, dots and dashes to underscores. This is how
you configure a container without a properties file.
@Value, and why the pizza API stopped using it
@Value injects a single property:
@Value("${pizza.pricing.tax-rate}")
private BigDecimal taxRate;
@Value("${pizza.pricing.delivery-fee}")
private BigDecimal deliveryFee;
// With a default, after the colon:
@Value("${pizza.oauth2.success-redirect:http://localhost:5173/oauth2/callback}")
private String successRedirect;It is fine for one or two values. It scales badly, and the pizza API demonstrated exactly how: the
two fields above were declared in PricingService and again in
CartServiceImpl. The same keys, written out four times, with nothing checking that they
still agreed. That is the failure mode @Value invites — the property name is a string
repeated wherever it is needed.
It has a second problem. A missing property becomes a null field, and the failure surfaces later
and elsewhere. A missing pizza.jwt.secret produced a
NullPointerException from inside the JWT library on the first login attempt.
@ConfigurationProperties
Bind a whole namespace once, into types:
@Validated
@ConfigurationProperties(prefix = "pizza")
public record PizzaProperties(
@Valid Pricing pricing,
@Valid Jwt jwt,
@Valid Stripe stripe,
@Valid Cors cors,
@Valid Storage storage,
@Valid Mail mail) {
public record Pricing(
@DecimalMin("0.0") @DecimalMax("1.0") BigDecimal taxRate,
@DecimalMin("0.0") BigDecimal deliveryFee) {}
public record Jwt(
@NotBlank @Size(min = 32) String secret,
@Positive long expirationMinutes) {}
public record Stripe(String secretKey, String publishableKey, String webhookSecret) {}
public record Cors(@NotEmpty List<String> allowedOrigins) {}
public record Storage(@NotBlank String uploadDir, @Positive long maxImageBytes) {}
public record Mail(@NotBlank String from) {}
}Enable scanning for it once, on the application class:
@SpringBootApplication
@ConfigurationPropertiesScan
public class PizzaSpringbootBackendApplication { /* … */ }Then inject it like any other bean:
BigDecimal fee = dto.orderType() == OrderType.DELIVERY
? scale(properties.pricing().deliveryFee())
: BigDecimal.ZERO;
BigDecimal tax = scale(subtotal.multiply(properties.pricing().taxRate()));Why records. A record component can only be set by the constructor, so these
values are immutable after startup. Boot binds constructor arguments by name, which is why there are
no setters and no @ConstructorBinding.
Relaxed binding does the naming translation.
pizza.pricing.tax-rate binds to taxRate; kebab-case in the file, camelCase
in Java, no annotation needed.
Lists bind properly. Cors takes a
List<String>, and Boot splits the comma-separated property itself — which deletes
the split(",") that every @Value version of this ends up writing by
hand.
@Validated is the part that earns its keep
Those constraints are checked while the context is starting, so a bad value fails the application at boot with a message naming the property:
Property: pizza.jwt.secret
Value: "short"
Reason: size must be between 32 and 2147483647Compare that with the @Value version's NullPointerException at first
login. The 32-character minimum is not arbitrary — HS256 requires a 256-bit key and jjwt throws
WeakKeyException for anything shorter. Checking it here converts a runtime failure into
a startup failure that says what to fix.
Note what is deliberately not validated. Stripe has no
@NotBlank, because the keys default to empty so the app still starts without them.
Payment endpoints then fail loudly when used, which is the right trade for a demo someone just
cloned.
Profiles
A profile is a named set of overrides. Put them in
application-{profile}.properties:
# application.properties — the defaults, and the profile to use if nobody says otherwise
spring.profiles.active=local
spring.data.elasticsearch.repositories.enabled=false# application-search.properties — activated with --spring.profiles.active=local,search
spring.data.elasticsearch.repositories.enabled=true
spring.elasticsearch.uris=http://localhost:9200
spring.elasticsearch.connection-timeout=2sProfiles are additive, so local,search applies both files. This is the cleanest way to
flip a default: nothing in the default configuration has to know the profile exists.
Beans can be profile-scoped too, and in the pizza API this is load-bearing rather than decorative:
@Configuration
@EnableJms
@Profile("messaging")
public class MessagingConfig { /* … */ }A @JmsListener container starts polling as soon as the context is up. Without the
profile, merely having the Artemis starter on the classpath would make the app try to reach a broker
at every startup and fill the log with connection retries. Gated, the default run needs nothing but
MySQL.
Keeping secrets out of the repository
The pattern the pizza API uses — an env var with an empty default, overridable by a gitignored file:
# application.properties — committed. No secrets, and the app still starts without them.
pizza.stripe.secret-key=${STRIPE_SECRET_KEY:}
pizza.stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET:}
# HS256 needs >= 256 bits of key. Overridden in application-local.properties.
pizza.jwt.secret=change-me-this-is-a-development-only-placeholder-secret-key${VAR:default} reads an environment variable and falls back after the colon.
Real values go in application-local.properties, which is gitignored, or in env vars in
production. Three rules worth keeping:
- Never commit a real secret, and treat one that was committed as burned — rotate it rather than deleting the line.
- A placeholder must look like a placeholder. The JWT default above says
change-meanddevelopment-onlyin the value itself. - The app should still start without secrets, so a first-time clone works and the failure happens at the feature, not at boot.
What to take from this
- Later sources win — jar, then profile, then env, then system properties, then the command line.
@Valuefor one or two values;@ConfigurationPropertiesfor a namespace. Duplicated keys across classes is the smell that means it is time.@Validatedturns a runtime NPE into a startup error that names the property.- Profiles for environment differences and for gating optional integrations off by default.
Next: aspect-oriented programming — and the proxy rule
that explains why @Transactional, @Cacheable and @Async all
fail in the same silent way.