← Kursa Dön
📄 Text · 15 min

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, email değ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ı

  1. En az veri çekilir — SQL seviyesinde optimizasyon

  2. Entity yönetim maliyeti yok — Hibernate dirty checking, proxy oluşturma vs. yok

  3. Immutable — record ile doğal olarak immutable

  4. İş mantığı eklenebilir — metotlar, validasyon, formatlama

  5. 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 TipiSELECT OptimizasyonuEntity YönetimiNested DestekSpEL 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
DynamicTipe 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öntemSorgu SüresiBellekHibernate İşlem
Entity (Product)~15ms~500KBDirty check, proxy, cache
Closed Interface~8ms~50KBSadece projeksiyon
Record DTO~6ms~30KBHiç 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_name

Spring Data JPA'da Projection Seçim Rehberi

Hangi durumda hangi projection tipi kullanılmalı? Aşağıdaki karar ağacını takip edin:

  1. Sadece okuma amaçlı ve performans kritik mi?

- Evet → Record DTO (en performanslı) - Hayır → devam

  1. Basit CRUD listesi mi, karmaşık sorgu mu?

- Basit → Closed Interface (hızlı implementasyon) - Karmaşık → JPQL + DTO (tam kontrol)

  1. Hesaplanmış alanlar (computed fields) gerekli mi?

- Evet → Open Projection (@Value) veya DTO (DTO'da metot yazabilirsiniz) - Hayır → Closed Interface yeterli

  1. Aynı sorguyu farklı formatlarda mı döndürmek istiyorsunuz?

- Evet → Dynamic Projection (Class<T> parametresi) - Hayır → Spesifik tip

  1. 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:

ÖzellikMapStructJPQL 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 ile

  • Native 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