Skip to content
Back EndJava

Eliminating the Hibernate 7 & JPA N+1 Query Problem in Spring Boot 4: Step-by-Step Tutorial

17 min readsalahtobok
Eliminating the Hibernate 7 & JPA N+1 Query Problem in Spring Boot 4

The N+1 query problem is one of the most common performance bugs in Spring Boot applications. It happens silently. Your code looks clean in your IDE, but your database suffers in production. A single user request can trigger hundreds of unexpected SQL queries in milliseconds. In this step-by-step tutorial, you will learn how to reproduce, detect, and eliminate the Hibernate 7 and JPA N+1 query problem in Spring Boot 4 and Java 25 using real PostgreSQL Testcontainers integration tests.

What Is the N+1 Query Problem?

The N+1 query problem happens when your application loads parent entities and then loads related child entities in a loop. Hibernate executes 1 initial query to fetch $N$ parent records. Then, Hibernate executes $N$ additional queries to fetch the children for each parent record.

Consider a simple example with authors and books. You fetch 100 authors from the database. Next, you iterate over the authors to display their book titles. Instead of executing 1 fast SQL query with an inner or left join, Hibernate executes 1 query for the authors and 100 separate queries for their books. That is 101 total database queries for a single web page.

Hibernate N+1 query problem execution flow and network roundtrips
Figure 1: Sequence flow illustrating how a loop over lazy collections floods the database with N secondary queries.

In local development with test data, you might not notice any delay. But in production under high traffic, the N+1 problem causes severe consequences:

  • Database Connection Pool Exhaustion: Hundreds of rapid queries starve your connection pool. Learn how to size and protect your connection pool in our HikariCP Connection Pool Tuning Guide.
  • Massive Network Latency: Each query requires a complete network round-trip between your Spring Boot application server and PostgreSQL.
  • Database CPU Spikes: The database engine parses, plans, and executes dozens of small queries instead of one optimized join query.

What You Will Build

In this hands-on tutorial, you will build a complete, runnable Spring Boot 4 project backed by PostgreSQL 16. You will create:

  • A domain model featuring Author, Book, and Publisher entities.
  • A custom Hibernate 7 StatementInspector that records and counts every SQL query during test execution.
  • An automated test suite using Testcontainers 2.x that reproduces the N+1 bug and verifies 4 distinct fixes.
  • Four production-grade solutions: JPQL JOIN FETCH, dynamic JPA @EntityGraph, Hibernate @BatchSize, and lightweight DTO projections using Java 25 records.

Prerequisites

Before you begin, ensure you have the following tools installed on your development workstation:

  • Java Development Kit (JDK) 25 (Eclipse Temurin, BellSoft Liberica, or Amazon Corretto).
  • Apache Maven 3.9+ (or use the included Maven wrapper ./mvnw).
  • Docker Desktop (or compatible Docker runtime for running Testcontainers).
  • Your favorite IDE (IntelliJ IDEA, VS Code, or Eclipse).

Step 1: Create the Project and Dependencies

Start by configuring your pom.xml file. We use Spring Boot 4.0.0, which brings Hibernate 7.1.0, Spring Data JPA 4, and managed Testcontainers 2.x support.

<?xml version="1.0" encoding="UTF-8"?>
<!-- pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.0</version>
        <relativePath/>
    </parent>

    <groupId>com.webcodein.nplusone</groupId>
    <artifactId>hibernate-7-n-plus-1-query-problem-spring-boot</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>hibernate-7-n-plus-1-query-problem-spring-boot</name>
    <description>Eliminating the Hibernate 7 and JPA N+1 Query Problem in Spring Boot 4</description>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Testing Dependencies -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-postgresql</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Step 2: Define Domain Entities

Now, let us create our domain entities. We will define an Author entity that has a one-to-many relationship with Book. By default, JPA specifications dictate that @OneToMany associations use FetchType.LAZY.

Here is the full Author.java file:

// src/main/java/com/webcodein/nplusone/domain/Author.java
package com.webcodein.nplusone.domain;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "authors")
public class Author {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    private List<Book> books = new ArrayList<>();

    public Author() {
    }

    public Author(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);
    }

    public void removeBook(Book book) {
        books.remove(book);
        book.setAuthor(null);
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public List<Book> getBooks() {
        return books;
    }

    public void setBooks(List<Book> books) {
        this.books = books;
    }
}

Next, define the Book.java entity. Notice that we set fetch = FetchType.LAZY on both @ManyToOne relations. Never leave @ManyToOne or @OneToOne as FetchType.EAGER. Eager loading causes uncontrolled outer joins or automatic N+1 queries whenever you load a book.

// src/main/java/com/webcodein/nplusone/domain/Book.java
package com.webcodein.nplusone.domain;

import jakarta.persistence.*;

@Entity
@Table(name = "books")
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, unique = true)
    private String isbn;

    @Column(nullable = false)
    private double price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "publisher_id")
    private Publisher publisher;

    public Book() {
    }

    public Book(String title, String isbn, double price) {
        this.title = title;
        this.isbn = isbn;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getIsbn() {
        return isbn;
    }

    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }

    public double getPrice() {
        return price;
    }

    public void setPrice(double price) {
        this.price = price;
    }

    public Author getAuthor() {
        return author;
    }

    public void setAuthor(Author author) {
        this.author = author;
    }

    public Publisher getPublisher() {
        return publisher;
    }

    public void setPublisher(Publisher publisher) {
        this.publisher = publisher;
    }
}

To demonstrate Hibernate’s batch fetching mechanism, we also define a Publisher.java entity annotated with @BatchSize(size = 10) on its books collection:

// src/main/java/com/webcodein/nplusone/domain/Publisher.java
package com.webcodein.nplusone.domain;

import jakarta.persistence.*;
import org.hibernate.annotations.BatchSize;

import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "publishers")
public class Publisher {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @OneToMany(mappedBy = "publisher", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @BatchSize(size = 10)
    private List<Book> books = new ArrayList<>();

    public Publisher() {
    }

    public Publisher(String name) {
        this.name = name;
    }

    public void addBook(Book book) {
        books.add(book);
        book.setPublisher(this);
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Book> getBooks() {
        return books;
    }

    public void setBooks(List<Book> books) {
        this.books = books;
    }
}

Step 3: Detecting Queries with a Custom StatementInspector

Many teams rely on log visualizers or third-party libraries to count queries. In Hibernate 7, you can implement the standard SPI interface org.hibernate.resource.jdbc.spi.StatementInspector. It intercepts and counts every SQL string before execution with zero runtime overhead.

Create SqlStatementCountInspector.java:

// src/main/java/com/webcodein/nplusone/inspector/SqlStatementCountInspector.java
package com.webcodein.nplusone.inspector;

import org.hibernate.resource.jdbc.spi.StatementInspector;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Custom Hibernate StatementInspector used to count and record executed SQL queries
 * during integration tests to detect and prevent N+1 query regressions.
 */
public class SqlStatementCountInspector implements StatementInspector {

    private static final AtomicInteger queryCount = new AtomicInteger(0);
    private static final List<String> queries = Collections.synchronizedList(new ArrayList<>());

    @Override
    public String inspect(String sql) {
        queryCount.incrementAndGet();
        queries.add(sql);
        return sql;
    }

    public static void reset() {
        queryCount.set(0);
        queries.clear();
    }

    public static int getCount() {
        return queryCount.get();
    }

    public static List<String> getQueries() {
        return new ArrayList<>(queries);
    }
}

Register the inspector in your application.yml file under the Hibernate session factory configuration:

# src/main/resources/application.yml
spring:
  application:
    name: hibernate-7-n-plus-1-query-problem-spring-boot
  datasource:
    url: jdbc:postgresql://localhost:5432/nplusonedb
    username: postgres
    password: postgres
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        session_factory:
          statement_inspector: com.webcodein.nplusone.inspector.SqlStatementCountInspector

Step 4: Implement the Repositories and DTO

To eliminate the N+1 problem, Spring Data JPA provides several query capabilities. We define them in AuthorRepository.java:

// src/main/java/com/webcodein/nplusone/repository/AuthorRepository.java
package com.webcodein.nplusone.repository;

import com.webcodein.nplusone.domain.Author;
import com.webcodein.nplusone.dto.AuthorSummaryDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

    /**
     * Fix 1: JPQL JOIN FETCH.
     * Fetches authors and their books in a single SQL query using an INNER JOIN.
     * DISTINCT avoids duplicate author roots in memory.
     */
    @Query("SELECT DISTINCT a FROM Author a JOIN FETCH a.books")
    List<Author> findAllWithBooksJoinFetch();

    /**
     * Fix 2: Dynamic @EntityGraph.
     * Declaratively tells JPA and Hibernate to eagerly load the specified association
     * for this query only, overriding the default LAZY fetching.
     */
    @EntityGraph(attributePaths = {"books"})
    @Query("SELECT a FROM Author a")
    List<Author> findAllWithBooksEntityGraph();

    /**
     * Fix 3: Direct DTO Projection using Java 25 Record.
     * Selects only required scalar columns into an immutable record.
     * Completely bypasses Hibernate entity hydration and first-level cache overhead.
     */
    @Query("SELECT new com.webcodein.nplusone.dto.AuthorSummaryDto(a.id, a.name, a.email, count(b.id)) " +
           "FROM Author a LEFT JOIN a.books b GROUP BY a.id, a.name, a.email")
    List<AuthorSummaryDto> findAllAuthorSummaries();

    /**
     * Paginated fetch using EntityGraph.
     */
    @EntityGraph(attributePaths = {"books"})
    Page<Author> findAll(Pageable pageable);
}

Next, define the immutable Java 25 record AuthorSummaryDto.java:

// src/main/java/com/webcodein/nplusone/dto/AuthorSummaryDto.java
package com.webcodein.nplusone.dto;

/**
 * Lightweight DTO Projection to fetch author summary with aggregated book counts
 * without loading full entity graphs into Hibernate's persistence context.
 */
public record AuthorSummaryDto(
    Long id,
    String name,
    String email,
    long bookCount
) {}

Create the PublisherRepository.java interface:

// src/main/java/com/webcodein/nplusone/repository/PublisherRepository.java
package com.webcodein.nplusone.repository;

import com.webcodein.nplusone.domain.Publisher;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PublisherRepository extends JpaRepository<Publisher, Long> {
}

Step 5: Implement the Service Layer

The AuthorBookService.java class coordinates our persistence calls inside read-only transactional boundaries:

// src/main/java/com/webcodein/nplusone/service/AuthorBookService.java
package com.webcodein.nplusone.service;

import com.webcodein.nplusone.domain.Author;
import com.webcodein.nplusone.domain.Publisher;
import com.webcodein.nplusone.dto.AuthorSummaryDto;
import com.webcodein.nplusone.repository.AuthorRepository;
import com.webcodein.nplusone.repository.PublisherRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
public class AuthorBookService {

    private final AuthorRepository authorRepository;
    private final PublisherRepository publisherRepository;

    public AuthorBookService(AuthorRepository authorRepository, PublisherRepository publisherRepository) {
        this.authorRepository = authorRepository;
        this.publisherRepository = publisherRepository;
    }

    /**
     * Demonstrates the classic N+1 problem:
     * 1 query to fetch all authors, followed by N queries when accessing author.getBooks().
     */
    public List<String> getAuthorsWithUnoptimizedLazyLoading() {
        List<Author> authors = authorRepository.findAll();
        return authors.stream()
                .map(author -> author.getName() + " authored " + author.getBooks().size() + " books")
                .toList();
    }

    /**
     * Fix 1: Single query with JPQL JOIN FETCH.
     */
    public List<String> getAuthorsWithJoinFetch() {
        List<Author> authors = authorRepository.findAllWithBooksJoinFetch();
        return authors.stream()
                .map(author -> author.getName() + " authored " + author.getBooks().size() + " books")
                .toList();
    }

    /**
     * Fix 2: Single query with dynamic JPA @EntityGraph.
     */
    public List<String> getAuthorsWithEntityGraph() {
        List<Author> authors = authorRepository.findAllWithBooksEntityGraph();
        return authors.stream()
                .map(author -> author.getName() + " authored " + author.getBooks().size() + " books")
                .toList();
    }

    /**
     * Fix 3: Hibernate Batch Fetching via @BatchSize.
     * Triggers 1 query to fetch all publishers, followed by 1 batched IN-clause query
     * fetching all associated books for all publishers at once.
     */
    public List<String> getPublishersWithBatchFetching() {
        List<Publisher> publishers = publisherRepository.findAll();
        return publishers.stream()
                .map(publisher -> publisher.getName() + " published " + publisher.getBooks().size() + " books")
                .toList();
    }

    /**
     * Fix 4: DTO Projections.
     * Executes a single aggregated SQL query without entity lifecycle overhead.
     */
    public List<AuthorSummaryDto> getAuthorSummaries() {
        return authorRepository.findAllAuthorSummaries();
    }
}

Deep Dive: The 4 Solutions Explained

Each solution offers distinct advantages depending on your access patterns, memory budget, and whether you are reading or mutating data.

Solution 1: JPQL JOIN FETCH

With JOIN FETCH, you instruct Hibernate to construct an SQL query with an explicit INNER JOIN or LEFT JOIN. It retrieves the parent and children records in a single database roundtrip.

Side-by-side comparison between lazy loading N+1 queries and JPQL JOIN FETCH
Figure 2: Comparing multi-query lazy loading with a single optimized JPQL JOIN FETCH round-trip.

When to use: Use JOIN FETCH when you need the fully managed entity graph to perform business logic or updates. Avoid using JOIN FETCH with paginated collections (Pageable). Joining a one-to-many relationship multiplies result set rows, causing Hibernate to issue the in-memory pagination warning: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory!.

Solution 2: Dynamic JPA @EntityGraph

JPA 2.1 introduced @EntityGraph. In Spring Data JPA, you can annotate repository query methods with @EntityGraph(attributePaths = {"books"}). Hibernate overrides the default lazy loading strategy for that specific invocation.

When to use: Use @EntityGraph when you want to use Spring Data JPA generated query methods (like findAll or findByName) without writing manual JPQL queries.

Solution 3: Hibernate Batch Fetching (@BatchSize)

When you cannot use joins (for example, when implementing database pagination with offset and limit), Batch Fetching is the ideal solution. Instead of loading children one by one, Hibernate collects parent primary keys in the current persistence context and executes an SQL query using an IN clause:

SELECT * FROM books WHERE publisher_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate batch fetching with IN-clause mechanism diagram
Figure 3: Hibernate groups uninitialized child collections into a single batched IN-clause query.

With batch fetching, loading 5 publishers results in 2 queries (1 query for publishers + 1 batched query for books) instead of 6 queries. This completely prevents connection pool starvation while allowing true database-level pagination.

Solution 4: Direct DTO Projections

For read-only operations (such as REST API endpoints and reporting dashboards), loading full JPA entities into the first-level cache is an anti-pattern. Hibernate must track entity snapshots, compute dirty checks, and manage relationships.

Architecture comparison between JPA EntityGraph and direct DTO projections
Figure 4: Entity hydration into L1 cache versus direct scalar extraction into immutable records.

By using Java 25 records with constructor expressions in JPQL, PostgreSQL extracts only the requested columns. Hibernate maps the raw JDBC result set directly into your record. This reduces heap allocations by up to 70% and achieves maximum query throughput.

Step 6: Verifying with Testcontainers Integration Tests

Now, let us verify our implementation with a comprehensive integration test. We use @Testcontainers and Spring Boot 4’s @ServiceConnection to spin up a real PostgreSQL 16 container.

Here is the complete test file NPlusOneDetectionAndFixesTest.java:

// src/test/java/com/webcodein/nplusone/NPlusOneDetectionAndFixesTest.java
package com.webcodein.nplusone;

import com.webcodein.nplusone.domain.Author;
import com.webcodein.nplusone.domain.Book;
import com.webcodein.nplusone.domain.Publisher;
import com.webcodein.nplusone.dto.AuthorSummaryDto;
import com.webcodein.nplusone.inspector.SqlStatementCountInspector;
import com.webcodein.nplusone.repository.AuthorRepository;
import com.webcodein.nplusone.repository.BookRepository;
import com.webcodein.nplusone.repository.PublisherRepository;
import com.webcodein.nplusone.service.AuthorBookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Testcontainers
class NPlusOneDetectionAndFixesTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private AuthorRepository authorRepository;

    @Autowired
    private PublisherRepository publisherRepository;

    @Autowired
    private BookRepository bookRepository;

    @Autowired
    private AuthorBookService authorBookService;

    @BeforeEach
    void setupTestData() {
        bookRepository.deleteAll();
        authorRepository.deleteAll();
        publisherRepository.deleteAll();

        // Seed 5 Authors, each with 3 Books (total 15 books)
        for (int i = 1; i <= 5; i++) {
            Author author = new Author("Author " + i, "author" + i + "@example.com");
            for (int j = 1; j <= 3; j++) {
                Book book = new Book("Author " + i + " - Book " + j, "ISBN-" + i + "-" + j, 29.99 * j);
                author.addBook(book);
            }
            authorRepository.save(author);
        }

        // Seed 3 Publishers, each with 3 Books (total 9 books)
        for (int i = 1; i <= 3; i++) {
            Publisher publisher = new Publisher("Publisher " + i);
            for (int j = 1; j <= 3; j++) {
                Book pubBook = new Book("Publisher " + i + " - Book " + j, "PUB-ISBN-" + i + "-" + j, 39.99 * j);
                publisher.addBook(pubBook);
            }
            publisherRepository.save(publisher);
        }

        SqlStatementCountInspector.reset();
    }

    @Test
    @DisplayName("Scenario 1: Reproduce N+1 query problem with unoptimized lazy iteration (1 + 5 = 6 queries)")
    void reproduceNPlusOneQueryProblem() {
        SqlStatementCountInspector.reset();

        List<String> results = authorBookService.getAuthorsWithUnoptimizedLazyLoading();

        assertThat(results).hasSize(5);

        // 1 query to fetch authors + 5 separate queries to fetch books per author
        int executedQueries = SqlStatementCountInspector.getCount();
        assertThat(executedQueries)
                .as("Unoptimized lazy loading must trigger 1 initial query + 5 secondary queries (N+1)")
                .isEqualTo(6);
    }

    @Test
    @DisplayName("Scenario 2: Fix N+1 with JPQL JOIN FETCH (exactly 1 query)")
    void fixWithJoinFetch() {
        SqlStatementCountInspector.reset();

        List<String> results = authorBookService.getAuthorsWithJoinFetch();

        assertThat(results).hasSize(5);

        // Exactly 1 SQL query with JOIN
        int executedQueries = SqlStatementCountInspector.getCount();
        assertThat(executedQueries)
                .as("JOIN FETCH must fetch all authors and books in a single SQL statement")
                .isEqualTo(1);
    }

    @Test
    @DisplayName("Scenario 3: Fix N+1 with dynamic JPA @EntityGraph (exactly 1 query)")
    void fixWithEntityGraph() {
        SqlStatementCountInspector.reset();

        List<String> results = authorBookService.getAuthorsWithEntityGraph();

        assertThat(results).hasSize(5);

        // Exactly 1 SQL query with LEFT OUTER JOIN
        int executedQueries = SqlStatementCountInspector.getCount();
        assertThat(executedQueries)
                .as("@EntityGraph must fetch authors and books in a single SQL query")
                .isEqualTo(1);
    }

    @Test
    @DisplayName("Scenario 4: Fix N+1 with Hibernate @BatchSize (1 + 1 = 2 queries instead of 1 + 3 = 4 queries)")
    void fixWithBatchFetching() {
        SqlStatementCountInspector.reset();

        List<String> results = authorBookService.getPublishersWithBatchFetching();

        assertThat(results).hasSize(3);

        // 1 query for all publishers + 1 batched IN clause query for all their books
        int executedQueries = SqlStatementCountInspector.getCount();
        assertThat(executedQueries)
                .as("@BatchSize must batch lazy collection loading into a single IN-clause query")
                .isEqualTo(2);
    }

    @Test
    @DisplayName("Scenario 5: Fix N+1 with DTO Projection record (exactly 1 query, 0 entity overhead)")
    void fixWithDtoProjection() {
        SqlStatementCountInspector.reset();

        List<AuthorSummaryDto> summaries = authorBookService.getAuthorSummaries();

        assertThat(summaries).hasSize(5);
        assertThat(summaries.get(0).bookCount()).isEqualTo(3);

        // Exactly 1 query selecting scalar columns into an immutable record
        int executedQueries = SqlStatementCountInspector.getCount();
        assertThat(executedQueries)
                .as("DTO projections must execute a single targeted aggregate query")
                .isEqualTo(1);
    }
}

Run the test suite from your terminal:

./mvnw test

The console will confirm all 5 integration tests passing:

[INFO] Running com.webcodein.nplusone.NPlusOneDetectionAndFixesTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 17.40 s
[INFO] BUILD SUCCESS

Comparative Analysis of Solutions

The table below summarizes the key trade-offs between the four strategies:

Strategy Queries Executed Memory Footprint Safe with Pagination? Ideal Use Case
Unoptimized Lazy $1 + N$ Low (per entity) Yes (but slow) Never inside loops
JPQL JOIN FETCH $1$ Medium (Cartesian product) No (in-memory warning) Domain entity updates
JPA @EntityGraph $1$ Medium No (for collections) Dynamic query methods
Hibernate @BatchSize $1 + \lceil N / \text{batch} \rceil$ Low Yes (database-level) Paginated parent-child views
DTO Projections $1$ Lowest (no L1 cache) Yes Read-heavy REST endpoints

Common Mistakes to Avoid

  • Switching FetchType to EAGER: Changing FetchType.LAZY to FetchType.EAGER does not solve N+1 queries. In JPQL, eager relationships are still fetched with secondary queries unless explicitly joined. Furthermore, eager loading degrades performance across all other queries that touch the entity.
  • Joining Multiple Collections Simultaneously: Never write a query that uses JOIN FETCH on two distinct @OneToMany collections (for example, fetching an author’s books and awards simultaneously). This causes a Cartesian Product explosion that multiplies returned database rows exponentially.
  • Ignoring In-Memory Pagination Warnings: When Hibernate issues warning HHH90003004, it fetches the entire database table into JVM heap memory and applies setFirstResult() in Java. This can easily trigger OutOfMemoryError under production data volumes. Use @BatchSize or two-step queries for paginated collections instead.

Frequently Asked Questions

Why does EAGER fetching not fix the N+1 problem?

When you use em.find(), Hibernate may use an outer join for eager relationships. However, when executing JPQL or Spring Data repository queries (such as findAll()), the JPQL parser translates your query directly to SQL without inspecting mapping annotations. Hibernate fetches the parent entities first, then detects the eager association and immediately fires secondary queries to satisfy the contract. Thus, eager loading often makes N+1 worse.

A value between 16 and 64 is generally optimal for production workloads. You can configure this globally in Spring Boot using spring.jpa.properties.hibernate.default_batch_fetch_size: 32. This automatically protects all un-joined lazy associations from triggering individual queries.

How can I prevent N+1 queries in CI pipelines?

By implementing a custom Hibernate StatementInspector as demonstrated in this tutorial, you can assert exact query counts inside your integration test suite. If an engineer introduces an unoptimized lazy loop, the test fails immediately in GitHub Actions before reaching production.

Companion Code

The complete, runnable source code for this tutorial is open source and available on GitHub. It includes the Spring Boot 4 application, domain entities, custom StatementInspector, and Testcontainers PostgreSQL integration tests:

👉 Access the Companion Code on GitHub

References

Comments