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;
public void submitOrder(Order order) {
paymentService.processPayment(order);
}
}
@Service
public class PaymentService {
@Transactional
public void processPayment(Order order) { ... }
}
If refactoring to a separate class isn't practical, inject self:
@Service
public class OrderService {
@Autowired
private OrderService self;
public void submitOrder(Order order) {
self.processPayment(order);
}
@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
public class Order {
@OneToMany(fetch = FetchType.LAZY)
private List<LineItem> lineItems;
}
public List<OrderSummary> getOrders() {
List<Order> orders = orderRepository.findAll();
return orders.stream()
.map(o -> new OrderSummary(o, o.getLineItems().size()))
.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
if (orderRepository.findById(orderId).isPresent()) {
}
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:
if (orderRepository.existsById(orderId)) { ... }
long count = orderRepository.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);
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.
spring:
datasource:
hikari:
maximum-pool-size: 10
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:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
keepalive-time: 60000
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
@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")
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):
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;
}
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return orderRepository.findById(id).orElseThrow();
}
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
@Transactional(readOnly = true)
public OrderResponse getOrder(Long id) {
Order order = orderRepository.findByIdWithItems(id)
.orElseThrow(() -> new OrderNotFoundException(id));
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 {
}
@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);
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();
}
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);
}
}
}
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) {
alertService.sendAlert("Reconciliation failed: " + e.getMessage(), AlertSeverity.HIGH);
meterRegistry.counter("job.reconciliation.failure").increment();
throw new RuntimeException("Scheduled reconciliation failed", e);
}
}
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
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:
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:
log.debug("Order state: {}", () -> computeExpensiveOrderDump(order));
The Production Checklist
Before a Spring Boot service goes to production, run through this:
graph TD
A[Spring Boot Service] --> B{Code Review}
B --> C[Transaction Check]
B --> D[Query Analysis]
B --> E[Pool Config]
B --> F[Async Config]
C --> G[No private @Transactional]
D --> H[No N+1 — JOINs or projections]
D --> I[Indexes on FK columns]
E --> J[Bounded Hikari pool]
E --> K[Pool metrics exposed]
F --> L[Bounded executor configured]
G --> M[Production Ready]
H --> M
I --> M
J --> M
K --> M
L --> M
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.