Tech · 6 min read
Spring Boot Best Practices for Production
Spring Boot accelerates Java backend development but ships with defaults that need tuning for production. Configuration, observability, performance, and security practices that matter.
By Jarviix Engineering · Apr 19, 2026
Spring Boot has dominated Java backend development for nearly a decade. Its convention-over-configuration approach gets teams shipping quickly, and its ecosystem covers nearly every backend need. But "quick to start" doesn't mean "production-ready by default." Many of Spring Boot's defaults are tuned for getting started, not for running production workloads at scale.
This post covers the practices that distinguish production-grade Spring Boot applications from prototypes — configuration, observability, performance, security, and the patterns that experienced teams converge on.
Configuration management
Use profiles strategically
application.yml: defaults safe for any environmentapplication-dev.yml: development overridesapplication-test.yml: test environment specificsapplication-prod.yml: production overrides
Activate profile via SPRING_PROFILES_ACTIVE env var.
Externalize sensitive config
Never commit secrets. Use:
- Environment variables for passwords, API keys
- Secret managers (AWS Secrets Manager, Vault) for sensitive runtime data
- Spring Cloud Config Server for centralized config
Type-safe configuration
Prefer @ConfigurationProperties over scattered @Value:
@ConfigurationProperties(prefix = "myapp.database")
public record DatabaseProperties(
String url,
String username,
int connectionPoolSize,
Duration connectionTimeout
) {}
Validates structure at startup; refactoring-safe; single source of truth.
Don't load all config at startup
For dynamic configuration (feature flags, runtime tuning), integrate with external systems (LaunchDarkly, AWS AppConfig, custom config server). Restarting the application to change a flag is a smell.
Observability
Use Spring Boot Actuator
Add spring-boot-starter-actuator. Provides:
/actuator/health: health checks for K8s/load balancers/actuator/metrics: application metrics/actuator/info: build info, git commit/actuator/loggers: runtime log level changes
Secure actuator endpoints in production — don't expose them publicly.
Structured logging
Use SLF4J with structured logging:
@Slf4j
public class OrderService {
public void process(Order order) {
log.info("Processing order orderId={} userId={} amount={}",
order.getId(), order.getUserId(), order.getAmount());
}
}
For JSON logging (preferred in production), use logstash-logback-encoder or spring-boot-starter-log4j2.
Metrics with Micrometer
Spring Boot integrates with Micrometer. Register custom metrics:
@Service
public class PaymentService {
private final Counter paymentCounter;
public PaymentService(MeterRegistry registry) {
this.paymentCounter = Counter.builder("payments.processed")
.tag("status", "success")
.register(registry);
}
public void process(Payment p) {
// ...
paymentCounter.increment();
}
}
Export to Prometheus, Datadog, CloudWatch, etc.
Distributed tracing
Add Spring Cloud Sleuth (Spring Boot 2.x) or Micrometer Tracing (Spring Boot 3.x). Auto-instruments HTTP calls, database queries, message queues. Visualize in Zipkin or Jaeger.
Critical for debugging in microservice architectures.
Database practices
Connection pooling
HikariCP is the default in Spring Boot 2+. Tune it:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 10000
idle-timeout: 300000
max-lifetime: 1200000
Pool size depends on database capacity and concurrency. Rule of thumb: pool_size = ((core_count * 2) + effective_spindle_count) for the database server.
Avoid N+1 queries
JPA's lazy loading creates these by default. Use:
@EntityGraphfor fetch hints- JPQL
JOIN FETCHfor explicit loading - Repository methods with
@Query
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.userId = :userId")
List<Order> findOrdersWithItems(Long userId);
Without this, fetching 100 orders triggers 101 queries (1 + 100 for items).
Use read replicas
For read-heavy workloads, route reads to replicas. Spring's AbstractRoutingDataSource enables this.
Pagination always
Never findAll() on tables that can grow. Use Pageable:
Page<Order> findByStatus(String status, Pageable pageable);
Transactions deliberately
- Use
@Transactionalthoughtfully — every method = its own transaction is expensive - Set isolation levels explicitly when needed
- Avoid long-running transactions
- Read-only transactions:
@Transactional(readOnly = true)for slight optimization
Performance
JVM tuning
Container-aware JVM (Java 11+ recognizes container limits automatically). Set heap appropriately:
-Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
For containers, prefer fixed heap to avoid resize jitter.
Caching
Use Spring Cache abstraction:
@Cacheable(value = "users", key = "#id")
public User findById(Long id) { ... }
@CacheEvict(value = "users", key = "#user.id")
public void update(User user) { ... }
Backend: Caffeine (in-process) for hot data, Redis for distributed cache.
Async operations
For I/O-heavy work, use @Async and CompletableFuture:
@Async
public CompletableFuture<Result> fetchData(String id) {
return CompletableFuture.completedFuture(externalApi.call(id));
}
Configure thread pools deliberately — don't rely on defaults.
Reactive when justified
Spring WebFlux for high-concurrency, low-CPU workloads. Don't reach for it just because "reactive is modern" — adds significant complexity. Most CRUD apps don't benefit.
Security
Authentication and authorization
Use Spring Security with established patterns:
- OAuth2 for API authentication
- JWT for stateless tokens
- Method-level
@PreAuthorizefor fine-grained authorization
Don't roll your own auth. Spring Security handles edge cases you haven't thought of.
Input validation
Use Bean Validation (@Valid, @NotNull, @Pattern):
public record CreateUserRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password,
@Pattern(regexp = "^[A-Za-z\\s]+$") String name
) {}
@PostMapping("/users")
public User create(@Valid @RequestBody CreateUserRequest request) { ... }
CSRF protection
Enabled by default in Spring Security. Disable only for stateless APIs, and only deliberately.
Security headers
server:
servlet:
session:
cookie:
secure: true
http-only: true
same-site: strict
Add Content-Security-Policy, X-Content-Type-Options, X-Frame-Options.
Secrets in environment, not code
Never:
@Value("${api.key}")
private String apiKey = "actual-key-here-as-default"; // DON'T
Always require env var; fail fast on missing.
Testing practices
Layered tests
- Unit tests: pure logic, no Spring context
- Slice tests:
@WebMvcTest,@DataJpaTestfor specific layers - Integration tests:
@SpringBootTestwith TestContainers for real databases
TestContainers for integration tests
Run real PostgreSQL/Redis/Kafka in Docker for tests:
@Container
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
Far better than H2 (which behaves differently from production database).
Don't mock everything
Mock external dependencies; test internal logic with real implementations. Heavy mocking creates brittle tests that don't catch real issues.
Deployment
Production-ready containers
FROM eclipse-temurin:21-jre-jammy
RUN useradd -m appuser
USER appuser
COPY app.jar /app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "/app.jar"]
Use distroless or Alpine for smaller images if needed.
Graceful shutdown
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
Allows in-flight requests to complete during deploys.
Liveness vs Readiness
- Liveness: is the application running? (restart if not)
- Readiness: is the application ready to serve traffic? (route traffic if yes)
Spring Boot Actuator provides both at /actuator/health/liveness and /actuator/health/readiness.
Common mistakes
- Logging at INFO too liberally: floods logs; expensive at scale
- Auto-configuration overload: every starter adds bean count; review what's actually needed
- Eager loading by default: causes N+1 queries
- Hardcoded thread pool sizes: doesn't scale with deployment environment
- No request timeout configuration: hung requests consume resources indefinitely
- Stack traces in API responses: leaks internal details
- Default error handling: generic 500 errors instead of meaningful client responses
- No graceful shutdown: deploys kill in-flight requests
What to read next
- Java multithreading guide — concurrency fundamentals.
- JVM garbage collection — performance tuning.
- API rate limiting strategies — protecting endpoints.
- Microservices observability — instrumentation patterns.
Spring Boot's strength is also its weakness — getting started is easy, but the defaults aren't always production-grade. The patterns above represent what experienced Spring teams converge on after running real workloads. None are revolutionary; all are practical. Adopting them moves applications from "works in demo" to "runs reliably under load."
Frequently asked questions
What's the right way to manage configuration in Spring Boot?
application.yml/properties for non-sensitive defaults, profile-based files (application-prod.yml) for environment overrides, environment variables for environment-specific values, and external secret managers (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) for sensitive data. Never commit secrets to git. Use @ConfigurationProperties for type-safe binding rather than scattered @Value annotations. For runtime config changes, integrate with Spring Cloud Config or external feature flag systems.
Should I use Spring Data JPA or jOOQ/MyBatis?
JPA for typical CRUD-heavy applications where development speed matters and queries are mostly straightforward. jOOQ for complex SQL workloads, reporting, analytical queries — type-safe SQL with full database power. MyBatis for legacy migration or when you want SQL control without JPA's abstractions. JPA's strengths are also its weaknesses: hidden N+1 queries, lazy loading exceptions, and unpredictable SQL generation can hurt at scale. Many production systems use JPA for 80% of cases and jOOQ for the 20% requiring control.
How do I optimize Spring Boot startup time?
Several approaches: (1) Use Spring Boot 3+ with native compilation (GraalVM) — startup goes from seconds to milliseconds. (2) Lazy initialization (spring.main.lazy-initialization=true) for non-critical beans. (3) Minimize dependencies — every starter adds startup cost. (4) Use class data sharing (CDS) for the JVM. (5) Profile startup with Spring Boot Actuator's startup endpoint to identify slow beans. For containerized workloads, faster startup means faster scale-up; for serverless, it's critical.
Read next
Apr 19, 2026 · 6 min read
JVM Garbage Collection: G1, ZGC, and How to Pick One Without Reading 800 Pages of Spec
What the JVM garbage collectors actually do, the trade-offs between G1, ZGC, Shenandoah, and Parallel, and how to pick one for the workload you're actually running.
Apr 17, 2026 · 7 min read
Java Multithreading: A Working Engineer's Guide to Threads, Executors and the JMM
What thread, runnable, executor, future, completable future, virtual thread, volatile, synchronized, and the Java Memory Model actually mean — with the trade-offs that decide which one to reach for in production.
Apr 19, 2026 · 6 min read
API Versioning Strategies: URL, Header, and the Trade-offs Nobody Tells You
URL versioning, header versioning, content negotiation, and 'no versioning at all' — what each costs, what each gets you, and how to pick a strategy you won't regret in three years.