💾 Section 16 · Question #3

When to Use Lazy vs Eager Loading

In modern enterprise Java and Spring Boot development, engineers adhere to a strict foundational rule:


🟢 Junior Level

The Core Architectural Verdict

In modern enterprise Java and Spring Boot development, engineers adhere to a strict foundational rule:

“Always and unconditionally declare FetchType.LAZY for 100% of entity relationships. Static FetchType.EAGER in relationship annotations is an architectural anti-pattern.”

If associated data is required for a specific business scenario (e.g., generating an itemized invoice), it must be retrieved dynamically at the query execution site using JOIN FETCH, @EntityGraph, or DTO projections, rather than hardcoded statically at the entity mapping layer.

// Entity Mapping: ALWAYS LAZY
@Entity
public class Order {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // Overrides default EAGER!
    private User user;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
}
// Repository Layer: DYNAMIC FETCHING per use case
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Scenario 1: Lightweight dashboard table (User & Items omitted -> zero wasted I/O)
    List<Order> findByStatus(String status);

    // Scenario 2: Detailed invoice view (User & Items fetched atomically via JOIN FETCH)
    @Query("SELECT o FROM Order o JOIN FETCH o.user JOIN FETCH o.items WHERE o.id = :id")
    Optional<Order> findByIdWithDetails(@Param("id") Long id);
}

Comparative Application Matrix

Cardinality / Mapping FetchType.LAZY (Default) FetchType.EAGER (Permissibility)
Collections (@OneToMany, @ManyToMany) 100% of cases Never (High risk of OutOfMemoryError and Cartesian products)
Single Associations (@ManyToOne, @OneToOne) 99% of cases (Standard) Extremely rare 1:1 shared primary key mappings (@MapsId)
Embedded Components (@Embedded) Not applicable (Same row) Always loaded together (Part of the same database table row)
Lookup Dictionaries (Categories, Statuses) Map via @Enumerated or LAZY Even if a dictionary has only 5 rows, static EAGER causes N+1

🟡 Middle Level

The Architect’s Query Decision Tree

When designing data retrieval for a business operation, follow this structured decision workflow:

Are associated entities required for the current operation?
│
├── NO  ──> Use standard findById() / findAll() with LAZY mappings.
│           (Unneeded relationships are never fetched; network I/O is minimized).
│
└── YES ──> What is the objective of the operation?
            │
            ├── READ-ONLY Operation (Displaying tables, REST API responses, reports):
            │   └── Use DTO Projections / Spring Data Record Projections.
            │       (Selects only needed columns; zero entity proxy and L1 cache overhead).
            │
            └── MUTATING Operation (Modifying aggregate business invariants):
                │
                ├── Fetching a SINGLE root aggregate by ID:
                │   └── Use JOIN FETCH or @EntityGraph.
                │
                └── Fetching a LIST of aggregates (especially with pagination Pageable):
                    └── Use batch fetching via @BatchSize / default_batch_fetch_size.

The “Small Lookup Table” EAGER Fallacy

A frequent developer trap is rationalizing: “A product category table only has 5 rows. Why not set @ManyToOne(fetch = FetchType.EAGER) on Product.category? It’s practically weightless!”

The Hidden Technical Danger:

  1. Suppose entity Product maps category with FetchType.EAGER.
  2. A service retrieves 100 products using standard JPQL:
    List<Product> products = productRepository.findAll();
    
  3. The JPQL parser strictly executes the query as written: SELECT p FROM Product p. It does not inject an outer join automatically.
  4. Hibernate executes:
    • 1 initial query: SELECT * FROM products (returns 100 products).
    • Followed by 100 secondary queries: SELECT * FROM categories WHERE id = ?.
  5. Even if all 5 category rows are cached in the Level 1 Session Cache, Hibernate must verify foreign key references for all 100 products.
  6. Result: An innocent @ManyToOne(fetch = EAGER) triggers a textbook N+1 query disaster.

🔴 Senior Level

The Aggregate Boundary Principle in Domain-Driven Design (DDD)

In senior systems architecture, the boundary between Lazy and Eager is dictated by transactional consistency boundaries (Aggregate Boundaries):

┌───────────────────────────────────────────────────────────────┐
│                 ORDER AGGREGATE (Root: Order)                 │
│                                                               │
│   Order (Aggregate Root)                                      │
│     │                                                         │
│     ├── List<OrderItem> (Internal Aggregate Entities)         │
│     │   (Lifecycle strictly bound to Order;                   │
│     │    fetched together via JOIN FETCH during mutations)    │
│     │                                                         │
│     └── DeliveryAddress (Value Object, @Embedded)             │
│         (Physically stored in the same `orders` table row)    │
└───────────────────────────────────────────────────────────────┘
                               │
                 Reference strictly by ID (or LAZY)
                               ▼
┌───────────────────────────────────────────────────────────────┐
│                 CUSTOMER AGGREGATE (Root: Customer)           │
│                                                               │
│   Customer (Independent Aggregate)                            │
│   (STRICTLY FORBIDDEN to load EAGER inside Order!             │
│    Model relationship as LAZY or use `Long customerId`)       │
└───────────────────────────────────────────────────────────────┘
  1. Inside Aggregate Boundaries: Entities maintaining immediate business consistency (such as Order and OrderItem) must be modified together. However, their entity relationship is still declared LAZY to permit lightweight reads; the aggregate root repository exposes dedicated methods like findOrderWithItemsById().
  2. Across Aggregate Boundaries: Direct EAGER associations between independent aggregate roots are strictly forbidden. Aggregates should reference each other by scalar identity (Long customerId) or strictly via LAZY references.

Comparative Evaluation of Dynamic Fetching Mechanisms

When associated data is genuinely required, select from these dynamic tools instead of static EAGER:

Mechanism Query Count Cartesian Product Risk Pagination Safe? Operational Complexity
JOIN FETCH Exactly 1 Yes (Multiple child collections) Dangerous (HHH000104, OOM) Low (Explicit JPQL)
@EntityGraph Exactly 1 (LEFT JOIN) Yes (Multiple collections) Dangerous for collections Low (Declarative)
@BatchSize / batch_fetch_size $1 + \lceil N / \text{batchSize} \rceil$ None (Independent SQL queries) Safe (Enforces DB LIMIT/OFFSET) Zero (Global config)
@Fetch(FetchMode.SUBSELECT) Exactly 2 None Risky with complex WHERE Medium

The Senior Engineering Rule of Thumb:

  • For fetching a single entity with its children: use JOIN FETCH or @EntityGraph.
  • For fetching lists or paginated collections: rely on spring.jpa.properties.hibernate.default_batch_fetch_size = 50.
  • For read-only views and UI endpoints: use constructor DTO / Java Record projections.

4 Tricky Questions

1. Can fields within an @Embeddable object or the @Embedded object itself be configured as LAZY?

Answer: By default in standard JPA, no.

  1. @Embeddable classes have no independent identity (@Id) or standalone database tables. Their properties are mapped directly as columns of the parent entity’s table.
  2. When Hibernate executes a SQL SELECT for the parent record, the database returns all columns of that table row simultaneously. Because the raw column bytes are already read from the JDBC ResultSet, deferring them adds zero performance value.
  3. Exceptions: If the @Embeddable object contains an association to an external table (such as @ManyToOne(fetch = FetchType.LAZY)), that relationship remains lazy. Furthermore, enabling build-time Bytecode Enhancement (hibernate-enhance-maven-plugin) allows specific scalar columns to be loaded lazily via @Basic(fetch = FetchType.LAZY).

2. What is the critical difference between JPA FetchType and Hibernate’s @Fetch(FetchMode.JOIN)?

Answer: This is a frequent interview point of confusion between the JPA standard and Hibernate extensions:

  • FetchType (JPA): Defines WHEN data is loaded (LAZY = deferred until first access, EAGER = immediate).
  • FetchMode (Hibernate): Defines HOW data is retrieved at the SQL level (JOIN = outer join, SELECT = secondary select query, SUBSELECT = subquery).
  • The Critical Gotcha: If you annotate an association with @Fetch(FetchMode.JOIN), Hibernate overrides FetchType to EAGER internally, even if you explicitly wrote fetch = FetchType.LAZY! This silently breaks lazy loading.

3. Why does having static EAGER associations inflict far greater damage in microservices than in monolithic applications?

Answer:

  1. In microservices, services communicate over networks via REST, gRPC, or Kafka messages.
  2. Entities returned from services are serialized into JSON or Protobuf. If an entity contains EAGER associations, serializers (like Jackson) recursively navigate the entire object graph, bloating response payloads from a clean 2 KB to hundreds of kilobytes.
  3. This triggers network bandwidth saturation, spikes p99 serialization latencies, and rapidly exhausts microservice JVM heaps under load.
  4. Furthermore, high-frequency microservice queries exhaust the shared database connection pool (HikariCP) due to unnecessary join complexity.

4. In which solitary scenario is FetchType.EAGER technically justified and free of performance hazards?

Answer: The only architecturally clean scenario:

  • A bidirectional @OneToOne association where the parent and child share the exact same primary key via @MapsId:
    @Entity
    public class OrderReceipt {
        @Id
        private Long id;
    
        @OneToOne(fetch = FetchType.EAGER)
        @MapsId
        @JoinColumn(name = "id")
        private Order order;
    }
    
  • In this structure, the relationship is strictly mandatory ($1:1$), existence is guaranteed, both records share identical IDs, and a Cartesian explosion is mathematically impossible. Fetching via a single INNER JOIN is safe and introduces zero N+1 overhead. In all other scenarios, LAZY remains mandatory.

🎯 Interview Cheat Sheet

30-Second Elevator Pitch

“Modern JPA architecture mandates FetchType.LAZY across all entity relationships. Static FetchType.EAGER cannot be disabled per query, triggers uncontrollable N+1 query cascades in JPQL findAll() queries, and causes Cartesian product explosions. Instead, applications should adopt dynamic fetch plans tailored to specific business operations: JOIN FETCH or @EntityGraph when loading a single aggregate for mutation, global batch loading (default_batch_fetch_size: 50) for paginated lists, and lightweight DTO / Java Record projections for high-performance read-only queries.”

Key Production Monitoring Metrics

  • Average SQL Query Execution Time per HTTP request.
  • hibernate.stat.entity_load_count: Count of loaded entity instances per transaction.
  • JVM Heap Allocation Rate during list querying and report generation.

Red Flags (DO NOT Say)

  • ❌ “I configure @ManyToOne as EAGER for dictionary tables because they only have a few rows.” (Static EAGER still fires N+1 queries when querying parents via JPQL).
  • ❌ “Eager loading is faster because it executes fewer round-trips to the database.” (In JPQL, EAGER does not use a join; it fires $N$ secondary queries, worsening latency).
  • ❌ “Choosing between Lazy and Eager is purely a developer stylistic preference.” (It is a foundational architectural decision determining database scalability and memory safety).