EFEF Core Handbook

ORTA

Loading Strategies (Veri Yükleme)

Bir entity'yi çektiğinde ilişkili verileri (navigation property'ler) ne zaman ve nasıl yükleyeceğini belirler. Yanlış tercih N+1 problemine yol açar — yüzlerce gereksiz sorgu.

Veritabanı sağlayıcısı Bu sayfadaki eşleşen örnekleri seçilen sağlayıcıya göre gösterir.

Loading Stratejileri: Karar Ağacı

Entity'yi değiştirip kaydedecek misin? EVET HAYIR Include() kullan Entity track edilir → SaveChanges çalışır Select() ile Projection En hızlı — sadece ihtiyacın olan sütunlar Birden fazla koleksiyon Include? EVET HAYIR AsSplitQuery() Her Include ayrı SQL olur Tek sorgu (varsayılan) Tek bir JOIN ile çözülür 💡 %80 kuralı: Sadece okuyup JSON dönüyorsan → Select() | Değiştirip kaydedeceksen → Include()

Eager Loading (Hevesli Yükleme)

Ana sorguyla birlikte ilişkili veriyi de çek. Include() kullanılır.

// 1️⃣ Tekil ilişki — Ürünleri kategorileriyle birlikte çek
var products = context.Products
    .Include(p => p.Category)
    .ToList();
-- 1️⃣ Include(p => p.Category) — tekil referans, her satır = 1 ürün
-- ORDER BY yok: Category reference navigation (1:1 eşleşme, gruplama gereksiz)
SELECT [p].[Id], [p].[Name], [p].[Price], [p].[CategoryId],
       [c].[Id], [c].[Name]
FROM [Products] AS [p]
INNER JOIN [Categories] AS [c] ON [p].[CategoryId] = [c].[Id];
-- 1️⃣ Include(p => p.Category) — tekil referans, her satır = 1 ürün
-- ORDER BY yok: Category reference navigation (1:1 eşleşme, gruplama gereksiz)
SELECT p.id, p.name, p.price, p.category_id,
       c.id, c.name
FROM products AS p
INNER JOIN categories AS c ON p.category_id = c.id;
// 2️⃣ İç içe ilişki (Nested Include) — Kategori → Ürünler → Taglar
var categories = context.Categories
    .Include(c => c.Products)
        .ThenInclude(p => p.Tags)
    .ToList();
-- 2️⃣ Include + ThenInclude (iki seviye JOIN)
-- ORDER BY'ı EF Core otomatik ekler (entity materialization için gerekli)
SELECT [c].[Id], [c].[Name],
       [p].[Id], [p].[Name], [p].[CategoryId],
       [t].[Id], [t].[Name], [t].[ProductId]
FROM [Categories] AS [c]
LEFT JOIN [Products] AS [p] ON [p].[CategoryId] = [c].[Id]
LEFT JOIN [Tags] AS [t] ON [t].[ProductId] = [p].[Id]
ORDER BY [c].[Id], [p].[Id];
-- 2️⃣ Include + ThenInclude (iki seviye JOIN)
-- ORDER BY'ı EF Core otomatik ekler (entity materialization için gerekli)
SELECT c.id, c.name,
       p.id, p.name, p.category_id,
       t.id, t.name, t.product_id
FROM categories AS c
LEFT JOIN products AS p ON p.category_id = c.id
LEFT JOIN tags AS t ON t.product_id = p.id
ORDER BY c.id, p.id;
// 3️⃣ Filtrelenmiş Include (EF Core 5+) — sadece aktif ürünleri çek
var categories = context.Categories
    .Include(c => c.Products.Where(p => p.IsActive))
    .ToList();
-- 3️⃣ Filtered Include — WHERE koşulu JOIN'a eklenir
SELECT [c].[Id], [c].[Name],
       [p].[Id], [p].[Name], [p].[CategoryId], [p].[IsActive]
FROM [Categories] AS [c]
LEFT JOIN [Products] AS [p] ON [p].[CategoryId] = [c].[Id] AND [p].[IsActive] = CAST(1 AS bit)
ORDER BY [c].[Id];
-- 3️⃣ Filtered Include — WHERE koşulu JOIN'a eklenir
SELECT c.id, c.name,
       p.id, p.name, p.category_id, p.is_active
FROM categories AS c
LEFT JOIN products AS p ON p.category_id = c.id AND p.is_active = TRUE
ORDER BY c.id;
// 4️⃣ Birden fazla Include — Ürün + Kategori + Tedarikçi
var products = context.Products
    .Include(p => p.Category)
    .Include(p => p.Supplier)
    .Where(p => p.Price > 100)
    .ToList();
-- 4️⃣ Çoklu Include — her biri ayrı JOIN
SELECT [p].[Id], [p].[Name], [p].[Price], [p].[CategoryId], [p].[SupplierId],
       [c].[Id], [c].[Name],
       [s].[Id], [s].[CompanyName]
FROM [Products] AS [p]
INNER JOIN [Categories] AS [c] ON [p].[CategoryId] = [c].[Id]
INNER JOIN [Suppliers] AS [s] ON [p].[SupplierId] = [s].[Id]
WHERE [p].[Price] > 100.0;
-- 4️⃣ Çoklu Include — her biri ayrı JOIN
SELECT p.id, p.name, p.price, p.category_id, p.supplier_id,
       c.id, c.name,
       s.id, s.company_name
FROM products AS p
INNER JOIN categories AS c ON p.category_id = c.id
INNER JOIN suppliers AS s ON p.supplier_id = s.id
WHERE p.price > 100.0;

Explicit Loading (Açık Yükleme)

Sonradan, ihtiyaç duyulduğunda yükle. İki ayrı sorgu üretir.

// 1️⃣ Tekil navigation (Reference)
var product = context.Products.First(p => p.Id == 1);
context.Entry(product)
       .Reference(p => p.Category)
       .Load();
-- İlk sorgu:
SELECT TOP(1) [p].[Id], [p].[Name], [p].[CategoryId]
FROM [Products] AS [p]
WHERE [p].[Id] = 1;

-- .Load() çağrıldığında (ayrı sorgu):
SELECT [c].[Id], [c].[Name]
FROM [Categories] AS [c]
WHERE [c].[Id] = @categoryId;   -- product.CategoryId değeri
-- İlk sorgu:
SELECT p.id, p.name, p.category_id
FROM products AS p
WHERE p.id = 1
LIMIT 1;

-- .Load() çağrıldığında (ayrı sorgu):
SELECT c.id, c.name
FROM categories AS c
WHERE c.id = @categoryId;
// 2️⃣ Koleksiyon navigation (Collection) — filtreleme ile
var category = context.Categories.First(c => c.Id == 1);  // 1. sorgu: Category
context.Entry(category)
       .Collection(c => c.Products)
       .Query()                              // IQueryable döner — filtrelenebilir!
       .Where(p => p.Price > 500)
       .Load();                              // 2. sorgu: filtrelenmiş Products

2 ayrı sorgu çalışır: İlk satır Category'yi, .Load() ise Products'ı getirir. Bu Explicit Loading'in tasarımı gereğidir — avantajı ikinci sorguda .Where() ile filtreleme yapabilmenizdir (Eager Loading'de tüm ilişkili veri gelir).

-- .Query().Where(...).Load()
SELECT [p].[Id], [p].[Name], [p].[Price], [p].[CategoryId]
FROM [Products] AS [p]
WHERE [p].[CategoryId] = @categoryId AND [p].[Price] > 500.0;
-- .Query().Where(...).Load()
SELECT p.id, p.name, p.price, p.category_id
FROM products AS p
WHERE p.category_id = @categoryId AND p.price > 500.0;

Lazy Loading (Tembel Yükleme)

Navigation property'ye erişildiğinde otomatik yükler. N+1 sorgu tehlikesi — production'da dikkatli kullan!

// 1. NuGet: Microsoft.EntityFrameworkCore.Proxies
// 2. DbContext'te aktifleştir:
options.UseLazyLoadingProxies();

// 3. Navigation property'ler virtual olmalı:
public class Product
{
    public int Id { get; set; }
    public virtual Category Category { get; set; }  // virtual!
}

// Kullanım — eriştiğinde otomatik SQL çalışır
var product = context.Products.First();
var categoryName = product.Category.Name;  // ← Burada SELECT çalışır!
-- product.Category.Name erişildiğinde arka planda:
SELECT [c].[Id], [c].[Name]
FROM [Categories] AS [c]
WHERE [c].[Id] = @categoryId;
-- product.Category.Name erişildiğinde arka planda:
SELECT c.id, c.name
FROM categories AS c
WHERE c.id = @categoryId;

N+1 Problemi — Lazy Loading'in tehlikesi:

var products = context.Products.ToList();      // 1 sorgu
foreach (var p in products)
    Console.WriteLine(p.Category.Name);        // N sorgu!
// Toplam: 1 + N sorgu (100 ürün = 101 sorgu!)

Çözüm: Include() kullan veya projection (Select) yap.

-- Döngüdeki her iterasyon için ayrı sorgu:
SELECT * FROM Products;                                    -- 1. sorgu
SELECT * FROM Categories WHERE Id = 1;                    -- 2. sorgu
SELECT * FROM Categories WHERE Id = 2;                    -- 3. sorgu
SELECT * FROM Categories WHERE Id = 1;                    -- 4. (aynı bile olsa!)
-- ... 100 ürün = 101 sorgu!
-- Döngüdeki her iterasyon için ayrı sorgu:
SELECT * FROM products;                                    -- 1. sorgu
SELECT * FROM categories WHERE id = 1;                    -- 2. sorgu
SELECT * FROM categories WHERE id = 2;                    -- 3. sorgu
SELECT * FROM categories WHERE id = 1;                    -- 4. (aynı bile olsa!)
-- ... 100 ürün = 101 sorgu!

Split Query (EF Core 5+)

Büyük include'larda kartezyen patlamayı önler.

var orders = context.Orders
    .Include(o => o.LineItems)
    .Include(o => o.Payments)
    .AsSplitQuery()    // Tek büyük JOIN yerine birden fazla sorgu
    .ToList();
-- AsSplitQuery: ayrı ayrı sorgular
SELECT [o].* FROM [Orders] AS [o];
SELECT [l].* FROM [OrderLineItems] AS [l] WHERE [l].[OrderId] IN (...);
SELECT [p].* FROM [Payments] AS [p] WHERE [p].[OrderId] IN (...);
-- AsSplitQuery: ayrı ayrı sorgular
SELECT o.* FROM orders AS o;
SELECT l.* FROM order_line_items AS l WHERE l.order_id = ANY(@orderIds);
SELECT p.* FROM payments AS p WHERE p.order_id = ANY(@orderIds);

IN (...) parametre limiti: EF Core, ilk sorgudan dönen tüm PK değerlerini IN listesine koyar.

  • SQL Server: max 2.100 parametre → ~2.100 order'dan fazlası varsa EF otomatik olarak geçici tablo veya alt sorgu kullanır (EF Core 8+)
  • PostgreSQL: max 65.535 parametre — daha rahat ama yine de dikkat
  • 1M kayıt gibi büyük veri setlerinde: AsSplitQuery() yerine pagination (Skip/Take) + Include veya Projection (Select) kullan
  • En güvenli yaklaşım: Filtresiz tüm tabloyu çekmek yerine WHERE ile daralt, sonra AsSplitQuery() uygula

Single Query vs Split Query — Trade-off:

Kriter Single Query (varsayılan) Split Query
Ağ roundtrip 1 N (include sayısı kadar)
Kartezyen patlama Olabilir (satır tekrarı) Olmaz
Veri tutarlılığı Tek transaction snapshot Sorgular arası veri değişebilir
Küçük veri seti Daha hızlı Gereksiz overhead
Büyük/derin include Bellek patlar Çok daha verimli
// Global olarak tüm sorguları split yapma (dikkatli kullan)
options.UseSqlServer(conn, o => o.UseQuerySplittingBehavior(
    QuerySplittingBehavior.SplitQuery));

// Tek sorgu için split'i kapat
var data = context.Orders
    .Include(o => o.Items)
    .AsSingleQuery()    // Global split açıksa, bunu tek sorgu yap
    .ToList();

Hangi stratejiyi ne zaman kullan?

Durum Strateji
İlişkili veriyi kesinlikle kullanacaksın Eager Loading (Include)
Belki kullanacaksın, koşula bağlı Explicit Loading
Web API, DTO dönüyorsun Projection (Select) — en iyi!
Büyük include kartezyen patlıyor AsSplitQuery()
Kullanma Lazy Loading (N+1 riski!)
// ✅ En iyi yol: Projection (sadece ihtiyacın olan alanları çek)
var result = context.Products
    .Where(p => p.IsActive)
    .Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        CategoryName = p.Category.Name  // EF otomatik JOIN yapar
    })
    .ToList();
-- Projection: Include yazmana gerek yok, EF navigation'ı otomatik JOIN'a çevirir
SELECT [p].[Id], [p].[Name], [c].[Name] AS [CategoryName]
FROM [Products] AS [p]
INNER JOIN [Categories] AS [c] ON [p].[CategoryId] = [c].[Id]
WHERE [p].[IsActive] = CAST(1 AS bit);
-- ✅ Sadece 3 sütun çekilir (tüm tablo değil)
-- Projection: Include yazmana gerek yok, EF navigation'ı otomatik JOIN'a çevirir
SELECT p.id, p.name, c.name AS category_name
FROM products AS p
INNER JOIN categories AS c ON p.category_id = c.id
WHERE p.is_active = TRUE;
-- ✅ Sadece 3 sütun çekilir (tüm tablo değil)

JOIN üretimi için DB'de FK constraint olması şart mı?

Hayır. EF Core SQL üretirken sadece kendi modelindeki konfigürasyona bakar (navigation property + Fluent API). DB'deki fiziksel FK constraint sorgu üretimini etkilemez.

Senaryo JOIN üretilir mi? Açıklama
DB'de FK + EF config Standart kullanım
DB'de FK + EF config Legacy DB, FK'sız çalışır
DB'de FK + EF config Navigation yoksa derlenmez
İkisinde de yok p.Category diye bir property yok

DB'deki FK constraint'in görevi: veri bütünlüğü (orphan kayıt engelleme).
EF'teki config'in görevi: sorgu üretimi (JOIN, Include, navigation).
İkisi birbirinden bağımsız çalışır.

N+1 Problemini Tespit Etme

N+1, production'da en sık karşılaşılan performans sorunudur. Tespit etmenin en kolay yolu EF Core'un log'unu açmak:

// Program.cs veya DbContext'te
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);

// Sonra Output penceresinde SQL satırlarını say:
// 1 sorgu = normal
// 101 sorgu (1 ana + 100 tekrar eden) = N+1 problemi!

Araç önerisi: MiniProfiler — her sayfanın üstünde kaç SQL çalıştığını gösterir. Geliştirme sırasında paha biçilmez.