Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #23

EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş

Mert Özen 5 Kas 2026 10 dk 2 görüntülenme
EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş

Generic repository'nin WHERE'i nasıl sessizce belleğe taşıdığını gerçek SQL ile görüyor, DbContext'in zaten bir unit of work olduğunu tartışıyoruz. Aggregate'e özel repository, CQRS okuma tarafında projection ve aynı connection ile transaction'ı paylaşan Dapper ile kemik yapıyı tamamlıyoruz.

Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.

Önce Tahmin Et: Bu Repository Kaç Satır Getirir?

Birçok projede ilk iş olarak yazılan, "her entity için tek bir repository" vaadiyle gelen generic bir repository:

public interface IRepository<T> where T : class
{
    IEnumerable<T> GetAll();
    T? GetById(int id);
    void Add(T entity);
}

public class Repository<T> : IRepository<T> where T : class
{
    private readonly BookstoreContext _db;

    public Repository(BookstoreContext db) => _db = db;

    public IEnumerable<T> GetAll() => _db.Set<T>();
    public T? GetById(int id) => _db.Set<T>().Find(id);
    public void Add(T entity) => _db.Set<T>().Add(entity);
}

Dört kitaplık bir tabloda 10 dolardan ucuz kitapları istiyoruz:

var books = new Repository<Book>(db);
var cheap = books.GetAll().Where(b => b.Price < 10m).ToList();

Veritabanından kaç satır gelir? İki mi, dört mü? EF Core 6 ve 10'da log aynı:

SELECT "b"."Id", "b"."AuthorId", "b"."Price", "b"."Stock", "b"."Title"
FROM "Books" AS "b"

SQL'de WHERE yok. Dört satırın hepsi geldi, filtre bellekte çalıştı ve cheap.Count 2 oldu. Sebebi GetAll'ın dönüş tipi. IEnumerable<T> olduğu için arkasındaki DbSet bir IQueryable olsa bile .Where artık Enumerable.Where, yani LINQ to Objects. LINQ yazısında gördüğümüz IQueryable ve IEnumerable tuzağı, bu kez bir mimari kararın içine gömülü. Dört kitapta fark etmez; dört milyon siparişte uygulamayı durdurur.

Kemik yapının son yazısında EF Core'u kod satırlarından biraz uzaklaşıp mimari açıdan ele alıyoruz: repository ve unit of work tartışması, CQRS'in okuma tarafı ve Dapper'a ne zaman geçmek gerektiği.

DbContext Zaten Bir Unit of Work

Martin Fowler'ın tanımlarıyla başlayalım. Repository, domain nesnelerine bellekteki bir koleksiyon gibi erişmeni sağlayan bir katman. Unit of work ise bir iş süresince yapılan değişiklikleri takip edip hepsini tek seferde kaydeden yapı. Şimdi DbContext'e bak: DbSet<Book> sorgulanabilir bir koleksiyon gibi davranıyor, change tracker bütün değişiklikleri takip ediyor ve SaveChanges hepsini tek transaction'da yazıyor. Yani EF Core bu iki deseni zaten uyguluyor.

Bu yüzden DbContext'in üstüne bir IUnitOfWork arayüzü yazıp içinde sadece SaveChangesAsync'i çağırmak çoğu zaman tek katman daha eklemekten başka bir şey yapmaz. Generic repository de aynı durumda: Add, Find, Remove zaten DbSet'te var. Üstelik generic repository'nin imzası, EF Core'un en güçlü araçlarını saklıyor. Include, AsNoTracking, projection, AsSplitQuery gibi şeyleri GetAll() imzasında ifade edemezsin.

"O zaman GetAll IQueryable<T> döndürsün" dediğin anda da soyutlama delinir. Çağıran kod yine LINQ yazıyor, yine EF Core'un neyi çevirip neyi çeviremediğini bilmek zorunda, test ederken de yine bir IQueryable sağlaman gerekiyor. Repository'nin vaat ettiği izolasyon kalmıyor; sadece adı kalıyor.

Bir şoför düşün: arabanın direksiyonu, pedalları ve vites kolu var. Birisi bunların üstüne "araç kontrol arayüzü" diye ikinci bir direksiyon takıyor ve ikinci direksiyon birincisini çeviriyor. Ama vites kolu bu arayüzde yok. Sonunda herkes ya ikinci direksiyonun yanından uzanıp asıl vitese dokunuyor ya da arabayı hep birinci viteste sürüyor.

Repository Ne Zaman Anlamlı?

Bu, repository'nin hiçbir zaman işe yaramadığı anlamına gelmiyor. Generic yerine aggregate'e özel ve niyeti açık bir repository başka bir hikâye:

public interface IOrderRepository
{
    Task<Order?> GetWithItemsAsync(int id, CancellationToken ct = default);
    void Add(Order order);
}

public class OrderRepository : IOrderRepository
{
    private readonly BookstoreContext _db;

    public OrderRepository(BookstoreContext db) => _db = db;

    public Task<Order?> GetWithItemsAsync(int id, CancellationToken ct = default)
        => _db.Orders
            .Include(o => o.Items)
            .ThenInclude(i => i.Book)
            .SingleOrDefaultAsync(o => o.Id == id, ct);

    public void Add(Order order) => _db.Orders.Add(order);
}
var orders = new OrderRepository(db);
var order = await orders.GetWithItemsAsync(1);
Console.WriteLine($"order 1: {order!.Items.Count} items, first: {order.Items[0].Book.Title}");

Çıktı order 1: 2 items, first: Dune. Burada repository bir şey katıyor. Siparişin hangi parçalarla birlikte yükleneceği tek bir yerde ve bir adla duruyor. Domain kodu Include zincirini bilmiyor, sadece "kalemleriyle birlikte sipariş" istiyor. Repository IQueryable dışarı sızdırmıyor, sadece somut sonuç döndürüyor.

Repository'yi şu durumlarda yazmaya değer bul. Domain-driven design ile çalışıyorsan ve bir aggregate'in sınırını kodda görünür kılmak istiyorsan. Aynı sorgu birçok yerde tekrar ediyorsa ve onu bir ada bağlamak istiyorsan. Ya da önceki yazıda gördüğümüz gibi, iş kurallarını veritabanından tamamen bağımsız test etmek istiyorsan; EF dokümantasyonu da test double gerekiyorsa repository katmanını önerdiğini söylüyor. Kaydetme işini ise repository'ye değil, iş akışının sonuna bırak: bir request, bir context, sonunda bir SaveChangesAsync.

CQRS'in Okuma Tarafı: Doğrudan Projection

CQRS, yazma (command) ve okuma (query) taraflarını ayırmayı önerir. Yazarken aggregate'leri yükler, iş kurallarını çalıştırır, change tracker'a güvenirsin. Okurken ise çoğu zaman entity'ye bile ihtiyacın yoktur; ekrana gidecek DTO'yu doğrudan veritabanından çekmek istersin. EF Core'da okuma tarafı bu yüzden repository'ye ihtiyaç duymadan, bir query class'ının içinde projection ile yazılabilir:

public record BookSummary(int Id, string Title, string AuthorName, decimal Price, int SoldCopies);

public class GetBestSellersQuery
{
    private readonly BookstoreContext _db;

    public GetBestSellersQuery(BookstoreContext db) => _db = db;

    public Task<List<BookSummary>> ExecuteAsync(int top, CancellationToken ct = default)
        => _db.Books
            .AsNoTracking()
            .OrderByDescending(b => b.OrderItems.Sum(i => i.Quantity))
            .Take(top)
            .Select(b => new BookSummary(
                b.Id,
                b.Title,
                b.Author.Name,
                b.Price,
                b.OrderItems.Sum(i => i.Quantity)))
            .ToListAsync(ct);
}
var best = await new GetBestSellersQuery(db).ExecuteAsync(top: 3);
foreach (var s in best)
    Console.WriteLine($"{s.Title,-14} {s.AuthorName,-14} {s.SoldCopies}");
Persuasion     Jane Austen    4
Dune           Frank Herbert  3
Dune Messiah   Frank Herbert  2

EF Core 10'un ürettiği SQL:

SELECT "b0"."Id", "b0"."Title", "a"."Name", "b0"."Price", (
    SELECT COALESCE(SUM("o0"."Quantity"), 0)
    FROM "OrderItem" AS "o0"
    WHERE "b0"."Id" = "o0"."BookId")
FROM (
    SELECT "b"."Id", "b"."AuthorId", "b"."Price", "b"."Title", (
        SELECT COALESCE(SUM("o"."Quantity"), 0)
        FROM "OrderItem" AS "o"
        WHERE "b"."Id" = "o"."BookId") AS "c"
    FROM "Books" AS "b"
    ORDER BY (
        SELECT COALESCE(SUM("o"."Quantity"), 0)
        FROM "OrderItem" AS "o"
        WHERE "b"."Id" = "o"."BookId") DESC
    LIMIT @p
) AS "b0"
INNER JOIN "Authors" AS "a" ON "b0"."AuthorId" = "a"."Id"
ORDER BY "b0"."c" DESC

EF Core 6 aynı yapıyı üretiyor; farklar takma adlarda ("b0" yerine "t") ve parametre adında (@p yerine @__p_0). Sadece ekranın istediği kolonlar geliyor, entity nesnesi üretilmiyor, change tracker hiç devreye girmiyor. Performans temelleri yazısında gördüğümüz "projection ile DTO" kuralı burada bir mimari karara dönüşüyor: okuma tarafı ince, yazma tarafı zengin.

SQL'e bir de eleştirel gözle bak. Satış toplamı için aynı alt sorgu üç kez yazılmış. Bu tabloda sorun değil, ama bu tür raporlar büyüdükçe LINQ'in ürettiği SQL'i okumak ve yönlendirmek zorlaşır. Bu da bizi son soruya getiriyor.

Ne Zaman Dapper'a Geçmeli?

Dapper, SQL'i senin yazdığın, sonucu nesnelere eşleyen ince bir katman. Change tracking yok, LINQ çevirisi yok, migration yok; sadece SQL ve hızlı materialization. Dapper'ı kendi başına ayrı bir seride anlatıyoruz; burada soru şu: EF Core kullanan bir projede Dapper'a ne zaman uzanmalı?

Kendime sorduğum sorular şunlar. Sorgu LINQ ile ifade edilemiyor ya da ifade edilince okunmaz bir SQL mi çıkıyor? Window function, recursive CTE ya da veritabanına özgü bir özellik mi gerekiyor? Bu bir raporlama ekranı mı, yani sonuç hiçbir zaman entity olarak güncellenmeyecek mi? Profiler'da gerçekten bu sorgunun materialization maliyeti mi görünüyor? Bu soruların çoğuna evet diyorsan, o sorgu için Dapper doğru araç. Hepsine hayır diyorsan, EF Core'da kal. Unutma: EF Core 6'da da FromSqlRaw ve keyless entity ile raw SQL yazabiliyorsun; raw SQL yazısında bunu gördük.

İkisinin bir arada yaşaması kolay, çünkü aynı connection'ı paylaşabiliyorlar. Yazar başına en çok satan kitapları sıralayan, window function'lı bir rapor:

using System.Data.Common;
using Dapper;

DbConnection connection = db.Database.GetDbConnection();
var ranking = await connection.QueryAsync<AuthorRank>(@"
    SELECT a.Name AS AuthorName,
           b.Title,
           SUM(i.Quantity) AS Sold,
           RANK() OVER (PARTITION BY a.Id ORDER BY SUM(i.Quantity) DESC) AS RankInAuthor
    FROM OrderItem i
    JOIN Books b ON b.Id = i.BookId
    JOIN Authors a ON a.Id = b.AuthorId
    GROUP BY a.Id, a.Name, b.Id, b.Title
    ORDER BY a.Name, RankInAuthor");

public record AuthorRank(string AuthorName, string Title, long Sold, long RankInAuthor);
Frank Herbert  #1 Dune           3
Frank Herbert  #2 Dune Messiah   2
Jane Austen    #1 Persuasion     4
Jane Austen    #2 Emma           1

GetDbConnection() EF'in kullandığı connection'ı veriyor; Dapper'ı eklemek için dotnet add package Dapper yeterli. Record'daki long tipleri tesadüf değil: SQLite tam sayıları 64 bit döndürüyor ve int yazınca Dapper A parameterless default constructor or one matching signature (System.String AuthorName, System.String Title, System.Int64 Sold, System.Int64 RankInAuthor) is required for AuthorRank materialization diye itiraz ediyor. Yazma işlemlerini de aynı transaction'a bağlayabilirsin:

using Microsoft.EntityFrameworkCore.Storage;

await using (var tx = await db.Database.BeginTransactionAsync())
{
    var affected = await connection.ExecuteAsync(
        "UPDATE Books SET Stock = Stock + @amount WHERE AuthorId = @authorId",
        new { amount = 5, authorId = 1 },
        tx.GetDbTransaction());
    db.Orders.Add(new Order { CustomerEmail = "grace@example.com", OrderedAt = new DateTime(2026, 11, 3) });
    await db.SaveChangesAsync();
    await tx.CommitAsync();
    Console.WriteLine($"restocked rows: {affected}");
}

Çıktı restocked rows: 2. GetDbTransaction(), EF'in açtığı transaction'ın altındaki ADO.NET nesnesini veriyor; Dapper'ın UPDATE'i ile EF'in INSERT'i aynı transaction'da commit ediliyor. Transaction'lar yazısındaki kurallar burada da geçerli. Dikkat çekici bir detay: komut loglarını açık bıraktığım hâlde EF'in logunda sadece kendi INSERT'i göründü. Dapper'ın UPDATE'i EF'in pipeline'ından hiç geçmedi.

Sık Düşülen Tuzaklar

Birinci tuzak az önceki detayın sonucu. Dapper ile değiştirdiğin satırlardan EF'in haberi olmaz. Takip edilen bir entity varken altındaki satırı Dapper ile güncellersen, EF sana eski değeri göstermeye devam eder:

var trackedDune = await db.Books.SingleAsync(b => b.Title == "Dune");
Console.WriteLine($"before: {trackedDune.Stock}");
await connection.ExecuteAsync("UPDATE Books SET Stock = 0 WHERE Title = 'Dune'");
var again = await db.Books.SingleAsync(b => b.Title == "Dune");
Console.WriteLine($"after dapper, tracked: {again.Stock}, fresh: {await db.Books.AsNoTracking().Where(b => b.Title == "Dune").Select(b => b.Stock).SingleAsync()}");
before: 15
after dapper, tracked: 15, fresh: 0

Identity resolution yine iş başında: ikinci sorgu SQL'i gönderdi ama EF zaten takip ettiği nesneyi döndürdü. Aynı sebeple interceptor yazısındaki audit alanları ve query filter yazısındaki filtreler de Dapper sorgularına uygulanmaz. Dapper'ı ya okuma tarafında ya da EF'in o anda takip etmediği verilerde kullan; karıştırdığında ChangeTracker.Clear() ya da Reload() ile change tracker'ı tazele.

İkinci tuzak okuma tarafında. Positional bir record'a projection yaptıktan sonra onun property'sine göre sıralamak çevrilemiyor:

var bad = await db.Books
    .Select(b => new BookSummary(b.Id, b.Title, b.Author.Name, b.Price, b.OrderItems.Sum(i => i.Quantity)))
    .OrderByDescending(s => s.SoldCopies)
    .Take(3)
    .ToListAsync();

EF Core 6 ve 10'da sonuç, mesajında could not be translated geçen bir InvalidOperationException. EF, constructor'a giden argümanlarla SoldCopies property'si arasındaki bağı bilemiyor. GetBestSellersQuery'de yaptığımız gibi sıralamayı ve Take'i Select'ten önce yaz.

Üçüncü tuzak katmanlar arasında IQueryable dolaştırmak. Controller'a kadar IQueryable taşıyan bir repository'de sorgunun ne zaman ve hangi context'le çalışacağını kimse bilmez; context dispose edildikten sonra enumerate edilen bir sorgu ObjectDisposedException ile patlar. Sorguyu sınırın içinde bitir, dışarıya liste ya da DTO ver. Dördüncüsü de mimariyi dogma hâline getirmek. Küçük bir CRUD uygulamasında DbContext'i doğrudan kullanmak tamamen meşru; katman eklemeden önce o katmanın hangi sorunu çözdüğünü tek cümleyle söyleyebilmelisin.

Küçük Bir Deneme

★☆☆ Generic repository'deki GetAll'ın dönüş tipini IQueryable<T> yap ve aynı Where sorgusunun logunu tekrar al. WHERE'in SQL'e geçtiğini gör, sonra bu değişikliğin repository'nin test edilebilirliğine ne yaptığını düşün.

★★☆ GetBestSellersQuery'yi Dapper ile yeniden yaz: aynı sonucu veren SQL'i kendin yaz ve iki sürümün çıktısını karşılaştır. Hangisini okumak, hangisini değiştirmek daha kolay?

★★★ Bir PlaceOrderCommand handler'ı yaz: IOrderRepository ile siparişi oluştursun, stoğu EF Core ile düşürsün, ardından aynı transaction içinde Dapper ile bir SalesLog tablosuna satır eklesin. Bir hata fırlatıp ikisinin de geri alındığını doğrula.

Bu yazıyla kemik yapı tamamlandı. 23 yazı boyunca EF Core 6'nın API yüzeyiyle model kurmayı, ilişkileri, migration'ları, sorgulamayı, change tracking'i, transaction ve concurrency'yi, performansı, interceptor'ları ve testi gördük; yazdığımız her satır EF Core 10'da da değişmeden çalıştı. Şimdi sıra sürümlerle evrimde. Blok B'nin ilk yazısında zamanda biraz geri gidiyoruz: hâlâ EF Core 3.1 ya da 5 üzerinde çalışan bir projeyi EF Core 6'ya taşırken nelerle karşılaşacağına, 3.0'daki LINQ devrimine ve 5'in getirdiklerine bakacağız. Ardından EF Core 7, 8, 9 ve 10 ile her sürümde "EF Core 6'da böyle yapıyorduk, şimdi şöyle" diyeceğiz.