# Spring Data JPA N+1-Oplossingen in 2026: Fetch Join en EntityGraph > Volledige gids voor het detecteren en oplossen van het N+1-probleem in Spring Data JPA. Fetch join, @EntityGraph, batch fetching en strategieën voor queryprestaties. - Published: 2026-03-17 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring data jpa, n+1 problem, fetch join, entitygraph, performance - Reading time: 12 min --- Het N+1-probleem vormt een van de meest voorkomende prestatievalkuilen in JPA. Een onschuldige query om 100 bestellingen op te halen kan 101 SQL-queries veroorzaken: één voor de bestellingen en daarna één voor elke bijbehorende klant. Deze stille vermenigvuldiging van queries verslechtert de prestaties en overbelast de database. > **Praktische Impact van N+1** > > Een endpoint dat 50 artikelen met hun auteurs retourneert kan door N+1 van 10ms naar 500ms springen. Vroege detectie voorkomt kritieke productieproblemen. ## Het N+1-Probleem in JPA Begrijpen Het N+1-probleem treedt op wanneer JPA een collectie van entiteiten laadt en vervolgens een extra query uitvoert voor elke entiteit om de bijbehorende associaties te laden. Dit gedrag komt voort uit de standaard lazy loading van `@OneToMany`- en `@ManyToMany`-relaties. Neem een klassiek model met bestellingen en klanten. Elke bestelling behoort tot een klant, en deze relatie gebruikt standaard lazy loading. ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; private LocalDateTime createdAt; // ManyToOne relationship is lazy by default since JPA 2.0 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // OneToMany relationship lazy by default @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) private List items = new ArrayList<>(); // Getters and setters omitted } ``` ```java // Customer.java @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; // Getters and setters omitted } ``` Wanneer een query bestellingen ophaalt en vervolgens de naam van de klant opvraagt, voert Hibernate voor elke bestelling een extra query uit. ```java // OrderService.java - Problematic code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // 1 query: SELECT * FROM orders List orders = orderRepository.findAll(); // For each order, accessing customer triggers a query return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), // N queries: SELECT * FROM customers WHERE id = ? order.getCustomer().getName() )) .toList(); } } ``` Voor 100 bestellingen voert deze code 101 SQL-queries uit. De Hibernate-logs onthullen dit destructieve patroon. ```sql -- Query 1: fetch orders SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o -- Queries 2-101: fetch each customer SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1 SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2 SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3 -- ... 97 more identical queries ``` ## Het N+1-Probleem Detecteren met Hibernate-Logs De eerste stap bestaat uit het inschakelen van SQL-logs om problematische queries te identificeren. De volgende configuratie toont elke door Hibernate uitgevoerde query. ```yaml # application.yml spring: jpa: show-sql: true properties: hibernate: # Format SQL for better readability format_sql: true # Display session statistics (queries, time) generate_statistics: true logging: level: # Detailed SQL query logging org.hibernate.SQL: DEBUG # Display prepared statement parameters org.hibernate.orm.jdbc.bind: TRACE ``` Hibernate-statistieken bieden een waardevolle samenvatting aan het einde van elke transactie. ``` Session Metrics { 23421 nanoseconds spent acquiring 1 JDBC connection; 0 nanoseconds spent releasing 0 JDBC connections; 1254789 nanoseconds spent preparing 101 JDBC statements; 15478963 nanoseconds spent executing 101 JDBC statements; 0 nanoseconds spent executing 0 JDBC batches; } ``` Het aantal van 101 JDBC-statements voor een eenvoudige bestellijst signaleert duidelijk een N+1-probleem. > **Uitschakelen in Productie** > > SQL-logs en statistieken hebben invloed op de prestaties. Deze opties moeten in productie uitgeschakeld blijven en gereserveerd zijn voor ontwikkel- en testomgevingen. ## Oplossing 1: Fetch Join met JPQL Fetch join laadt associaties in één enkele SQL-query via een join. Deze expliciete aanpak lost N+1 op door alle benodigde gegevens in één keer op te halen. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Explicit fetch join to load customers @Query("SELECT o FROM Order o JOIN FETCH o.customer") List findAllWithCustomer(); // Multiple fetch join for several associations @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "JOIN FETCH o.items i") List findAllWithCustomerAndItems(); // Fetch join with WHERE condition @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "WHERE o.createdAt > :since") List findRecentOrdersWithCustomer( @Param("since") LocalDateTime since ); } ``` Fetch join transformeert N+1-queries in één geoptimaliseerde query. ```sql -- Single query with join SELECT o.id, o.order_number, o.created_at, o.customer_id, c.id, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id ``` De service gebruikt nu de geoptimaliseerde methode zonder de bedrijfscode aan te passen. ```java // OrderService.java - Optimized code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // Single query with join List orders = orderRepository.findAllWithCustomer(); // No additional queries return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), order.getCustomer().getName() // Already loaded )) .toList(); } } ``` ## Oplossing 2: @EntityGraph voor Declaratieve Controle De annotatie `@EntityGraph` biedt een declaratief alternatief voor fetch join. Deze definieert welke associaties moeten worden geladen zonder aangepaste JPQL te schrijven. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Inline EntityGraph with attributePaths @EntityGraph(attributePaths = {"customer"}) List findAll(); // EntityGraph with multiple attributes @EntityGraph(attributePaths = {"customer", "items"}) List findByCreatedAtAfter(LocalDateTime since); // Named EntityGraph referencing entity definition @EntityGraph(value = "Order.withCustomerAndItems") List findByCustomerId(Long customerId); // Combination with custom query @EntityGraph(attributePaths = {"customer"}) @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%") List findByOrderNumberPrefix(@Param("prefix") String prefix); } ``` Benoemde EntityGraphs worden direct op de entiteit gedefinieerd voor hergebruik in meerdere repositories. ```java // Order.java @Entity @Table(name = "orders") @NamedEntityGraph( name = "Order.withCustomer", attributeNodes = @NamedAttributeNode("customer") ) @NamedEntityGraph( name = "Order.withCustomerAndItems", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode("items") } ) @NamedEntityGraph( name = "Order.full", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode(value = "items", subgraph = "items-product") }, subgraphs = @NamedSubgraph( name = "items-product", attributeNodes = @NamedAttributeNode("product") ) ) public class Order { // Fields unchanged } ``` De subgraph maakt het laden van geneste associaties mogelijk. Het bovenstaande voorbeeld laadt de bestellingen, hun items en het product van elk item in één query. ## Oplossing 3: Batch Fetching voor Collecties Batch fetching biedt een alternatief voor fetch join voor `@OneToMany`-collecties. In plaats van elke collectie afzonderlijk te laden, groepeert Hibernate de queries in batches. ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // Batch fetching on the collection @OneToMany(mappedBy = "order") @BatchSize(size = 25) private List items = new ArrayList<>(); } ``` Met `@BatchSize(size = 25)` laadt Hibernate items voor 25 bestellingen tegelijk in plaats van 1. Voor 100 bestellingen daalt het aantal queries van 101 naar 5. ```sql -- Without batch fetching: 100 queries SELECT * FROM order_items WHERE order_id = 1 SELECT * FROM order_items WHERE order_id = 2 -- ... 98 more queries -- With @BatchSize(size = 25): 4 queries SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25) SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50) SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75) SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100) ``` De globale configuratie van de batchgrootte geldt voor alle collecties in de applicatie. ```yaml # application.yml spring: jpa: properties: hibernate: # Global default batch size default_batch_fetch_size: 25 ``` > **Batch vs Fetch Join** > > Batch fetching past bij gevallen waarin fetch join een te groot Cartesisch product genereert. Voor een bestelling met 10 items en 5 betalingen retourneert fetch join 50 rijen. Batch fetching voert 2 afzonderlijke, efficiëntere queries uit. ## Vergelijking van Laadstrategieën Elke strategie biedt voordelen afhankelijk van de gebruikscontext. De volgende tabel vat de optimale gebruiksgevallen samen. | Strategie | Gebruikscontext | Voordelen | Nadelen | |----------|----------|------------|---------------| | **Fetch Join** | `@ManyToOne`-relaties | Enkele SQL-query | Cartesisch product met collecties | | **@EntityGraph** | Declaratief laden | Herbruikbaar, leesbaar | Minder flexibel dan JPQL | | **Batch Fetching** | `@OneToMany`-collecties | Vermijdt Cartesisch product | Meerdere queries | | **Subselect** | Zelden geraadpleegde collecties | Laadt alleen wanneer nodig | Gecorreleerde subquery | De subselect-strategie laadt de hele collectie bij de eerste toegang tot een element. ```java // Order.java @OneToMany(mappedBy = "order") @Fetch(FetchMode.SUBSELECT) private List items = new ArrayList<>(); ``` ```sql -- Generated subselect query SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?) ``` ## N+1 Vermijden met DTO-Projecties DTO-projecties bieden een radicale maar effectieve aanpak. Door alleen de noodzakelijke kolommen te selecteren, vermijden projecties volledig het laden van entiteiten en hun associaties. ```java // OrderSummaryDto.java public record OrderSummaryDto( Long orderId, String orderNumber, String customerName, String customerEmail ) {} ``` ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // DTO projection with constructor @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c") List findAllOrderSummaries(); // DTO projection with condition @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c " + "WHERE o.createdAt > :since") List findRecentOrderSummaries( @Param("since") LocalDateTime since ); } ``` Deze aanpak genereert een optimale SQL-query zonder de overhead van entity-mapping. ```sql SELECT o.id, o.order_number, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.created_at > ? ``` ## Geavanceerde Configuratie met Spring Data JPA Spring Data JPA 3.x introduceert verbeteringen voor het beheer van EntityGraph en geoptimaliseerde queries. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Dynamic EntityGraph with Specification @EntityGraph(attributePaths = {"customer"}) List findAll(Specification spec); // Pagination with EntityGraph @EntityGraph(attributePaths = {"customer"}) Page findByCustomerNameContaining( String name, Pageable pageable ); // Slice for efficient pagination @EntityGraph(attributePaths = {"customer"}) Slice findByCreatedAtBefore( LocalDateTime date, Pageable pageable ); } ``` Voorwaardelijk laden maakt het mogelijk om verschillende strategieën toe te passen afhankelijk van de context. ```java // OrderService.java @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final EntityManager entityManager; public List getOrdersWithGraph(String graphName) { // Dynamic EntityGraph retrieval EntityGraph graph = entityManager .getEntityGraph(graphName); return entityManager .createQuery("SELECT o FROM Order o", Order.class) .setHint("jakarta.persistence.loadgraph", graph) .getResultList(); } public List getOrdersForListing() { // Minimal graph for listing return getOrdersWithGraph("Order.withCustomer"); } public List getOrdersForDetail() { // Full graph for detail view return getOrdersWithGraph("Order.full"); } } ``` ## Prestatietests om N+1 te Detecteren Geautomatiseerde tests verifiëren de afwezigheid van N+1-problemen door het aantal uitgevoerde SQL-queries te tellen. ```java // OrderRepositoryPerformanceTest.java @DataJpaTest @AutoConfigureTestDatabase(replace = Replace.NONE) class OrderRepositoryPerformanceTest { @Autowired private OrderRepository orderRepository; @Autowired private EntityManager entityManager; @PersistenceContext private EntityManager em; private Statistics statistics; @BeforeEach void setUp() { // Enable Hibernate statistics Session session = entityManager.unwrap(Session.class); SessionFactory factory = session.getSessionFactory(); statistics = factory.getStatistics(); statistics.setStatisticsEnabled(true); statistics.clear(); } @Test void findAllWithCustomer_shouldExecuteSingleQuery() { // Given: 50 orders in database createTestOrders(50); entityManager.clear(); statistics.clear(); // When: fetch with fetch join List orders = orderRepository.findAllWithCustomer(); // Then: single query executed assertThat(orders).hasSize(50); assertThat(statistics.getQueryExecutionCount()) .as("Should execute only 1 query with fetch join") .isEqualTo(1); } @Test void findAll_withoutOptimization_triggersNPlus1() { // Given: 50 orders in database createTestOrders(50); entityManager.clear(); statistics.clear(); // When: fetch without optimization List orders = orderRepository.findAll(); // Access customers to trigger lazy loading orders.forEach(o -> o.getCustomer().getName()); // Then: N+1 queries (51 instead of 1) assertThat(statistics.getQueryExecutionCount()) .as("Should detect N+1 problem") .isGreaterThan(1); } private void createTestOrders(int count) { for (int i = 0; i < count; i++) { Customer customer = new Customer(); customer.setName("Customer " + i); customer.setEmail("customer" + i + "@test.com"); entityManager.persist(customer); Order order = new Order(); order.setOrderNumber("ORD-" + i); order.setCustomer(customer); order.setCreatedAt(LocalDateTime.now()); entityManager.persist(order); } entityManager.flush(); } } ``` De assertie op `getQueryExecutionCount()` zorgt ervoor dat optimalisaties behouden blijven tijdens code-evolutie. ## Conclusie Het N+1-probleem vormt een grote prestatie-uitdaging in Spring Data JPA, maar er bestaan verschillende effectieve oplossingen. Fetch join en `@EntityGraph` lossen de meeste gevallen op door associaties in één query te laden. Batch fetching biedt een alternatief voor grote collecties waar fetch join een Cartesisch product genereert. **Checklist voor N+1-Preventie:** - ✅ SQL-logs inschakelen tijdens ontwikkeling om meerdere queries te detecteren - ✅ `JOIN FETCH` gebruiken voor vaak geraadpleegde `@ManyToOne`-relaties - ✅ `@EntityGraph` toepassen voor herbruikbaar declaratief laden - ✅ `@BatchSize` configureren voor grote `@OneToMany`-collecties - ✅ DTO-projecties verkiezen voor alleen-lezen-bewerkingen - ✅ Tests schrijven die het aantal uitgevoerde queries verifiëren - ✅ SQL-logs en statistieken uitschakelen in productie - ✅ Kritieke endpoints regelmatig profileren met Hibernate-metrics --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/nl/blog/spring-boot/spring-data-jpa-n-plus-1-fetch-join-entitygraph