Skip to main content
Advertisement

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 fetch or @EntityGraph to 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 fetch or @EntityGraph in 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, final fields stay immutable, and tests can inject mocks easily.
  • Field injection (@Autowired on 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 @SpringBootTest only when you need the full stack. Use @WebMvcTest for controller-only tests and @DataJpaTest for JPA/repository tests.
  • Overusing @SpringBootTest slows 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}.yml and activate via spring.config.activate.on-profile or run arguments.
  • Do not put DB passwords or API keys in application.yml as 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 Optional in 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/users and /api/v2/users keeps 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 @RestControllerAdvice handle 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.

tip

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.

Advertisement