12.4 Pro Tips for Spring in Production
Common pitfalls and patterns that senior developers rely on. Keeping these in mind helps with debugging and design.
1. @Transactional Does Not Apply to Same-Class Method Calls
Spring’s @Transactional works through a proxy. When you call another method inside the same bean, that call does not go through the proxy, so no transaction is started.
@Service
public class OrderService {
public void placeOrder(OrderRequest req) {
validate(req);
saveOrder(req); // ❌ Same-class call → @Transactional is ignored
}
@Transactional(rollbackFor = Exception.class)
public void saveOrder(OrderRequest req) { ... }
}
Fix: Call the transactional method from another bean, or inject a reference to self and call self.saveOrder(req) so the call goes through the proxy. Alternatively, put the transaction boundary at the top (e.g. on placeOrder).
2. LazyInitializationException: Transaction Scope and Fetch Strategy
With JPA, accessing lazy associations (e.g. @OneToMany) outside a transaction causes LazyInitializationException (“session is closed”).
- Use
@Transactional(readOnly = true)on service methods that only read data so the session stays open for the whole method. - Use
join fetchor@EntityGraphto load needed associations in one go and avoid N+1.
@Transactional(readOnly = true)
public OrderDto getOrder(Long id) {
Order order = orderRepository.findByIdWithItems(id); // join fetch
return OrderDto.from(order); // session still open when accessing order.getItems()
}
3. N+1: Use Batch Size and Fetch Strategy for List Queries
When associations are lazy, iterating a list and calling getItems() (or similar) triggers N+1 queries.
@BatchSize(size = 100)on the entity or association: loads in batches via IN queries.join fetchor@EntityGraphin the repository to load in one query.- For read-only lists, DTO projections (JPQL or native) that select only the columns you need are often the safest and clearest.
4. Prefer Constructor Injection, Avoid Field Injection
- Constructor injection: Dependencies are explicit,
finalfields stay immutable, and tests can inject mocks easily. - Field injection (
@Autowiredon a field): Harder to test and can hide circular dependencies until runtime.
With a single constructor, @Autowired can be omitted. Using @RequiredArgsConstructor and final fields is a common best practice.
5. Tests: Start Only the Context You Need
- Use
@SpringBootTestonly when you need the full stack. Use@WebMvcTestfor controller-only tests and@DataJpaTestfor JPA/repository tests. - Overusing
@SpringBootTestslows tests and pulls in beans you don’t need, making failures harder to diagnose. - For integration tests, Testcontainers keeps DB behavior consistent between local and CI.
6. Environment-Specific Config and Secrets
- Split config with
application-{profile}.ymland activate viaspring.config.activate.on-profileor run arguments. - Do not put DB passwords or API keys in
application.ymlas plain text. Use environment variables or Vault.
Example:password: ${DB_PASSWORD:}(no default so startup fails clearly if the variable is missing).
7. Don’t Log Passwords, Tokens, or Full Request Bodies
- When logging request bodies, mask or omit passwords, tokens, card numbers, etc.
- In production, control how much stack trace you log (security and volume). Prefer structured logging and safe error messages.
8. Handle Optional in the Service Layer
- Repositories return
Optional; the service layer should resolve it once, e.g.orElseThrow(() -> new NotFoundException(...)). - Don’t expose
Optionalin the API; map “not found” to 404 and a clear message in the controller/advice.
9. Use DTOs for API, Not Entities
- Exposing entities directly in JSON ties your API to associations, lazy loading, and entity evolution, and can create security and performance issues.
- Use dedicated DTOs for request and response; keep entity handling inside the service and convert to DTO there or in a dedicated mapper.
10. HikariCP Pool Sizing
- Rule of thumb:
connections ≈ (core_count * 2) + effective_spindle_count. - For a typical CPU-bound web app, around 2x core count is a starting point; tune with load tests.
- Oversizing the pool can increase DB connections and memory without improving throughput.
11. Put API Version in the Path
- Path-based versioning like
/api/v1/usersand/api/v2/userskeeps caching, client rollout, and docs simple. - Header-based versioning is possible, but path versioning is often easier to align with logs, monitoring, and OpenAPI.
12. Handle and Translate Exceptions by Layer
- Controllers should catch only what they need; let
@RestControllerAdvicehandle the rest. - Services throw domain/business exceptions; the advice maps them to HTTP status, error codes, and messages so the API has a consistent error shape. Centralizing “what the client sees” in one place pays off in production.
These tips are a practical checklist, not a substitute for team conventions. When you hit a new pitfall, add a line to your team docs or this list so the next person benefits.