Methods to Choose Between Projection, Entity Graph, and Native SQL for Read-Heavy Endpoints?
Methods to Choose Between Projection, Entity Graph, and Native SQL for Read-Heavy Endpoints?
Source: Methods to Choose Between Projection, Entity Graph, and Native SQL for Read-Heavy Endpoints?
You know the feeling: an endpoint that used to be snappy now creeps into tens or hundreds of milliseconds as traffic grows. Engineers debate “just use a projection” or “mark it read-only” or “drop a native query” — each suggestion is valid in some context and disastrous in another. This article walks through practical methods to choose between three common strategies for read-heavy endpoints: Projections (DTO/interface), Entity Graphs, and Native SQL (including JdbcTemplate), with realistic Java examples and deep coverage of performance, edge cases, and trade-offs.
1. Read-heavy endpoint constraints and goals
Before choosing a technique, quantify what “read-heavy” means for your case. Common constraints that matter:
- Latency budget — 50–200ms vs sub-10ms tail vs background batch.
- Throughput — concurrent requests per second and connection limits.
- Payload shape — flat row of scalars vs deep object graph with collections.
- Mutability — read-only projections vs managed entities that might be updated.
- Sorting/pagination — offset pagination, keyset, or streaming large results.
- Operational concerns — maintainability, portability, security, observability.
1.1 Measurement first
Always measure current behavior before changing the approach. Capture:
- SQL profiles: exact SQL text, bind values, explain plans.
- Heap and GC pressure during heavy queries.
- Database CPU/IO and index usage.
2. Short decision criteria (cheat sheet)
A compact set of rules to reach a decision quickly:
- If you need a narrow, read-only result (scalars or a few fields) and want the least mapping overhead ? prefer DTO/interface projection.
- If you need managed entities (to update later within the same transaction) or need Hibernate caching semantics ? prefer Entity Graphs or fetch joins.
- If the DB-specific optimizations, advanced window functions, or extreme performance matter ? prefer Native SQL or JdbcTemplate.
3. Projection (DTO or Interface) — pros, cons, code
Projections limit selected columns and map raw result into a lightweight object: fewer bytes transferred, much less object hydration cost than materializing managed entities. Two common Spring Data JPA flavors: Interface-based projections and constructor (DTO) projections.
3.1 Interface projection (Spring Data)
Interface projections let Spring build proxies that expose getters mapping to columns. They are simple and lazy in structure but can produce surprising extra queries if you access nested properties that require further selects.
public interface OrderSummary { Long getId(); String getCustomerName(); LocalDateTime getPlacedAt(); BigDecimal getTotal();}// Repositorypublic interface OrderRepository extends JpaRepository<Order, Long> { List<OrderSummary> findByStatus(String status);}
Explanation:
- What it does: Spring issues a SELECT for columns that match property names. If the names match DB columns via mappings, you get a projection for each row.
- Performance: Good for narrow projections; JPA maps scalars into the proxy but does not create full entities, saving allocation and change-tracking cost.
- Pitfall: If the projection method references a nested property (e.g., getCustomer().getAddress().getCity()) Spring may issue separate selects for the nested parts (N+1). Also, interface projections are tied to property names — renaming DTO getters can break mapping.
- When to use: Flat results with no nested traversal and when you want minimal mapping overhead.
3.2 Constructor (DTO) projection with JPQL
DTO constructor expressions in JPQL give explicit control and produce a single SELECT with explicit joins if you write them. They are slightly more verbose but predictable.
public class OrderDTO { public final Long id; public final String customerName; public final BigDecimal total; public OrderDTO(Long id, String customerName, BigDecimal total) { this.id = id; this.customerName = customerName; this.total = total; }}// Repository@Query("select new com.example.dto.OrderDTO(o.id, c.name, sum(i.price * i.qty)) " + "from Order o join o.customer c join o.items i " + "where o.status = :status group by o.id, c.name")List<OrderDTO> findSummariesByStatus(@Param("status") String status);
Explanation:
- What it does: JPQL creates a SELECT with joins and aggregation. Results are mapped directly to DTO via constructor.
- Performance: Predictable SQL and single-roundtrip. No entity hydration, so lower memory and no change-tracking overhead.
- Pitfall: JPQL may generate less optimal SQL than hand-tuned native SQL for complex aggregations; also constructor argument order and types must match exactly.
- When to use: When you can express the shape with JPQL, want a single roundtrip, and prefer portability across databases.
4. Entity Graphs / Fetch Joins — pros, cons, code
Entity Graphs instruct JPA to fetch certain associations eagerly for particular queries. This is useful when you want managed entities with controlled eager fetching to avoid N+1 queries while still benefiting from persistence context behavior.
4.1 Named EntityGraph on entity
@Entity@NamedEntityGraph(name = "Order.withCustomerAndItems", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode("items") })public class Order { @Id Long id; @ManyToOne Customer customer; @OneToMany List<OrderItem> items; // ...}// Use with EntityManagerEntityGraph<Order> graph = em.createEntityGraph("Order.withCustomerAndItems");Map<String,Object> hints = Collections.singletonMap("javax.persistence.fetchgraph", graph);Order order = em.find(Order.class, id, hints);
Explanation:
- What it does: Overrides default fetch plan for the operation. JPA provider (Hibernate) translates it into appropriate SQL — usually join-fetches — so associations are loaded eagerly in the same SQL or using secondary selects depending on provider and association type.
- Performance: Good to avoid N+1 while still getting managed entities. However, join-fetching collections can cause row duplication and heavier network transfer; the persistence context then collapses into correct collection objects, but you may pay CPU and memory for duplicate row processing.
- Pitfall: Using eager fetch for collections with pagination is problematic: JPA prohibits paging a query that fetches multiple collections and even when allowed you’ll get incorrect results due to duplicate root rows. Also, fetching full entities is slower than projection mapping because JPA must build entities, handle associations, and enable change tracking.
- When to use: When you need entities for subsequent updates, when you want to leverage first-level or second-level cache, or when multiple endpoints share the same entity graph.
4.2 Spring Data @EntityGraph on repository methods
public interface OrderRepository extends JpaRepository<Order, Long> { @EntityGraph(attributePaths = {"customer", "items"}) @Query("select o from Order o where o.status = :status") List<Order> findByStatusWithAssociations(@Param("status") String status);}
Explanation:
- Effect: Similar to NamedEntityGraph but defined per repository method. Spring Data wires the entity graph into the query execution.
- Trade-off: You get managed entities and avoid N+1 for specified associations, at the cost of heavier hydration and potential duplicate rows. Use distinct in the JPQL if duplicates cause client-visible duplicates, but this may cause additional processing in the DB.
5. Native SQL and JdbcTemplate — pros, cons, code
Native SQL gives you absolute control of the SQL the database executes. Use it when you need DB-specific optimizations, complex window functions, or maximal throughput with minimal mapping overhead.
5.1 Spring Data native query to scalar/DTO
@Query(value = "select o.id, c.name as customerName, sum(i.price * i.qty) as total " + "from orders o " + "join customers c on o.customer_id = c.id " + "join order_items i on i.order_id = o.id " + "where o.status = :status " + "group by o.id, c.name", nativeQuery = true)List<Object[]> findSummariesNative(@Param("status") String status);
Explanation:
- What it returns: A list of Object[] arrays representing columns; you must map them to DTOs manually (or use result set mappings).
- Performance: The database can use its features and return exactly what you need. No JPA hydration overhead.
- Pitfall: Losing portability, more manual mapping, no automatic relation resolution, and bypass of the persistence context (so you won’t get cached entities or change-tracking).
5.2 JdbcTemplate with streaming RowCallbackHandler
String sql = "..."; // tuned native SQL with LIMIT/OFFSET or cursorjdbcTemplate.query(sql, ps -> ps.setString(1, status), rs -> { OrderDTO dto = new OrderDTO( rs.getLong("id"), rs.getString("customerName"), rs.getBigDecimal("total") ); // stream to response or collect with low memory footprint});
Explanation:
- What it does: Streams rows with constant memory footprint if you process each row as it’s read. Ideal for large result sets or exporting data.
- Performance: Minimal Java-side overhead, full control over fetch size/cursor. Use PreparedStatement.setFetchSize and DB-specific cursor settings.
- Pitfall: You manage mapping and transaction scope; streaming outside transactions or with closed connections will fail. Also, careful with client timeouts and memory spike when collecting.
6. Deep performance behaviors and numbers (qualitative)
Concrete behaviors to keep in mind when benchmarking and comparing options:
- Entity hydration vs DTO: Hydrating a managed entity typically allocates many objects (entity, proxy for lazy collections, collection implementations, nested entities), does identity map checks, and registers the entity in the persistence context. That can be 3–10� CPU and memory per row compared to mapping to a DTO of a few fields.
- Network bytes and duplicate rows: When a root entity is joined to a collection, the DB returns row duplication. If root has 1 item and collection has 10 items, you get 10 rows rather than 1; client-side de-duplication cost and network transfer increases proportionally.
- Distinct and pagination: Adding DISTINCT to remove duplicates may push DB to do extra work; paginating after joins often leads to incorrect page boundaries unless you page on unique keys (keyset pagination is safer for joined queries).
- Index usage: JPQL or native SQL both rely on DB optimizer; native SQL may permit hints or rewritten forms that use indexes better. JPQL translations are sometimes suboptimal for complex queries.
7. Common edge cases and tricky pitfalls
7.1 N+1 with projections
Interface projections can silently trigger N+1 if you call nested getters that are not loaded in the initial SQL. Always inspect the executed SQL when using projections that navigate associations.
7.2 Pagination with fetched collections
Don’t fetch collections and paginate the same query. Either:
- Page on the root’s id in a separate lightweight query, then fetch associations for the selected ids (two-phase fetch), or
- Use keyset (seek) pagination on a deterministic ordering without joining collections.
7.3 Read-only hints and second-level cache
If you use EntityGraph for read-heavy endpoints and entities are not modified, mark the transaction as read-only and set Hibernate hints (e.g., setReadOnly on the Query). That avoids unnecessary dirty checking. Managed entities can be cached (second-level cache) but native queries bypass that.
8. Practical decision flow with examples
Step-by-step decision flow you can use as a checklist:
- Is the endpoint strictly read-only and only returns a few columns? ? Prototype a DTO constructor projection or interface projection and measure.
- Does the operation need to update the returned objects within the same transaction? ? Use Entity Graph or fetch join to return managed entities.
- Are you returning large result sets or streaming rows? ? Use JdbcTemplate with streaming and setFetchSize.
- Does the SQL require DB-specific functionality (window functions, lateral joins) or the optimizer needs hinting? ? Use Native SQL and map results to DTOs carefully.
- If you choose Entity Graphs, confirm pagination strategy and memory footprint on sample production-sized data.
8.1 Example scenario — read-only list endpoint for dashboard
Scenario: a dashboard needs the top 200 orders with aggregated totals and customer name — response must be low-latency. Best option: DTO constructor projection with JPQL if it produces efficient SQL, otherwise native SQL if the JPQL plan is suboptimal. Use database indices, limit, and ensure you only select necessary columns. Also consider caching layer (Redis) for extremely high QPS endpoints with mostly static data.
9. Quick code patterns and details to reuse
9.1 Two-phase fetch to combine safe pagination and eager associations
Pattern: 1) query page of root ids using lightweight projection; 2) fetch entities with EntityGraph by id list. Safe, avoids duplicate row pagination issues.
// 1) lightweight id pageList<Long> ids = orderRepository.findIdsByStatusPaged(status, pageRequest);// 2) fetch entities with graphList<Order> orders = orderRepository.findAllByIdWithGraph(ids);// Repository snippets@Query("select o.id from Order o where o.status = :status order by o.placedAt desc")List<Long> findIdsByStatusPaged(@Param("status") String status, Pageable p);@EntityGraph(attributePaths = {"customer", "items"})@Query("select o from Order o where o.id in :ids")List<Order> findAllByIdWithGraph(@Param("ids") List<Long> ids);
Explanation:
- This pattern guarantees correct pagination (page is determined by root ids) and still loads associated state in a second query optimized for IN-lists. Use a stable ordering for consistency.
- IN-list size should be bounded (page size). The second query can use jdbc batching or tuned IN semantics for large lists.
9.2 Streaming via JdbcTemplate for export endpoints
jdbcTemplate.query(connection -> { PreparedStatement ps = connection.prepareStatement( "select id, customer_name, total from orders where status = ?", ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); ps.setFetchSize(1000); // driver-specific behavior ps.setString(1, status); return ps;}, (ResultSet rs) -> { while (rs.next()) { // write row to stream response writer }});
Explanation:
- Cursor-based fetch reduces memory usage. Set appropriate fetch size and ensure transaction scope keeps connection open for streaming.
- Driver and DB behavior vary: some drivers ignore fetch size unless ResultSet.TYPE_FORWARD_ONLY is used. Test with production-like data sizes.
10. Final trade-off summary
- Projections (DTO/interface): Lowest hydration cost, predictable network payload, best for flat, read-only responses. Watch nested properties for N+1.
- Entity Graphs / Fetch joins: Good when you need managed entities, caching, or reuse of entity mappings, but higher CPU/memory and tricky with pagination.
- Native SQL / JdbcTemplate: Highest control and often the best raw performance for complex queries and streaming. Higher maintenance and fewer safety nets (no change-tracking).
10.1 Final practical checklist
- Measure current SQL and app-side costs.
- Prototype the two fastest candidates (projection vs native) for a representative load.
- Run explain plans and capture network bytes and CPU time.
- Prefer DTO projections for simple read endpoints, EntityGraphs when you need entities, native/JdbcTemplate for complex/analytic queries or streaming.
- Monitor in production and put alerts on median and tail latencies as well as DB CPU and connection pool saturation.
If you want, here are a few concise Java recipes to copy into your project and adapt. They show the minimal code for each approach and call out the most common tuning knobs: projection mapping, entity graphs, and JdbcTemplate streaming. Use them as a starting point and profile changes under realistic loads — there is no substitute for measurement.
If you have questions about a specific schema or a particular query pattern in your codebase, please comment below and I’ll help analyze and recommend the best approach.
If my articles have been valuable to you, I’d be deeply grateful for your support at here . Your encouragement fuels my passion for creating even more insightful and high-quality content!
메타데이터
- post_id
- b91e142fbb17
- slug
- methods-to-choose-between-projection-entity-graph-and-native-sql-for-read-heavy-endpoints-b91e142fbb17
- url
- https://medium.com/@tuananhbk1996/methods-to-choose-between-projection-entity-graph-and-native-sql-for-read-heavy-endpoints-b91e142fbb17
- canonical_url
- https://medium.com/@tuananhbk1996/methods-to-choose-between-projection-entity-graph-and-native-sql-for-read-heavy-endpoints-b91e142fbb17
- author_url
- https://medium.com/@tuananhbk1996
- status
- ok
- fetched_at
- 2026-06-11 05:11:55