JAVA Contents

Entity design, lazy loading pitfalls

Designing JPA entities for production: lazy loading traps, equals and hashCode mistakes, cascade misuse and DTO separation.

On this page

Why Bad Entity Design Breaks Production

JPA works beautifully in demos. It collapses under load when entity design is careless. Production symptoms: - LazyInitializationException in random endpoints - Massive unexpected joins - Memory explosion - Infinite JSON recursion - N+1 queries everywhere - Cascade deleting half your database JPA is not magic. It is a thin abstraction over SQL.

Incident Scenario: LazyInitializationException in Production

Everything worked in integration tests. Production starts throwing: LazyInitializationException: could not initialize proxy Root cause: Entity relationship was LAZY. Session closed before serialization. Controller returned entity directly. You leaked persistence concerns into your API layer.

Anti-Pattern: Returning Entities from Controllers

@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}
Why this is dangerous: - Triggers lazy loading during JSON serialization - Causes N+1 queries - Exposes internal schema - Breaks if transaction is closed Entities are not API models.

Correct Pattern: Entity → DTO Mapping

public record UserDto(Long id, String email) {}

@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) {
    User user = userRepository.findById(id).orElseThrow();
    return new UserDto(user.getId(), user.getEmail());
}
This: - Prevents lazy traversal - Defines stable API contract - Reduces query load

Lazy vs Eager: Default Is Dangerous

Many-to-one defaults to EAGER. One-to-many defaults to LAZY. EAGER in production: - Hidden joins - Massive result sets - Performance degradation Always prefer LAZY. Load explicitly with fetch joins.

Bidirectional Relationship Hell

Example: User has List Orders. Order has User. If not handled carefully: - Infinite recursion in JSON - StackOverflowError - Huge SQL joins If you need both sides, manage them explicitly. Otherwise prefer unidirectional mapping.

equals and hashCode Disaster

Never include collections. Never include mutable fields. Wrong:
@Override
public boolean equals(Object o) {
    return this.email.equals(((User)o).email);
}
If email changes, entity breaks in sets. Best practice: - Use database id only - Or use immutable business key Incorrect equals/hashCode causes: - Broken caching - Duplicate entities in collections - Subtle production bugs

Cascade Misuse

CascadeType.ALL looks convenient. It is dangerous. If you delete parent: All children may be deleted silently. Production incident: Deleting one user removed entire order history. Be explicit: - Use cascade only where lifecycle truly matches - Avoid ALL blindly

Fetch Join for Controlled Loading

Example:
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
Optional findWithOrders(@Param("id") Long id);
This: - Loads required relations - Avoids N+1 - Keeps transaction predictable

Transactions and Entity Scope

Entities are attached to persistence context. Once transaction ends: - Lazy relations break - Dirty checking stops Never depend on open session in view pattern in production. It hides performance issues.

Performance Considerations

- Avoid large collections in entities - Avoid loading thousands of rows into memory - Use pagination always - Monitor generated SQL - Log slow queries Enable SQL logging in staging. Know what ORM generates.

Checklist

- Never return entities directly in REST - Prefer LAZY relationships - Use DTO mapping - Avoid bidirectional unless necessary - Define safe equals and hashCode - Avoid CascadeType.ALL blindly - Use fetch joins intentionally - Keep transactions short - Monitor generated SQL JPA is powerful. But if your entity model is naive, production will expose it fast.