1. When do you need projections?
If you have just learned join fetch and @EntityGraph, the natural thought is: “Well, that is it, now I will pull relations everywhere with one query and live happily ever after.” And that is almost true... until you catch yourself loading an entire entity just for a couple of fields. That is like ordering a cargo van to deliver one bun: the bun will arrive, but you did not pay for the bun — you paid for the van, the fuel, and the driver who will also ask where to unload the pallets.
Let us recall the reality of our shop-data-jpa project. We have entities Product, Category, CustomerOrder, OrderItem. Entities often have many fields, sometimes large text fields (descriptions), sometimes relations (category, order items, products inside items). When we return an entity, we return an ORM object that lives by the rules of the persistence context, can be a proxy, can lazily load relations, and is generally a “write-side model”.
But many read-use-cases in the backend are not about writing. For example, “show the product catalog table” usually requires sku, name, price, and maybe category.name. That is all. We are not going to modify these products in this scenario; we just want to render rows.
This is where projections become not just a “nice DTO”, but a real performance tool: they let you read a minimally sufficient data shape without materializing the entity graph or creating the illusion that these are “objects you can edit”.
To make the picture easier to hold in mind, here is a small diagram of how the thinking changes:
flowchart TD
A["Read-use-case: 'catalog table'"] --> B["Decide: entity or read model?"]
B -->|entity| C["join fetch / @EntityGraph (if relations are needed)"]
B -->|read model| D["Projection (read only the necessary fields)"]
D --> E["SQL selects 3-6 columns, not the whole object"]
Entity vs Projection: what do you get “in hand”?
Before writing code, it is important to agree on terminology. A Projection in Spring Data JPA is a way to get the result of a query not as an entity, but as a “view”: an interface or a DTO/record that contains only the needed fields. For a beginner, this sounds similar to “well, that is just a DTO,” but the difference is in where the result is formed and what is actually read from the database.
In our course we deliberately keep LAZY relations as a reasonable default and learn to control reads consciously. Projections fit this idea perfectly: they turn reading from “I will take the entity and figure it out later” into “I will read exactly what this use-case needs right away”.
Let us compare entity-based reading and projection-based reading at the level of feel (and consequences):
| Characteristic | Entity (Product, CustomerOrder) | Projection (ProductListView, OrderSummaryRow) |
|---|---|---|
| What it is | ORM entity, part of the write model | Read model, query result |
| Managed state | Can be managed in a transaction | No, this is not a managed entity |
| Dirty checking | Works (if the entity is managed) | Does not work, and that is good (for reading) |
| Lazy loading | Can still “kick in” | Does not “read through” relations; what you asked for is what you get |
| Data volume | Often more than needed | Usually minimally needed |
| Best scenario | Detail view + further work inside a transaction (changes, invariants) | Tabular lists, summaries, reports, lightweight reads |
The key practical takeaway: if the use-case is “read and show”, then a projection often makes the code more honest. At the repository signature level, you immediately say: “I am reading a catalog row,” not “I am reading a product as if I am about to modify it”.
2. Interface-based projections
An interface-based projection is the easiest way to start: you declare an interface with getters, and Spring Data JPA returns you an “object that implements those getters.” For a beginner, this looks like magic, but it is useful magic: it forces you to define the read model very explicitly. Another nice bonus: these projections usually fit Page and Slice very well, which makes them a good fit for lists.
Imagine the use-case: “show a product catalog page.” In the UI or outer layer (even if it is just a console check for now), we need sku, name, price. To do that, let us create a projection interface, for example in the com.example.shopdatajpa.catalog.query package.
package com.example.shopdatajpa.catalog.query;
import java.math.BigDecimal;
public interface ProductListView {
// SKU — usually the product's business identifier, shown in lists
String getSku();
// Product name for the catalog row
String getName();
// Price — read as BigDecimal so we do not run into rounding errors
BigDecimal getPrice();
}
Notice: there is no setPrice(), no getCategory() (for now), no getId() “just in case.” We are not trying to make “almost Product.” We are honestly describing what the list needs.
Now let us add a method to ProductRepository. Important: we are not creating a separate repository “for projections”; we are just adding a read method as part of the catalog feature (and if the project grows, move read methods into catalog.query, as planned in the course structure).
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductListView;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
// The repository returns a read model directly, not a Product entity
Page<ProductListView> findByStatus(ProductStatus status, Pageable pageable);
It looks too simple, and that is the beauty of it. Now the service can say: “give me a page of catalog rows,” and the repository will really return not a Product, but a ProductListView.
An example of use inside a service read flow (we keep the read boundary in the service, as we did in previous lessons):
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductListView;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
// Request the first page (0) with 3 items
PageRequest page = PageRequest.of(0, 3);
// Get catalog rows, not entities
Page<ProductListView> rows = productRepository.findByStatus(ProductStatus.ACTIVE, page);
// Use only the fields actually needed by the read scenario
rows.forEach(r -> System.out.println(r.getSku() + " -> " + r.getPrice())); // SKU-1 -> 19.99
From an engineering perspective, this matters more than it may seem. If instead you do Page<Product> and then “slice out” fields from it, you still load the entity (and potentially everything it pulls in), and then pretend the whole thing was light.
A Projection is a way to “not load the truck” and instead order delivery of exactly one package.
3. Projections and JOIN for relations
A very common situation: you want to show not only product fields in a list, but also something from a related entity. For example, in the catalog you may want to display category.name. And this is where the usual beginner dreams begin: “Well, the product has a category, I will just do product.getCategory().getName().” And then we end up in N+1 again (if category is LAZY) or in excessive loading (if somebody made it EAGER and now category is dragged everywhere).
A Projection solves this task, but an important rule for today is: a projection does not replace thinking about query shape. If you need data from a related table, there will still be a JOIN in SQL. The difference is that you make that JOIN consciously and read only the needed columns.
The cleanest approach for interface-based projections with relations is to use a nested projection. That is, ProductListWithCategoryView contains a getCategory() method that returns a small projection for the category.
package com.example.shopdatajpa.catalog.query;
import java.math.BigDecimal;
public interface ProductListWithCategoryView {
// Catalog row fields
String getSku();
String getName();
BigDecimal getPrice();
// Nested projection: here we explicitly say that we want a "piece" of the category
CategoryView getCategory();
}
And CategoryView separately (it can be nested too, but this is more compact):
package com.example.shopdatajpa.catalog.query;
public interface CategoryView {
// Category code (usually short and stable)
String getCode();
// Category name for display in the UI
String getName();
}
Now the repository method can look just as simple:
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductListWithCategoryView;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Page<ProductListWithCategoryView> findByStatus(ProductStatus status, Pageable pageable);
And usage becomes honest and safe:
ProductListWithCategoryView row = rows.getContent().getFirst();
System.out.println(row.getName()); // iPhone 17
System.out.println(row.getCategory().getName()); // Electronics
It is important to feel the idea: there is no “lazy reading later” here. If you requested category.name through a projection, Spring Data will shape the query to fetch that field. In other words, the JOIN becomes part of the SQL, not a “random object navigation after the repository call”.
If you catch yourself wondering, “is this really one query?”, that is an excellent question. In this course we keep coming back to SQL logs precisely because “nice Java code” does not guarantee “nice SQL.” Projections are no exception: if you start using overly clever computed getters or SpEL expressions in interfaces, you can accidentally go back to loading entities and making lazy trips to the database. So in our junior-friendly approach, keep this rule: an interface-based projection should be as direct as possible, without tricks, and should reflect real fields and relations.
4. DTO/record projections: select new ...
Interface-based projections are good because they are fast to write and fit simple lists very well. But sometimes you want more explicitness: you want to see right in the query which fields are selected, which joins are made, whether there are aggregates (count, sum), and what shape the result has.
This is where DTO/record projection via a JPQL constructor expression helps: select new ...
Let us take a typical scenario from ordering: “show a list of orders by status,” where the table needs orderNumber, totalAmount, and the number of items (itemsCount). We do not need the order entity with all its items; we need a summary.
Let us create a record in com.example.shopdatajpa.ordering.query:
package com.example.shopdatajpa.ordering.query;
import java.math.BigDecimal;
// Read model for an order list row (summary, without the entity graph)
public record OrderListRow(
String orderNumber, // order number for display
BigDecimal totalAmount, // total order amount
long itemsCount // number of items in the order
) {}
Now let us add a method to CustomerOrderRepository (or whatever you named the order repository). Here we consciously write @Query, because we have a count(i) aggregate and grouping.
import com.example.shopdatajpa.ordering.entity.OrderStatus;
import com.example.shopdatajpa.ordering.query.OrderListRow;
import org.springframework.data.jpa.repository.Query;
// Here we explicitly describe the SQL shape of the result: 3 fields + count aggregate
@Query("""
select new com.example.shopdatajpa.ordering.query.OrderListRow(
o.orderNumber, o.totalAmount, count(i)
)
from CustomerOrder o left join o.items i
where o.status = :status
group by o.id, o.orderNumber, o.totalAmount
""")
java.util.List<OrderListRow> findRowsByStatus(OrderStatus status);
What is important here (and useful for a beginner's brain):
You can see that the join to items is done right in the query. No “oops, why did it go to the database when I called getItems().” You can see that the result is not a CustomerOrder, but an OrderListRow. And finally, you can see that we are reading exactly three “meaning columns”: number, amount, item count. No deliveryAddress, no list of OrderItem, no Product inside each item.
As a result, such a query usually turns into a fairly simple SQL statement, similar in spirit to:
select o.order_number, o.total_amount, count(i.id)
from customer_order o
left join order_item i on i.order_id = o.id
where o.status = ?
group by o.id, o.order_number, o.total_amount;
That already looks like a grown-up person's query, not “let us load the whole object graph and then count the collection size in Java.” And yes, sometimes that very “grown-up query” ends up being simpler and faster than trying to “fix N+1 with join fetch” in a place where entities are not needed at all.
5. Projections in the service and entity
The most dangerous mistake when learning projections is to fall in love with them and start pretending that entities are no longer needed. That is roughly like falling in love with a screwdriver and trying to hammer nails with it. The nail will, of course, go in... but you will suffer very convincingly.
The right model is this: projections are a read-side tool. They are ideal for lists, summaries, reports, and lightweight reads. But as soon as the use-case implies state changes, invariants, and a unit of work, you go back to entity and transaction.
Let us look at a very everyday contrast.
Catalog reading scenario: return Page<ProductListView> or Page<ProductListWithCategoryView>. This is pure reading, and the service can be readOnly.
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.query.ProductListView;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true) // Important: read-only transaction for the "read and show" scenario
public Page<ProductListView> loadCatalog(ProductStatus status, Pageable pageable) {
// Return the projection directly from the repository (without loading the entity)
return productRepository.findByStatus(status, pageable);
}
And now a product price change scenario. Here a projection is not suitable, because we need a managed entity, dirty checking, and a proper business operation.
import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.transaction.annotation.Transactional;
@Transactional // Important: a regular transaction, because we are changing state
public void changePrice(long productId, java.math.BigDecimal newPrice) {
// Load a managed entity, otherwise dirty checking will not work
Product p = productRepository.findById(productId).orElseThrow();
p.setPrice(newPrice); // UPDATE will be issued through dirty checking
}
If you try to “update through a projection,” you either will not have a setter, or you will get the illusion that you changed the object while nothing changed in the database. And that is correct: a projection should not become a gray zone where it is unclear what will be saved and what will not.
There is another important discipline: it is better to read a projection directly from the repository than to do this: List<Product> products = repo.find... and then products.stream().map(p -> new Row(...)). Such mapping is sometimes appropriate, but it does not solve the problem of excessive reading. You have already loaded the entity (and possibly got N+1) before creating your DTO. So in this lesson we treat projection as a “read shape at the SQL level,” not as a “pretty object after the fact.”
6. Common mistakes when working with projections
Mistake #1: read an entity and then “cut out” 3 fields from it and call it a projection.
The most common trap looks like this: you return List<Product>, then do map(p -> new ProductRow(p.getSku(), p.getName(), p.getPrice())) and feel happy. Visually you got “catalog rows,” but you no longer control the SQL: you may have read extra columns, may have accidentally triggered lazy relations, and may have gotten N+1 even before mapping. A real projection is when the repository immediately returns ProductListView or ProductCatalogRow.
Mistake #2: trying to use a projection as a write model.
Sometimes you want to “save effort” and write a method like ProductListView find..., then call some setter on it (which does not exist), or pretend that “I will change the field and then save it.” A projection is not managed; it has no entity lifecycle, and it does not participate in dirty checking. If the use-case implies changes, load the entity inside a transaction and modify it as an entity.
Mistake #3: making the projection too wide — “almost an entity,” but without entity responsibility.
When the projection contains all product fields, plus category, plus details, plus stock, and also a delivery address “just in case,” you again end up with a “giant object graph,” only now without the benefits of an entity. Such projections become fragile, hard to maintain, and lose their meaning as a minimal read model. A healthy projection usually serves one use-case and looks like a “screen row,” not a “universal entity.”
Mistake #4: expecting the projection to magically remove the need to think about JOIN.
A projection does not teleport data from related tables. If you want category.name in a catalog row, that means a JOIN in the query. The beauty of projection is that the JOIN becomes conscious and controlled, not “something that happened accidentally because of object navigation after the repository.” So when you add nested fields, always ask yourself mentally: “what will this look like in SQL?”
Mistake #5: forgetting to check that your nice Java code produces the expected SQL.
Projections usually reduce the volume of data read and often reduce the number of queries, but the word “usually” is the key here. If you used clever tricks, formulated the query incorrectly, or mixed projections with lazy entity navigation, the result may be surprising. So we keep the course habit: turn on SQL logs, see how many queries were executed, and which columns are actually read. ORM loves surprises, and we love predictability.
GO TO FULL VERSION