Why configuration recipes matter
Great Spring Boot applications don’t just happen—they’re configured. The fastest way to reach a reliable, high‑performance setup is to reuse small, proven configuration “recipes.” The 10 recipes below are concise, copy‑pastable snippets you can drop into your projects to optimize startup, throughput, resilience, and maintainability. They’re designed for Spring Boot 3.x and Java 17+ and come with practical guidance on when and why to use them.
Before you start:
- Keep configuration centralized in
application.yml. - Prefer type‑safe
@ConfigurationPropertiesto scattered@Valueinjections. - Test key performance knobs under realistic load; “best” defaults are workload‑dependent.
1) Profile Groups and Layered Configuration
Profile groups help you compose environments (e.g., dev, staging, prod) without duplicating every property. This recipe also demonstrates using a default profile and separating secrets.
When to use
- You have multiple environments with shared subsets (e.g., “aws” and “prod”).
- You want predictable overrides with minimal duplication.
Snippet (application.yml)
spring:
profiles:
default: local
config:
activate:
on-profile: local
# Base settings apply to all profiles unless overridden
server:
port: 8080
logging:
level:
root: INFO
com.example: DEBUG
# Group profiles so activating one implies others
spring:
profiles:
group:
prod: [aws, observability]
staging: [aws]
local: [devtools, mock-deps]
---
spring:
config:
activate:
on-profile: aws
# AWS-specific overrides
management:
endpoints:
web:
exposure:
include: health,info,prometheus
---
spring:
config:
activate:
on-profile: prod
server:
compression:
enabled: true
http2:
enabled: true
# Keep secrets in an external file or environment variables
# e.g., export SPRING_DATASOURCE_URL=...
Why it works
- Profile groups let you attach common feature sets to environments.
- Default profile ensures the app is usable locally without extra flags.
Tips
- Keep production secrets external (environment vars, Vault, AWS Parameter Store).
- Use
spring.config.importto pull centralized configs (e.g., from config server).
2) Type‑Safe Configuration with Validation
Stop sprinkling @Value everywhere. Bind related properties into a single object with validation so misconfiguration fails fast.
When to use
- You need clarity and validation for critical settings (timeouts, URLs, sizes).
Snippet (Java)
// build.gradle or pom.xml should include starter-validation
// implementation("org.springframework.boot:spring-boot-starter-validation")
package com.example.config;
import jakarta.validation.constraints.*;
import org.hibernate.validator.constraints.time.DurationMin;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
@Validated
@ConfigurationProperties("client.api")
public class ApiClientProperties {
@NotBlank
private String baseUrl;
@DurationMin(seconds = 1)
private Duration connectTimeout = Duration.ofSeconds(3);
@DurationMin(seconds = 1)
private Duration readTimeout = Duration.ofSeconds(5);
@Min(1) @Max(1000)
private int maxConnections = 100;
// getters/setters omitted for brevity
}
package com.example.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(ApiClientProperties.class)
public class PropertiesConfig {}
# application.yml
client:
api:
base-url: https://api.example.com
connect-timeout: 2s
read-timeout: 4s
max-connections: 200
Why it works
- Validation ensures invalid configs are caught at startup.
- One cohesive class makes intent and defaults obvious.
Tips
- Prefer constructor‑bound properties or records for immutability.
- Use
@DurationMin/@DurationMaxand size constraints liberally.
3) HikariCP Connection Pool Tuning
Database connection pools often gate your throughput and latency. Tune HikariCP deliberately.
When to use
- You see connection starvation, high latencies, or DB CPU spikes.
- You want predictable behavior under load.
Snippet (application.yml)
spring:
datasource:
url: jdbc:postgresql://db:5432/app
username: app
password: ${DB_PASSWORD}
hikari:
pool-name: app-pool
maximum-pool-size: 30 # Size based on DB cores/queries per tx
minimum-idle: 10
connection-timeout: 250ms # Fail fast to trigger fallbacks
idle-timeout: 600000 # 10m
max-lifetime: 1800000 # 30m < DB idle timeout
leak-detection-threshold: 2000 # Optional; dev only
auto-commit: false
spring:
jpa:
open-in-view: false # important for performance and correctness
hibernate:
ddl-auto: validate
Why it works
- Tight
connection-timeoutsurfaces backpressure instead of hanging. max-lifetime< DB’s own idle timeout prevents stale connections.- Disabling Open Session in View avoids long‑lived transactions across web layers.
Tips
- Align pool size with database capacity (not just CPU).
- Monitor
hikaricp.connections.*and JDBC timings via Micrometer.
4) Faster Startup and Leaner Memory
Lazy initialization and selective autoconfiguration can cut cold start times and memory.
When to use
- Serverless / short‑lived workloads.
- Large monoliths with rarely used beans.
Snippet (application.yml)
spring:
main:
lazy-initialization: true
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration # example
- org.springframework.boot.autoconfigure.data.rest.RepositoryRestMvcAutoConfiguration
spring:
jackson:
serialization:
write-dates-as-timestamps: false
# Keep transactions short; fail fast on slow web requests
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
Optional: conditional beans
@Bean
@ConditionalOnProperty(name = "feature.x.enabled", havingValue = "true")
public MyHeavyBean heavyBean() { ... }
Why it works
- Lazy init defers bean creation until first use.
- Excluding unused autoconfig trims the graph.
- Graceful shutdown avoids request failures during redeploys.
Tips
- Don’t overuse lazy init in latency‑sensitive code paths.
- Profile startup with
--debugandApplicationStartup(Buffering or FlightRecorder).
5) Production‑Ready WebClient with Timeouts, Pooling, and Retries
A hardened HTTP client reduces tail latency and protects upstreams.
When to use
- Your service calls other services or third‑party APIs.
- You need timeouts, connection pooling, and standard retry policies.
Snippet (Java)
package com.example.http;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import reactor.util.retry.Retry;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeFilterFunctions;
import org.springframework.web.reactive.function.client.WebClient;
@Configuration
public class WebClientConfig {
@Bean
WebClient webClient(WebClient.Builder builder) {
ConnectionProvider provider =
ConnectionProvider.builder("app-webclient")
.maxConnections(200)
.pendingAcquireMaxCount(1000)
.pendingAcquireTimeout(Duration.ofSeconds(1))
.maxIdleTime(Duration.ofSeconds(30))
.build();
HttpClient httpClient = HttpClient.create(provider)
.compress(true)
.responseTimeout(Duration.ofSeconds(3))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(3, TimeUnit.SECONDS))
.addHandlerLast(new WriteTimeoutHandler(3, TimeUnit.SECONDS)));
return builder
.clientConnector(new ReactorClientHttpConnector(httpClient))
.filter(ExchangeFilterFunctions.statusError(
status -> status.is5xxServerError() || status.value() == 429,
(req, res) -> new RuntimeException("Upstream error: " + res.statusCode())))
.filter((req, next) ->
next.exchange(req)
.retryWhen(Retry.backoff(3, Duration.ofMillis(200))
.filter(ex -> !(ex instanceof IllegalArgumentException))
.maxBackoff(Duration.ofSeconds(2))))
.build();
}
}
Why it works
- Connection pool prevents excessive socket churn.
- Aggressive timeouts and bounded retries protect your service and upstreams.
- Gzip compression reduces payload sizes.
Tips
- Externalize numbers with
@ConfigurationProperties(see Recipe #2). - For synchronous clients, configure
RestTemplateBuildersimilarly.
6) Blazing‑Fast Cache with Caffeine
A low‑latency local cache lowers DB/HTTP loads and improves tail latency.
When to use
- Hot reads with moderate cardinality and acceptable staleness.
- You want per‑cache TTLs and sizes.
Snippet (dependencies)
- Add
spring-boot-starter-cache - Add
com.github.ben-manes.caffeine:caffeine
Snippet (Java)
package com.example.cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
import java.util.List;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
CacheManager cacheManager() {
Caffeine<Object, Object> base = Caffeine.newBuilder().recordStats();
CaffeineCache usersById = new CaffeineCache(
"usersById",
base.maximumSize(50_000).expireAfterWrite(Duration.ofMinutes(10)).build());
CaffeineCache productsBySku = new CaffeineCache(
"productsBySku",
base.maximumSize(100_000).expireAfterAccess(Duration.ofMinutes(5)).build());
SimpleCacheManager mgr = new SimpleCacheManager();
mgr.setCaches(List.of(usersById, productsBySku));
return mgr;
}
}
Usage
@Cacheable(cacheNames = "usersById", key = "#userId")
public User findUser(long userId) { ... }
@CacheEvict(cacheNames = "usersById", key = "#user.id")
public void updateUser(User user) { ... }
Why it works
- Caffeine offers high hit rates and near‑O(1) operations.
- Per‑cache settings let you tailor TTLs and sizes.
Tips
- Monitor
cache.gets/cache.hitvia Micrometer (Caffeine exposes stats). - For distributed caching, add Redis or a second‑level cache alongside Caffeine.
7) Async & Scheduler Thread Pools (with Virtual Threads Option)
Right‑sized executors prevent queue explosions and give predictable latency. Virtual threads can simplify tuning for IO‑heavy workloads.
When to use
- You use
@Asyncor@Scheduled. - You want MDC propagation and bounded queues.
Snippet (Java)
package com.example.async;
import org.slf4j.MDC;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import java.util.Map;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskDecorator mdcTaskDecorator() {
return runnable -> {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
if (contextMap != null) MDC.setContextMap(contextMap);
try { runnable.run(); } finally { MDC.clear(); }
};
};
}
@Bean(name = "appExecutor")
public Executor appExecutor(TaskDecorator mdcTaskDecorator) {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(16);
exec.setMaxPoolSize(64);
exec.setQueueCapacity(1000);
exec.setThreadNamePrefix("async-");
exec.setTaskDecorator(mdcTaskDecorator);
exec.initialize();
return exec;
}
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(8);
scheduler.setThreadNamePrefix("sched-");
return scheduler;
}
// Optional: handle uncaught exceptions from @Async void methods
@Bean
public AsyncUncaughtExceptionHandler asyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
// log or route to observability
};
}
}
Optional: flip a switch for virtual threads (Java 21+, Boot 3.2+)
spring:
threads:
virtual:
enabled: true
Or explicitly provide a virtual-thread executor:
@Bean(name = "appExecutor")
public Executor virtualThreadExecutor() {
return java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor();
}
Why it works
- Dedicated pools for async and scheduling avoid contention with server worker threads.
- MDC propagation preserves correlation IDs in logs.
- Virtual threads drastically simplify IO‑heavy scaling.
Tips
- Avoid unbounded queues; monitor queue depth and rejection counts.
- Tune sizes based on CPU (CPU‑bound) or concurrency (IO‑bound).
8) Actuator, Health, Readiness/Liveness, and Endpoint Security
Expose only what you need, secure it, and provide reliable probes for orchestrators.
When to use
- Kubernetes, ECS, or any orchestrator needs liveness/readiness.
- You want production observability without oversharing.
Snippet (application.yml)
management:
server:
port: 8081
endpoints:
web:
exposure:
include: health,info,prometheus
base-path: /actuator
endpoint:
health:
probes:
enabled: true
show-details: when_authorized
metrics:
tags:
application: my-app
Spring Security snippet to secure Actuator
package com.example.security;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class ActuatorSecurityConfig {
@Bean
SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
.anyRequest().hasRole("ACTUATOR"))
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable());
return http.build();
}
}
Custom HealthIndicator
package com.example.health;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class DownstreamHealthIndicator implements HealthIndicator {
@Override
public Health health() {
// ping a dependency with tight timeout or consult cached results
boolean ok = true; // replace with real check
return ok ? Health.up().build() : Health.down().withDetail("service", "down").build();
}
}
Why it works
- Separate management port and restricted exposure reduce attack surface.
- Liveness/readiness integrate with Kubernetes for safe rollouts.
- Health indicators surface downstream issues quickly.
Tips
- Add Prometheus metrics via
micrometer-registry-prometheusand scrape/actuator/prometheus. - Don’t expose
/envor/beanspublicly.
9) Graceful Shutdown and Server Thread Tuning (Tomcat)
Tune server threads and timeouts to maintain throughput without overwhelming the app during spikes.
When to use
- High‑traffic APIs that need stable latency.
- Rolling restarts or blue/green deployments.
Snippet (application.yml)
server:
shutdown: graceful
tomcat:
threads:
max: 200 # worker threads
min-spare: 20
accept-count: 1000 # queued connections when all workers busy
max-connections: 8192
connection-timeout: 3s
compression:
enabled: true
mime-types: text/html,text/xml,text/plain,text/css,application/json,application/xml
min-response-size: 1KB
http2:
enabled: true
spring:
lifecycle:
timeout-per-shutdown-phase: 25s
Why it works
- Graceful shutdown lets in‑flight requests complete before termination.
- Right‑sized worker threads and accept queue avoid connection resets.
- HTTP/2 and compression improve network efficiency.
Tips
- Match thread counts to CPU and downstream capacity.
- For WebFlux/Netty, tune Reactor Netty via
reactor.netty.http.server.*or dedicatedHttpServer.
10) Safe, Automated Database Migrations with Flyway
Database drift causes outages. Make schema changes repeatable and visible.
When to use
- You ship changes frequently.
- You want zero‑touch migrations during deploys.
Snippet (application.yml)
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true # helpful when adopting Flyway on existing DBs
out-of-order: false
clean-disabled: true
placeholders:
app_user: app
placeholder-prefix: ${ # default, explicit for clarity
placeholder-suffix: }
Example migration
src/main/resources/db/migration/V1__create_tables.sql
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Why it works
- Migrations run at startup in a controlled, versioned manner.
- Baseline avoids clobbering existing schemas when onboarding.
Tips
- For large/locking migrations, run Flyway via a job at deploy time, not on app startup.
- Do not enable
cleanin production.
Bonus mini‑tweaks to consider
- Disable excessive logs in hot paths:
logging: level: org.springframework.jdbc.core.JdbcTemplate: WARN org.hibernate.SQL: WARN org.hibernate.type.descriptor.sql.BasicBinder: WARN - Set response compression and cache headers at the framework level for static assets.
- For JSON, prefer afterburner and sane defaults:
implementation("com.fasterxml.jackson.module:jackson-module-afterburner")@Bean com.fasterxml.jackson.databind.Module afterburner() { return new com.fasterxml.jackson.module.afterburner.AfterburnerModule(); }
Putting it together: a reference application.yml
Use this as a starting point and adjust to your workload.
spring:
profiles:
default: local
group:
prod: [aws, observability]
main:
lazy-initialization: true
threads:
virtual:
enabled: true # Optional if you use Java 21+
datasource:
url: jdbc:postgresql://db:5432/app
username: app
password: ${DB_PASSWORD}
hikari:
pool-name: app-pool
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 250ms
max-lifetime: 1800000
idle-timeout: 600000
auto-commit: false
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
flyway:
enabled: true
baseline-on-migrate: true
clean-disabled: true
management:
server:
port: 8081
endpoints:
web:
base-path: /actuator
exposure:
include: health,info,prometheus
endpoint:
health:
probes:
enabled: true
show-details: when_authorized
server:
shutdown: graceful
http2:
enabled: true
compression:
enabled: true
min-response-size: 1KB
tomcat:
threads:
max: 200
min-spare: 20
accept-count: 1000
max-connections: 8192
connection-timeout: 3s
logging:
level:
root: INFO
com.example: DEBUG
Measuring impact: don’t guess—measure
Configuration is only as good as the results it delivers. To verify improvements:
- Measure startup: compare
ApplicationStartedEventtimestamps and memory profiles. - Load test: use realistic traffic patterns; watch p95/p99 latency, error rates.
- Observe: instrument with Actuator and Micrometer (Prometheus/Grafana).
- Tighten: iterate on pool sizes, timeouts, queues—small changes can be significant.
Common pitfalls to avoid
- Over‑allocating thread pools: more threads ≠ more throughput; can cause thrashing.
- Infinite retries: always bound retries and use backoff; fail fast when needed.
- Huge caches without eviction: memory spikes and GC pauses will hurt tail latency.
- Exposing actuator endpoints publicly: restrict exposure and require auth.
- Long transactions and Open Session in View: disable
open-in-viewand keep transactions short.
Final checklist
- Profiles and groups keep environment configs maintainable.
- Properties are type‑safe and validated.
- Connection pools and executors are tuned and monitored.
- WebClient has timeouts, pooling, and bounded retries.
- Caching is explicit with per‑cache TTLs and sizes.
- Actuator endpoints are secured; health probes are enabled.
- Server shutdown is graceful; threads are right‑sized.
- Migrations are automated and safe.
Adopt these recipes incrementally. Each one is a small, purposeful change with a measurable payoff. Combine them to unlock the full potential of your Spring Boot applications—faster startup, lower latency, fewer incidents, and a configuration you can trust.