Skip to main content

17.4 Pro Tips — Refactoring Complex Business Logic and Design Patterns

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.