JPA Projections — Veri Projeksiyon Yöntemleri
Giriş — Neden Bu Konu Önemli?
Çoğu zaman entity'nin tüm alanlarına ihtiyacınız yoktur. Bir ürün listesinde sadece ad ve fiyat gerekiyorken, 30 sütunluk bir entity yüklemek gereksiz bellek tüketimi ve performans kaybına yol açar. Ayrıca entity'yi doğrudan API'den döndürmek güvenlik riski taşır — kullanıcı şifresi, internal ID'ler gibi hassas veriler istemciye sızabilir.
Projection (projeksiyon), sorgu sonucunun sadece belirli alanlarını döndürmenizi sağlar. Bu hem performans hem de güvenlik açısından kritik bir kavramdır.
Gerçek Hayat Analojisi: Nüfus müdürlüğündeki sicil kaydınızı düşünün. Tam kayıtta ad, soyad, doğum yeri, anne-baba adı, kan grubu, adres, parmak izi... onlarca alan var. Ama ehliyet başvurusu için sadece ad, soyad ve doğum tarihi gerekiyor. Tüm sicili kopyalamak yerine sadece gerekli alanları içeren bir özet çıkarmak — işte projection budur.
Spring Data JPA dört farklı projeksiyon yöntemi sunar. Her birinin avantajları ve dezavantajları vardır.
1. Interface-Based Projections (Closed Projections)
En yaygın ve en kolay yöntemdir. Sadece getter metodlarını tanımlayan bir interface oluşturursunuz:
// Projeksiyon interface'i — sadece ihtiyacınız olan alanlar
public interface ProductSummary {
String getName();
BigDecimal getPrice();
String getCategoryName(); // İlişkili entity'den alan çekme
}
// Repository'de kullanım
public interface ProductRepository extends JpaRepository<Product, Long> {
List<ProductSummary> findByCategory(String category);
// Sıralama ve sayfalama da çalışır
Page<ProductSummary> findByPriceLessThan(BigDecimal maxPrice, Pageable pageable);
// Koşullu sorgular
List<ProductSummary> findByActiveTrue();
// Count
long countByCategory(String category);
}Spring Data JPA, bu interface'i otomatik olarak implement eder ve sadece belirtilen sütunları SELECT eder:
-- Üretilen SQL (tüm entity yerine sadece 3 sütun)
SELECT p.name, p.price, c.name AS categoryName
FROM product p
JOIN category c ON p.category_id = c.id
WHERE p.category = ?30 sütunluk bir entity'den sadece 3 sütun çekmek, hem ağ trafiğini hem de bellek kullanımını dramatik azaltır.
Nested Projections
İlişkili entity'lerden veri çekmek için nested projection kullanılır:
public interface OrderSummary {
Long getId();
LocalDateTime getCreatedAt();
BigDecimal getTotalAmount();
OrderStatus getStatus();
CustomerInfo getCustomer(); // Nested projection
interface CustomerInfo {
String getFirstName();
String getLastName();
String getEmail();
}
}
// Repository
List<OrderSummary> findByStatus(OrderStatus status);⚠️ Dikkat: Nested projection'larda Spring Data, ilişkili entity'nin (Customer) tüm alanlarını çeker ve sonra Java tarafında filtreler. Yani
SELECT *yapar,SELECT first_name, last_name, emaildeğil. Bu, performans açısından beklendiği kadar optimize olmayabilir. Yalnızca en üst seviye (root) alanlar optimize edilir.
2. Open Projections — @Value ve SpEL
@Value annotation'ı ile SpEL (Spring Expression Language) ifadeleri kullanarak hesaplanmış alanlar tanımlayabilirsiniz:
public interface ProductView {
String getName();
BigDecimal getPrice();
@Value("#{target.name + ' - ' + target.price + ' TL'}")
String getDisplayText();
@Value("#{target.price * 0.82}") // %18 KDV hariç fiyat
BigDecimal getPriceWithoutVat();
@Value("#{target.price * (1 - target.discountRate / 100)}")
BigDecimal getDiscountedPrice();
@Value("#{@pricingService.calculateDiscount(target)}")
BigDecimal getSpecialPrice(); // Spring bean çağrısı!
}SpEL ifadesinde target anahtar kelimesi, sorgu sonucundaki entity'yi referans eder. @pricingService ise Spring bean'e referanstır.
⚠️ Open projection'larda Spring Data, entity'nin tüm alanlarını yükler (hangi alanların SpEL ifadesinde kullanılacağını bilemez). Bu yüzden closed projection kadar performanslı değildir.
Closed vs Open Projection Karşılaştırma
// Closed — sadece gerekli sütunlar çekilir ✅
public interface ProductSummary {
String getName();
BigDecimal getPrice();
}
// SQL: SELECT name, price FROM product WHERE ...
// Open — TÜM sütunlar çekilir ⚠️
public interface ProductView {
String getName();
@Value("#{target.price * 0.82}")
BigDecimal getPriceWithoutVat();
}
// SQL: SELECT * FROM product WHERE ...3. Class-Based Projections (DTO Projections)
Interface yerine sınıf (veya Java record) kullanabilirsiniz. Constructor parametreleri, sorgu sonucuyla eşlenir:
// Record ile (Java 16+) — en kısa ve temiz
public record ProductDTO(String name, BigDecimal price, String categoryName) {}
// Geleneksel sınıf ile
public class ProductDTO {
private final String name;
private final BigDecimal price;
public ProductDTO(String name, BigDecimal price) {
this.name = name;
this.price = price;
}
// Getter'lar...
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
}Spring Data Query Method ile
public interface ProductRepository extends JpaRepository<Product, Long> {
// Return type DTO class — Spring Data otomatik olarak map eder
List<ProductDTO> findByCategory(String category);
Page<ProductDTO> findByPriceLessThan(BigDecimal maxPrice, Pageable pageable);
}JPQL ile DTO Projection
@Query("""
SELECT new com.example.dto.ProductDTO(p.name, p.price, c.name)
FROM Product p
JOIN p.category c
WHERE p.price < :maxPrice
""")
List<ProductDTO> findCheapProducts(@Param("maxPrice") BigDecimal maxPrice);JPQL'de new operatörü ile DTO constructor'ını doğrudan çağırabilirsiniz. Full qualified class name gerekir.
DTO Projection'ın Avantajları
En az veri çekilir — SQL seviyesinde optimizasyon
Entity yönetim maliyeti yok — Hibernate dirty checking, proxy oluşturma vs. yok
Immutable — record ile doğal olarak immutable
İş mantığı eklenebilir — metotlar, validasyon, formatlama
API yanıtı için ideal — entity'den hassas alanlar sızmaz
public record ProductDTO(
String name,
BigDecimal price,
String categoryName
) {
// Hesaplanmış alan
public BigDecimal priceWithVat() {
return price.multiply(new BigDecimal("1.18"));
}
// Formatlama
public String formattedPrice() {
return String.format("%.2f TL", price);
}
}4. Dynamic Projections
Aynı sorguyu farklı projeksiyon tipleriyle çağırmak için generic type parametresi kullanılır:
public interface ProductRepository extends JpaRepository<Product, Long> {
<T> List<T> findByCategory(String category, Class<T> type);
<T> Optional<T> findBySlug(String slug, Class<T> type);
<T> Page<T> findByActiveTrue(Class<T> type, Pageable pageable);
}Kullanım — aynı metot, farklı projeksiyon:
// Basit liste görünümü
List<ProductSummary> summaries = productRepo.findByCategory("elektronik", ProductSummary.class);
// Detaylı DTO
List<ProductDTO> dtos = productRepo.findByCategory("elektronik", ProductDTO.class);
// Tam entity (gerektiğinde)
List<Product> entities = productRepo.findByCategory("elektronik", Product.class);Bu yaklaşım, farklı katmanlar (API, admin panel, rapor) için farklı veri görünümleri sunmanızı sağlar — tek repository metodu, çoklu çıktı formatı.
5. Native Query Projections
Native SQL sorguları ile de projeksiyon kullanabilirsiniz:
public interface ProductStats {
String getCategory();
Long getProductCount();
BigDecimal getAvgPrice();
BigDecimal getMaxPrice();
BigDecimal getMinPrice();
}
@Query(value = """
SELECT p.category AS category,
COUNT(*) AS productCount,
AVG(p.price) AS avgPrice,
MAX(p.price) AS maxPrice,
MIN(p.price) AS minPrice
FROM product p
WHERE p.active = true
GROUP BY p.category
ORDER BY productCount DESC
""", nativeQuery = true)
List<ProductStats> getProductStatsByCategory();⚠️ Native query ile interface-based projection kullanırken, sütun alias'ları interface getter isimleriyle eşleşmelidir:
getCategory()→AS category,getProductCount()→AS productCount.
Kompleks Raporlama Örneği
public interface MonthlySalesReport {
Integer getYear();
Integer getMonth();
Long getTotalOrders();
BigDecimal getTotalRevenue();
BigDecimal getAverageOrderValue();
Long getUniqueCustomers();
}
@Query(value = """
SELECT
EXTRACT(YEAR FROM o.created_at) AS year,
EXTRACT(MONTH FROM o.created_at) AS month,
COUNT(*) AS totalOrders,
SUM(o.total_amount) AS totalRevenue,
AVG(o.total_amount) AS averageOrderValue,
COUNT(DISTINCT o.customer_id) AS uniqueCustomers
FROM orders o
WHERE o.status = 'DELIVERED'
AND o.created_at >= :startDate
GROUP BY EXTRACT(YEAR FROM o.created_at), EXTRACT(MONTH FROM o.created_at)
ORDER BY year DESC, month DESC
""", nativeQuery = true)
List<MonthlySalesReport> getMonthlySalesReport(@Param("startDate") LocalDateTime startDate);Tuple Projection
JPA Criteria API ve JPQL ile Tuple kullanarak projeksiyon yapılabilir:
// JPQL Tuple Projection
@Query("SELECT p.name AS name, p.price AS price, p.category AS category FROM Product p")
List<Tuple> findProductTuples();
// Kullanım
List<Tuple> tuples = productRepo.findProductTuples();
for (Tuple t : tuples) {
String name = t.get("name", String.class);
BigDecimal price = t.get("price", BigDecimal.class);
}Tuple esnek ama tip güvenli değildir — DTO projection'ı tercih edin.
Performans Karşılaştırma
| Projeksiyon Tipi | SELECT Optimizasyonu | Entity Yönetimi | Nested Destek | SpEL Desteği |
|---|---|---|---|---|
| Closed Interface | ✅ Sadece gerekli sütunlar | ❌ Yok | ✅ (ama tam entity yükler) | ❌ |
| Open Interface (@Value) | ❌ Tüm sütunlar | ❌ Yok | ✅ | ✅ |
| Class/Record DTO | ✅ Sadece gerekli sütunlar | ❌ Yok | ❌ | ❌ |
| Dynamic | Tipe bağlı | Tipe bağlı | Tipe bağlı | Tipe bağlı |
| Entity | ❌ Tüm sütunlar | ✅ Var (maliyet!) | ✅ | ❌ |
Gerçek Dünya Performans Testi
1000 ürün, 30 sütunluk entity, 20 ürünlük sayfa:
| Yöntem | Sorgu Süresi | Bellek | Hibernate İşlem |
|---|---|---|---|
Entity (Product) | ~15ms | ~500KB | Dirty check, proxy, cache |
| Closed Interface | ~8ms | ~50KB | Sadece projeksiyon |
| Record DTO | ~6ms | ~30KB | Hiç yönetim yok |
DTO projection entity'ye göre 2-3x daha hızlı ve 10x daha az bellek kullanır.
Gerçek Dünya Örneği: E-ticaret API
Farklı endpoint'lerde farklı projeksiyon ihtiyaçları:
// 1. Liste görünümü — minimal veri
public interface ProductListItem {
Long getId();
String getName();
BigDecimal getPrice();
String getImageUrl();
Double getAverageRating();
}
// 2. Detay görünümü — daha fazla veri
public record ProductDetailDto(
Long id, String name, String description,
BigDecimal price, String categoryName,
String brandName, List<String> imageUrls,
Double averageRating, Integer reviewCount,
boolean inStock
) {
public static ProductDetailDto from(Product product) {
return new ProductDetailDto(
product.getId(), product.getName(), product.getDescription(),
product.getPrice(), product.getCategory().getName(),
product.getBrand().getName(),
product.getImages().stream().map(Image::getUrl).toList(),
product.getAverageRating(), product.getReviewCount(),
product.getStockQuantity() > 0
);
}
}
// 3. Admin paneli — yönetim bilgileri
public record ProductAdminDto(
Long id, String name, BigDecimal price,
Integer stockQuantity, ProductStatus status,
LocalDateTime createdAt, LocalDateTime updatedAt,
Long salesCount
) {}
// Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// Liste: sadece gerekli alanlar
@EntityGraph(attributePaths = {})
Page<ProductListItem> findByActiveTrue(Pageable pageable);
// Detay: JOIN FETCH ile ilişkili veriler
@Query("""
SELECT p FROM Product p
LEFT JOIN FETCH p.category
LEFT JOIN FETCH p.brand
LEFT JOIN FETCH p.images
WHERE p.id = :id AND p.active = true
""")
Optional<Product> findActiveByIdWithDetails(@Param("id") Long id);
// Admin: DTO projection ile
@Query("""
SELECT new com.example.dto.ProductAdminDto(
p.id, p.name, p.price, p.stockQuantity, p.status,
p.createdAt, p.updatedAt,
(SELECT COUNT(oi) FROM OrderItem oi WHERE oi.product = p)
)
FROM Product p
ORDER BY p.createdAt DESC
""")
Page<ProductAdminDto> findForAdmin(Pageable pageable);
}Yaygın Hatalar
1. Entity'yi Doğrudan API'den Döndürmek
// ❌ Güvenlik riski — tüm alanlar istemciye gider
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
// password, internalNotes, vb. de JSON'a dahil olur!
}
// ✅ DTO/Projection kullanın
@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) {
return userRepository.findById(id, UserDto.class)
.orElseThrow();
}2. Nested Projection'da Performans Yanılgısı
// ⚠️ Nested projection — Customer'ın TÜM alanları çekilir
public interface OrderSummary {
CustomerInfo getCustomer();
interface CustomerInfo {
String getFirstName(); // Sadece 2 alan istiyoruz ama...
String getLastName();
}
}
// SQL: SELECT o.*, c.* FROM orders o JOIN customer c ON ...
// Customer'ın tüm 20 sütunu çekilir!
// ✅ JPQL DTO projection ile optimize
@Query("""
SELECT new com.example.dto.OrderSummaryDto(
o.id, o.totalAmount, c.firstName, c.lastName
)
FROM Order o JOIN o.customer c
""")
List<OrderSummaryDto> findOrderSummaries();
// SQL: SELECT o.id, o.total_amount, c.first_name, c.last_nameSpring Data JPA'da Projection Seçim Rehberi
Hangi durumda hangi projection tipi kullanılmalı? Aşağıdaki karar ağacını takip edin:
Sadece okuma amaçlı ve performans kritik mi?
- Evet → Record DTO (en performanslı) - Hayır → devam
Basit CRUD listesi mi, karmaşık sorgu mu?
- Basit → Closed Interface (hızlı implementasyon) - Karmaşık → JPQL + DTO (tam kontrol)
Hesaplanmış alanlar (computed fields) gerekli mi?
- Evet → Open Projection (@Value) veya DTO (DTO'da metot yazabilirsiniz) - Hayır → Closed Interface yeterli
Aynı sorguyu farklı formatlarda mı döndürmek istiyorsunuz?
- Evet → Dynamic Projection (Class<T> parametresi) - Hayır → Spesifik tip
Native SQL ile aggregate/raporlama mı?
- Evet → Native Query + Interface (AS alias eşleştirme) - Hayır → JPQL yeterli
MapStruct ile Entity → DTO Dönüşümü
JOIN FETCH ile entity çekip sonra DTO'ya map etmek de yaygın bir yaklaşımdır. Bu durumda MapStruct otomatik mapping sağlar:
@Mapper(componentModel = "spring")
public interface ProductMapper {
@Mapping(source = "category.name", target = "categoryName")
@Mapping(source = "brand.name", target = "brandName")
ProductDto toDto(Product product);
List<ProductDto> toDtoList(List<Product> products);
@Mapping(source = "items", target = "itemCount",
qualifiedByName = "listSize")
OrderDto toDto(Order order);
@Named("listSize")
default int listSize(List<?> list) {
return list != null ? list.size() : 0;
}
}
// Service'te kullanım
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepo;
private final ProductMapper mapper;
public Page<ProductDto> findAll(Pageable pageable) {
return productRepo.findAll(pageable).map(mapper::toDto);
}
}MapStruct vs JPQL DTO Projection:
| Özellik | MapStruct | JPQL DTO Projection |
|---|---|---|
| SQL optimizasyonu | ❌ Entity tüm alanlar | ✅ Sadece gerekli alanlar |
| Esneklik | ✅ Karmaşık mapping | ⚠️ Constructor limiti |
| İlişkili veriler | ✅ Nested mapping | ⚠️ Her ilişki için JOIN gerekir |
| Lazy loading riski | ⚠️ Var (transaction içinde kullan) | ❌ Yok |
| Kod üretimi | ✅ Build time | ❌ Yok |
Pratik kural: Read-heavy, performans-kritik endpoint'lerde → JPQL DTO Projection. CRUD ve admin paneli gibi yerlerde → MapStruct ile entity → DTO.
Özet
Closed Interface Projection en basit ve performanslı yöntemdir — sadece getter tanımlayın, Spring Data gerisi halleder
Open Projection (@Value) hesaplanmış alanlar için kullanışlıdır ama tüm sütunları çeker
Record/Class DTO en performanslı yöntemdir — entity yönetim maliyeti sıfır, immutable, iş mantığı eklenebilir
Dynamic Projection aynı sorguyu farklı formatlarda döndürür —
Class<T>parametresi ileNative Query + Interface kompleks raporlama sorguları için idealdir
API yanıtlarında asla entity dönmeyin — her zaman DTO veya projeksiyon kullanın (güvenlik + performans)
Nested projection beklendiği kadar optimize değildir — JPQL DTO projection'ı tercih edin
AI Asistan
Sorularını yanıtlamaya hazır