Most Spring Boot production incidents don't come from exotic bugs. They come from the same ten patterns, repeated across codebases, that work fine under light load and silently degrade as traffic grows.
This guide covers the anti-patterns that production Java systems hit most often — with the exact code that causes them, why they fail, and what to write instead.
1. @Transactional on Private Methods
The mistake:
@Service
public class OrderService {
// This @Transactional does NOTHING.
@Transactional
private void processPayment(Order order) {
// changes to order are NOT in a transaction
}
public void submitOrder(Order order) {
processPayment(order); // direct call, bypasses proxy
}
}
Why it fails: Spring's @Transactional works through a proxy that wraps the bean. When you call a private method — or any method from within the same class — you bypass the proxy and call the raw object directly. No proxy → no transaction. Exceptions don't roll back. Data writes commit even when they shouldn't.
This is the most common Spring transactional bug. It compiles and passes unit tests. It fails silently in production when a mid-transaction exception leaves the database in a partially-written state.
The fix:
@Service
@RequiredArgsConstructor
public class OrderService {
private final PaymentService paymentService; // separate @Service
public void submitOrder(Order order) {
paymentService.processPayment(order); // goes through Spring proxy
}
}
@Service
public class PaymentService {
@Transactional // works because external call goes through proxy
public void processPayment(Order order) { ... }
}
If refactoring to a separate class isn't practical, inject self:
@Service
public class OrderService {
@Autowired
private OrderService self; // Spring injects the proxied version
public void submitOrder(Order order) {
self.processPayment(order); // goes through proxy
}
@Transactional
public void processPayment(Order order) { ... }
}
Catch it early: Enable Spring's proxy debug logging (logging.level.org.springframework.aop=DEBUG) in a staging environment and verify transactions are opening where expected.
2. The N+1 Query Problem
This is the single most common performance issue in JPA-based Spring Boot applications. A page that renders in 50ms under load testing collapses to 4 seconds in production because the dataset grew.
The mistake:
// Entity
@Entity
public class Order {
@OneToMany(fetch = FetchType.LAZY)
private List<LineItem> lineItems;
}
// Service
public List<OrderSummary> getOrders() {
List<Order> orders = orderRepository.findAll(); // 1 query
return orders.stream()
.map(o -> new OrderSummary(o, o.getLineItems().size())) // N queries
.toList();
}
For 200 orders: 1 query to fetch orders + 200 queries to fetch each order's line items = 201 queries. At 1000 orders it's 1001 queries. Most ORMs make this invisible — the queries happen lazily, inside the stream, with no stack frame pointing to the cause.
How to detect it: Enable query logging:
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
If you see the same query repeated in a loop with different ID parameters, it's N+1.
The fix — JOIN FETCH:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.lineItems WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
}
One query. Fetches orders and their line items in a single JOIN.
The fix — Projections (when you only need some fields):
public interface OrderSummaryView {
String getOrderId();
BigDecimal getTotal();
int getItemCount();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummaryView> findByStatus(OrderStatus status);
}
Projections let Spring Data generate a SELECT with only the columns you need — no entity materialization, no lazy loading risk.
The fix — @EntityGraph:
@EntityGraph(attributePaths = {"lineItems", "lineItems.product"})
List<Order> findAll();
Declarative JOIN FETCH without a JPQL query. Cleaner for simple cases.
3. Loading Entities for Count/Existence Checks
// Loads the entire entity from DB just to check if it exists
if (orderRepository.findById(orderId).isPresent()) {
// ...
}
// Loads all orders just to count them
int count = orderService.findAll().size();
Both are SELECT * queries that transfer full row data across the network, materialize Java objects in heap, then throw most of it away.
The fix:
// Existence — single indexed column lookup, no data transfer
if (orderRepository.existsById(orderId)) { ... }
// Count — aggregation runs on the DB side
long count = orderRepository.count();
// Conditional count
long pendingCount = orderRepository.countByStatus(OrderStatus.PENDING);
For complex conditions:
@Query("SELECT COUNT(o) FROM Order o WHERE o.status = :status AND o.createdAt > :since")
long countRecentByStatus(@Param("status") OrderStatus status, @Param("since") LocalDateTime since);
4. Misconfigured Hikari Connection Pool
HikariCP ships with maximum-pool-size=10. Under low load this is invisible. Under real traffic it causes connection wait times that cascade into request timeouts.
# Default — often wrong for production
spring:
datasource:
hikari:
maximum-pool-size: 10 # too low for most services
Symptoms: Requests succeed but take 200-500ms longer than expected. Actuator metrics show hikaricp.connections.pending > 0 regularly. Logs show HikariPool - Connection is not available, request timed out.
The fix — size for your thread model:
spring:
datasource:
hikari:
# For virtual threads: connections = (number of cores * 2) is a starting point
# For platform threads: connections = thread pool size
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000 # fail fast — 3s, not the default 30s
idle-timeout: 600000 # 10 min
max-lifetime: 1800000 # 30 min — rotate before DB kills idle connections
keepalive-time: 60000 # prevent firewall from killing idle connections
Expose pool metrics:
management:
metrics:
enable:
hikaricp: true
Then monitor hikaricp.connections.pending and hikaricp.connections.acquire p99. If pending > 0 for more than brief spikes, increase the pool.
[!NOTE]
With virtual threads (Spring Boot 3.2+, spring.threads.virtual.enabled=true), the optimal pool size is lower than with platform threads — virtual threads park during I/O so fewer connections handle more concurrent requests. Start at 2× core count and tune from metrics.
5. @Transactional(readOnly = true) Left Off Read Queries
// Missing readOnly — Spring opens a full R/W transaction
@Transactional
public List<Order> findRecentOrders(LocalDate since) {
return orderRepository.findByCreatedAtAfter(since.atStartOfDay());
}
readOnly = true on a read-only transaction:
- Tells Hibernate to skip dirty checking (no snapshot comparison at flush time)
- Lets the JPA provider skip the write-ahead log
- Some databases route read-only transactions to read replicas automatically
The fix:
@Transactional(readOnly = true)
public List<Order> findRecentOrders(LocalDate since) {
return orderRepository.findByCreatedAtAfter(since.atStartOfDay());
}
Low effort, measurable improvement under read-heavy load. On services with 80% read traffic, this alone reduces database write-lock contention.
6. Missing Database Indexes on JPA Foreign Keys
JPA doesn't automatically create indexes on foreign key columns. It creates the constraint, not the index. This causes full table scans on every JOIN against a large table.
@Entity
public class LineItem {
@ManyToOne
@JoinColumn(name = "order_id") // creates FK constraint, NOT an index
private Order order;
}
Finding all line items for an order does SELECT * FROM line_item WHERE order_id = ?. Without an index on order_id, this is a full scan of the entire line_item table.
The fix — declare the index explicitly:
@Entity
@Table(name = "line_item",
indexes = @Index(name = "idx_line_item_order_id", columnList = "order_id"))
public class LineItem {
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
}
Or via Liquibase/Flyway (preferred for production — schema migrations in code):
-- V3__add_line_item_order_index.sql
CREATE INDEX idx_line_item_order_id ON line_item(order_id);
Finding missing indexes: Run EXPLAIN ANALYZE on your slowest queries. Any Seq Scan on a large table that filters by a FK column is a missing index candidate.
7. Leaking Hibernate Sessions Into Jackson Serialization
@Entity
public class Order {
@OneToMany(fetch = FetchType.LAZY)
private List<LineItem> lineItems;
}
// Controller — Open Session in View is ON by default
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return orderRepository.findById(id).orElseThrow();
// Jackson serializes Order → touches lineItems → Hibernate loads lazily
// If session is closed: LazyInitializationException
// If session is open (OSIV): unexpected extra query per request
}
Spring Boot enables Open Session in View (OSIV) by default. This keeps the Hibernate session open through the entire request, including serialization. This means:
- Lazy collections load silently during JSON serialization (N+1 hidden in Jackson)
- Session-per-request model doesn't compose with virtual threads
- Database connections held for the full request lifecycle, not just the DB operation
The fix — disable OSIV, use DTOs:
spring:
jpa:
open-in-view: false # disable OSIV
// Fetch everything you need while the session is open
@Transactional(readOnly = true)
public OrderResponse getOrder(Long id) {
Order order = orderRepository.findByIdWithItems(id)
.orElseThrow(() -> new OrderNotFoundException(id));
// Map to a DTO — no Hibernate proxy, safe to serialize
return OrderResponse.from(order);
}
public record OrderResponse(String orderId, BigDecimal total, List<LineItemResponse> items) {
static OrderResponse from(Order o) {
return new OrderResponse(
o.getId().toString(),
o.getTotal(),
o.getLineItems().stream().map(LineItemResponse::from).toList()
);
}
}
8. Unbounded @Async Thread Pools
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
// No custom config — Spring defaults to SimpleAsyncTaskExecutor
// SimpleAsyncTaskExecutor creates a new thread per task, no limit
}
@Service
public class NotificationService {
@Async
public void sendEmail(String to, String subject) {
// ...
}
}
SimpleAsyncTaskExecutor (the default when no pool is configured) creates a new thread per task with no upper bound. Under sustained load this spins up thousands of threads and OOMs the service.
The fix — explicit bounded pool:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(500); // tasks queue before rejecting
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
CallerRunsPolicy is usually the right rejection handler for background tasks — it runs the task on the caller's thread rather than throwing. This creates natural backpressure: if the async pool is saturated, the calling thread slows down instead of the service crashing.
For virtual threads (Java 21+):
@Bean
public Executor asyncExecutor() {
return Executors.newVirtualThreadPerTaskExecutor(); // unbounded, but virtual threads are cheap
}
Virtual thread executors are safe to make unbounded because virtual threads don't block OS threads during I/O and cost ~1KB each.
9. Exception Swallowing in Background Jobs
@Component
public class ReconciliationJob {
@Scheduled(cron = "0 0 2 * * *")
public void run() {
try {
reconcile();
} catch (Exception e) {
log.error("Reconciliation failed", e);
// returns normally — scheduler sees success
// no alert, no retry, no escalation
}
}
}
The scheduler sees this method return without exception and marks it successful. The error is in the log. Without a log alert configured, the failure is invisible. Reconciliation silently stops running.
This pattern — catch, log, swallow — is how data inconsistencies accumulate over months without anyone noticing.
The fix — let exceptions propagate, or use structured alerting:
@Scheduled(cron = "0 0 2 * * *")
public void run() {
try {
reconcile();
} catch (Exception e) {
// Send an alert before rethrowing
alertService.sendAlert("Reconciliation failed: " + e.getMessage(), AlertSeverity.HIGH);
meterRegistry.counter("job.reconciliation.failure").increment();
throw new RuntimeException("Scheduled reconciliation failed", e);
// Spring will log this as an error — also visible in Actuator health
}
}
Or better — use Temporal or Spring Batch for jobs that need fault tolerance and visibility (see the Temporal guide).
10. Logging in the Hot Path With String Concatenation
// Evaluated on every call, even when DEBUG logging is off
log.debug("Processing order " + order.getId() + " for customer " + customer.getName());
+ string concatenation creates intermediate String objects on every call. At 10,000 requests/minute, even a disabled DEBUG statement that runs on every request produces garbage that GC must collect, adding latency spikes.
The fix — parameterized logging:
// SLF4J defers evaluation — no string created if DEBUG is off
log.debug("Processing order {} for customer {}", order.getId(), customer.getName());
The {} placeholders are only evaluated if the logger is actually at DEBUG level. Zero allocation in production where DEBUG is off.
For complex log objects that are expensive to produce:
// Supplier form — only called if DEBUG is on
log.debug("Order state: {}", () -> computeExpensiveOrderDump(order));
The Production Checklist
Before a Spring Boot service goes to production, run through this:
- Grep for
@Transactionalon private methods — fix any found - Run queries against staging data at 10× production volume — look for N+1 patterns in query logs
- Disable OSIV (
spring.jpa.open-in-view: false) and fix resultingLazyInitializationExceptions - Run
EXPLAIN ANALYZEon your top 10 slowest queries — add missing indexes - Add
@Transactional(readOnly = true)to all read-only service methods - Verify Hikari pool size matches your threading model — expose pool metrics in Actuator
- Configure a named, bounded
ThreadPoolTaskExecutorfor@Asynctasks - Verify all
@Scheduledjobs either let exceptions propagate or send alerts before swallowing - Replace
log.debug("text" + var)with parameterizedlog.debug("text {}", var)in hot paths - Set
management.health.db.enabled=trueand test liveness/readiness probes
Catching These Before Production
Static analysis. SpotBugs + find-sec-bugs catches some of these (including the @Transactional private method issue). Add it to your Maven/Gradle build.
Query logging in staging. Enable org.hibernate.SQL: DEBUG in your staging environment against a dataset that's at least 10% of production volume. Any N+1 pattern shows up as repeated identical queries.
Load testing. k6, Gatling, or even ab at 2× expected peak. Watch HikariCP's connections.pending, Hibernate's queries.execution p99, and JVM GC pause time. Spikes in any of these under load point to one of the issues above.
Production metrics. Every Spring Boot 3 service should export to an observability backend. Dashboards to have:
- DB connection pool saturation (
hikaricp.connections.pending> 0) - Slow query p99 (> 100ms threshold)
- GC pause duration (> 50ms threshold)
- Heap utilization trend (monotonic growth = memory leak)
These patterns aren't obscure. They appear in production codebases at companies of every size, written by engineers who knew the frameworks well. The difference between a service that holds up under load and one that requires an incident at 2 AM is usually caught before deployment, not after.
Spotted one of these in your codebase? Or have a production anti-pattern that isn't on this list? Find me on LinkedIn — the list grows from war stories.