Entity design, lazy loading pitfalls
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 blindlyFetch 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