MongoDB EF Core Provider: Ne Zaman Kullanılır, Neyi Desteklemez?
MongoDB EF Core provider, CRUD ağırlıklı ve ekibin EF Core'a alıştığı yerde iyi bir seçim; aggregation, change stream, bulk write ve alan düzeyinde atomik update gereken yerde native driver gerekiyor. Gerçek komut çıktılarıyla: SaveChanges'in owned alanları her seferinde yeniden yazması ve bunun driver'ın $inc'ini ezmesi, row version, implicit transaction, Select ve Include'un 10.0.5'te çalışması, GroupBy'ın çalışmaması.
Sürüm notu: Bu yazıdaki kodlar .NET 10, MongoDB.EntityFrameworkCore 10.0.5 (EF Core 10.0.11 ile) ve MongoDB.Driver 3.14 ile derlenip MongoDB 9.0.8 Community Server üzerinde çalıştırılmıştır. Aynı komutlar MongoDB 8.0.38'de de birebir aynı komutları ve çıktıyı üretti; bu yazıdaki davranışların hepsi provider tarafında.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) yönetim ekranını EF Core (Entity Framework Core) ile yazdığını düşün; sipariş tarafı ise 3. ve 10. yazılardaki gibi native driver ve koşullu $inc ile çalışıyor. Bir editör Dune'un sayfasını açıyor. O sırada iki adet Dune satılıyor. Editör yalnız fiyatı değiştirip kaydediyor:
// Bir editör Dune'u yönetim ekranında açar (EF Core)
Book dune = await db.Books.SingleAsync(b => b.Id == 26);
// Bu arada driver üzerinden bir sipariş gelir (3. yazı): stokta koşullu $inc
UpdateDefinition<BsonDocument> sell = Builders<BsonDocument>.Update.Inc("stock.onHand", -2);
await rawBooks.UpdateOneAsync(new BsonDocument { { "_id", 26 }, { "stock.onHand", new BsonDocument("$gte", 2) } }, sell);
// Editör yalnız fiyatı değiştirip kaydeder
dune.ListPrice = 399.90m;
await db.SaveChangesAsync();
Stok 51'di, 2 satıldı, editör stoğa hiç dokunmadı. Veritabanında stok kaç?
editor loads Dune: price 390.50, stock 51, version 0
order sells 2 copies
editor saved the new price
in the database: price 399.90, stock 51, version 1
51. Satılan iki kitap stoktan sessizce geri geldi; hata yok, exception yok. EF Core yalnız değişen property'yi yazar diye beklersin, ama MongoDB provider'ı SaveChanges sırasında değişen alanların yanında entity'nin bütün owned (entity'nin içine gömülü, kendi başına kaydı olmayan) parçalarını da gönderiyor: stock alt document'i editörün yüklediği hâliyle, 51 olarak yeniden yazıldı. Bu yazıda MongoDB'nin Entity Framework Core provider'ını bu türden ayrıntılarla ele alıyoruz: kurulum ve mapping, SaveChanges'in sunucuya gönderdikleri, optimistic concurrency, LINQ'in (C#'ta sorguyu dilin içinde yazmayı sağlayan özellik) nereye kadar çevrildiği, resmî sınırlar ve native driver ile EF Core arasındaki karar.
Komutlar 9. yazıdaki konsol uygulamasında: ef-concurrency, ef-save ve ef-query. Önce 02-load.sh, 06-bookstore-indexes.js ve bu yazının 11-version-field.js script'i çalışmış olmalı; nedenini aşağıda göreceğiz.
UseMongoDB, DbContext ve Mapping
Provider MongoDB.EntityFrameworkCore paketi. 10.0.x hattı EF Core 10 ve .NET 10 için; aynı gün yayımlanan 9.1.5 ve 8.4.5 sürümlerinin gösterdiği gibi 9.x ve 8.x hatları da EF Core 9 ve 8 için bakımda. Proje README'sine göre MongoDB 5.0 ve sonrasını destekliyor ve transaction destekleyen bir kurulum istiyor; bizim tek node'lu replica set'imiz bunu karşılıyor. Kurulum iki parça: driver'daki singleton IMongoClient aynen kalıyor, DbContext onun üstüne scoped olarak ekleniyor.
public static class BookstoreEfCore
{
// DbContext scoped (istek başına bir change tracker); alttaki MongoClient singleton kalır
public static IServiceCollection AddBookstoreEfCore(this IServiceCollection services) =>
services.AddDbContext<BookstoreDbContext>((sp, options) =>
options.UseMongoDB(sp.GetRequiredService<IMongoClient>(), "bookstore"));
}
public sealed class BookstoreDbContext(DbContextOptions<BookstoreDbContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<Review> Reviews => Set<Review>();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
// Document'lerimizde alan adları camelCase (2. yazı); bu olmadan EF Core "ListPrice" yazar
configurationBuilder.Conventions.Add(_ => new CamelCaseElementNameConvention());
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>(book =>
{
book.ToCollection("books");
book.OwnsMany(b => b.Authors);
book.OwnsOne(b => b.Category);
book.OwnsOne(b => b.Stock);
// Başka bir collection'a navigation: Include bir $lookup'a dönüşür (provider 10.0.3+)
book.HasMany(b => b.Reviews).WithOne().HasForeignKey(r => r.BookId);
});
modelBuilder.Entity<Order>(order =>
{
order.ToCollection("orders");
order.OwnsOne(o => o.Customer);
order.OwnsMany(o => o.Items);
order.OwnsMany(o => o.Payments);
order.Property(o => o.Status).HasConversion<string>();
});
}
}
Mapping'in mantığı 4. yazıdaki modelle aynı: gömülü her şey (authors, category, stock, siparişteki items) owned type, ayrı collection'daki her şey ayrı entity. 9. yazıdaki driver convention'ları EF Core'u etkilemiyor; provider kendi model'ini kuruyor, bu yüzden camelCase alan adları için provider'ın kendi CamelCaseElementNameConvention'ı gerekiyor. Id property'si kendiliğinden _id'ye eşleniyor. Enum satırı bir tuzağın çaresi, "Sık Düşülen Tuzaklar"da.
Entity'ler 9. yazıdaki record'lar değil, ayrı ve değiştirilebilir class'lar. Change tracking, yüklediği entity'nin property'lerini sonradan değiştirmeni ve SaveChanges'te farkı bulmayı bekliyor; init'li bir record'da dune.ListPrice = 399.90m yazamazsın, with ise EF Core'un takip etmediği yeni bir instance üretir.
// EF Core entity'leri değiştirilebilir class'lar: change tracking property değerlerini karşılaştırır, init'li record'lar yerinde değiştirilemez
public sealed class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
public List<AuthorRef> Authors { get; set; } = [];
public CategoryRef? Category { get; set; }
public decimal ListPrice { get; set; }
public Stock? Stock { get; set; }
public List<Review> Reviews { get; set; } = [];
// Row version: EF Core onu update filtresine ekler ve her kayıtta bir artırır
[Timestamp]
public long Version { get; set; }
}
(Isbn, Language gibi diğer property'ler yer kazanmak için kısaltıldı.) Version alanını ekleyip ef-concurrency'yi 11-version-field.js'ten önce çalıştırdığımda ilk satırda şu hata geldi:
Unhandled exception. System.InvalidOperationException: Document element is missing for required non-nullable property 'Version'.
Driver eksik bir alanı property'nin varsayılan değeriyle geçiyordu; provider ise nullable olmayan bir property için alanın document'te bulunmasını şart koşuyor. EF Core'da modele alan eklemenin bilinen yolu migration, ama MongoDB provider'ında migration yok. Mevcut document'leri sen güncelliyorsun; 5. yazıdaki schema versioning yaklaşımının küçük bir örneği:
// 11 - EF Core row version: mevcut kitaplara version 0 yazılır (MongoDB için EF Core migration yok)
const result = db.books.updateMany(
{ version: { $exists: false } },
{ $set: { version: NumberLong("0") } }
);
print("books updated:", result.modifiedCount);
SaveChanges Sunucuya Ne Gönderiyor?
Lab'daki MongoClient'a bir CommandStartedEvent aboneliği ekledim (9. yazıdaki command event'leri); ef-save komutu provider'ın gönderdiği her update, insert ve commitTransaction komutunu yazdırıyor. Önce yalnız fiyat değişiyor, sonra aynı SaveChanges içinde bir yorum ekleniyor ve fiyat geri alınıyor:
loaded Dune: price 390.50, stock 51, version 0
change only the price:
> { "update" : "books", "ordered" : true, "updates" : [{ "q" : { "_id" : 26, "version" : 0 }, "u" : { "$set" : { "_id" : 26, "listPrice" : { "$numberDecimal" : "399.90" }, "version" : 1, "authors" : [{ "authorId" : 18, "name" : "Frank Herbert" }], "category" : { "categoryId" : 3, "name" : "Science Fiction" }, "stock" : { "onHand" : 51, "reorderLevel" : 5, "updatedAt" : { "$date" : "2026-06-14T17:53:00Z" } } } } }] }
a new review and the price back, in one SaveChanges:
> { "insert" : "reviews", "ordered" : true, "startTransaction" : true, "autocommit" : false, "documents" : [{ "_id" : { "$oid" : "6af86c965e79f63dee3bc706" }, "body" : null, "bookId" : 26, "rating" : 5, "reviewedAt" : { "$date" : "2026-11-14T14:13:42.782Z" }, "customer" : { "customerId" : 3, "name" : "Grace Hopper" } }] }
> { "update" : "books", "ordered" : true, "autocommit" : false, "updates" : [{ "q" : { "_id" : 26, "version" : 1 }, "u" : { "$set" : { "_id" : 26, "listPrice" : { "$numberDecimal" : "390.50" }, "version" : 2, "authors" : [{ "authorId" : 18, "name" : "Frank Herbert" }], "category" : { "categoryId" : 3, "name" : "Science Fiction" }, "stock" : { "onHand" : 51, "reorderLevel" : 5, "updatedAt" : { "$date" : "2026-06-14T17:53:00Z" } } } } }] }
> { "commitTransaction" : 1, "autocommit" : false }
İlk update "Önce Tahmin Et"in açıklaması. $set içinde değişen tek scalar alan listPrice var; title, isbn gibi değişmeyen scalar'lar gitmiyor. Ama authors, category ve stock owned parçaları, değişmemiş olsalar da, bütünüyle yeniden yazılıyor. Provider dokümanı iç içe owned entity'lerde bir property değişince root document'in yeniden yazıldığını söylüyor; gözlenen davranış bundan biraz dar ama sonuç aynı: owned parçalar her kayıtta yüklendiği andaki değerleriyle sunucuya gidiyor. Driver'la yazılan bir $inc ya da $push arada gelmişse eziliyor. 4. yazıda gömmenin bir kazancı alan düzeyinde atomik update'ti; EF Core üzerinden bu kazanç kalmıyor.
İkinci kayıtta iki root entity (bir yorum ve bir kitap) değişti ve provider ikisini startTransaction ile başlayan, commitTransaction ile biten bir transaction'a aldı. Bu implicit transaction; Database.AutoTransactionBehavior varsayılan WhenNeeded değeriyle birden çok root entity etkilendiğinde devreye giriyor, Always ile her kayıtta, Never ile hiç. Standalone bir sunucuda transaction açılamadığı için dokümana göre provider NotSupportedException fırlatıyor; 2. yazıda lab'ı replica set kurmamızın bir faydası daha. 10. yazıdan önemli bir fark: WithTransactionAsync TransientTransactionError etiketli hatada gövdeyi yeniden çalıştırıyordu, EF Core provider'ı ise dokümana göre bu hatada retry yapmıyor. Write conflict'in sık olduğu yerde SaveChanges'i kendi retry'ınla sarman gerekiyor. Ayrıntı: yorumdaki body alanı yazılmamak yerine null olarak yazıldı; 3. yazıda gördüğümüz "alan yok" ile "alan null" farkı, sorgularında bunu hesaba katmayı gerektiriyor.
Optimistic Concurrency: Row Version
Yukarıdaki filtrede "version" : 0 ve $set içinde "version" : 1 var: [Timestamp] ile işaretlenen long property row version oluyor. Provider onu update filtresine ekliyor ve her kayıtta bir artırıyor; filtre hiçbir document'i bulamazsa DbUpdateConcurrencyException fırlatıyor. Peki tahmin turunda neden yakalamadı? Çünkü driver tarafındaki $inc version'a dokunmadı; EF Core'un gözünde document değişmemişti. ef-concurrency'nin ikinci turunda sipariş tarafı da kurala uyuyor:
UpdateDefinition<BsonDocument> sell = Builders<BsonDocument>.Update.Inc("stock.onHand", -2);
if (bumpVersion)
{
sell = sell.Inc("version", 1L); // driver tarafı da row version kuralına uyar
}
editor loads Dune: price 390.50, stock 51, version 0
order sells 2 copies and increments version
DbUpdateConcurrencyException: Conflicts were detected when performing updates to 'bookstore.books'. Did not perform 1 modifications, 0 insertions, and 0 deletions.
in the database: price 390.50, stock 49, version 1
Bu sefer editörün kaydı reddedildi ve stok 49'da kaldı. Uygulamanın işi exception'ı yakalayıp entity'yi yeniden yüklemek ve kullanıcıya "bu kayıt siz düzenlerken değişti" demek. Dokümanın sınırlamalar notu bu yazının özeti gibi: row version ancak aynı document'e yazan bütün uygulamalar onu artırırsa çalışıyor. Aynı açık EF Core'un kendi içinde de var: provider'ın README'sine göre ExecuteUpdate change tracker'ı atlıyor ve concurrency token'larını kontrol etmiyor; aşağıdaki çıktıda da onun $set'inde version yok. Tek bir alanı korumak istiyorsan row version yerine [ConcurrencyCheck] ile o alanı concurrency token yapmak da mümkün; o zaman filtreye yalnız o alanın eski değeri giriyor. Provider 10.0.3'ten beri owned entity üzerindeki concurrency token'larını reddediyor, yani Stock.OnHand'i doğrudan koruyamazsın.
LINQ: Nereye Kadar Çevriliyor?
ef-query beş sorgu çalıştırıyor. Provider LINQ'i driver'ın LINQ3 (driver 3.0 ile tek seçenek kalan LINQ çeviricisi) çeviricisi üzerinden bir aggregation pipeline'a çeviriyor:
List<Book> scienceFiction = await db.Books
.Where(b => b.Category!.Name == "Science Fiction" && b.ListPrice < 500m)
.OrderByDescending(b => b.ListPrice)
.Take(3)
.ToListAsync();
var prices = await db.Books.Where(b => b.Id == 26).Select(b => new { b.Title, b.ListPrice }).ToListAsync();
Book dune = await db.Books.Include(b => b.Reviews).SingleAsync(b => b.Id == 26);
var byStatus = await db.Orders.GroupBy(o => o.Status).Select(g => new { Status = g.Key, Count = g.Count() }).ToListAsync();
int updated = await db.Books.Where(b => b.Id == 26).ExecuteUpdateAsync(s => s.SetProperty(b => b.ListPrice, 390.50m));
Where + OrderBy + Take:
> { "aggregate" : "books", "pipeline" : [{ "$match" : { "category.name" : "Science Fiction", "listPrice" : { "$lt" : { "$numberDecimal" : "500" } } } }, { "$sort" : { "listPrice" : -1 } }, { "$limit" : 3 }] }
Neuromancer 480.50, The War of the Worlds 450.50, Dune 390.50
Select into an anonymous type:
> { "aggregate" : "books", "pipeline" : [{ "$match" : { "_id" : 26 } }, { "$project" : { "Title" : "$title", "ListPrice" : "$listPrice", "_id" : 0 } }] }
{ Title = Dune, ListPrice = 390.50 }
Include across collections:
> { "aggregate" : "books", "pipeline" : [{ "$match" : { "_id" : 26 } }, { "$lookup" : { "from" : "reviews", "localField" : "_id", "foreignField" : "bookId", "as" : "_lookup_Reviews" } }, { "$limit" : 2 }] }
Dune: 5 reviews
GroupBy:
InvalidOperationException: The LINQ expression 'DbSet<Order>() ...
ExecuteUpdate:
> { "update" : "books", "ordered" : true, "updates" : [{ "q" : { "_id" : 26 }, "u" : { "$set" : { "listPrice" : { "$numberDecimal" : "390.50" } } }, "multi" : true }] }
1 book(s) updated
İlk sorgu 9. yazıdaki LINQ sorgusuyla aynı pipeline'ı üretti ve aynı üç kitabı getirdi. İkinci ve üçüncü satır sürprizli: provider'ın "Feature Limitations" sayfası Select ile anonymous tipe projection'ın desteklenmediğini yazıyor, ama 10.0.5'te sorgu sunucuda bir $project olarak çalıştı. Release notes bunu açıklıyor: 10.0.2 Select() projection'larını genişletti, 10.0.3 de collection'lar arası Include ve navigation desteği ile ExecuteUpdate/ExecuteDelete'i getirdi. Include bir $lookup oldu; 7. yazıdaki kural geçerli, reviews.bookId üzerinde index olmalı (6. yazının bookId_1_reviewedAt_-1 index'i bunu karşılıyor). $limit: 2 ise SingleAsync'in ikinci bir sonuç olup olmadığını kontrol etme yolu. GroupBy çevrilemedi ve EF Core'un bilinen "could not be translated" hatası geldi. Ders: bu provider hızlı değişiyor ve dokümanın sınırlamalar sayfası release notes'un gerisinde kalabiliyor; kullandığın sürümde sorgunun çevrilip çevrilmediğini bu yazıdaki gibi komut log'uyla kendin gör.
Resmî Sınırlar
Dokümanın listesi iki gruba ayrılıyor. EF Core tarafında desteklenmeyenler: migration, var olan veritabanından model üretmek (database-first), foreign key, table splitting, temporal table ve spatial veri. Bunların çoğu ilişkisel veritabanı kavramı; document modelinde karşılıkları ya yok ya da anlamsız. Foreign key yok demek, yukarıdaki HasForeignKey'in sunucuda hiçbir kısıt kurmadığı demek: var olmayan bir kitaba yorum yazmayı veritabanı engellemiyor, Include sadece okurken eşleştiriyor. MongoDB tarafında desteklenmeyenler: time series collection'lar ve Atlas Search. Bunlara bugünkü çıktılardan ikisini ekliyorum: GroupBy ve genel olarak aggregation, owned alanlarda alan düzeyinde update.
Release notes'ta bir uyarı daha var: sürüm numaraları EF Core'la hizalı olduğu için provider breaking change'leri yalnız major sürümlerde yapmıyor, minor sürümlerde de yapabiliyor. 8.3.0'ın nullable alanlardaki BsonRepresentation düzeltmesi buna bir örnek. Paket güncellemesini bir patch güncellemesi gibi değil, sürüm notunu okuyup testleri çalıştırarak yap.
Karar: Native Driver mı, EF Core mu?
Kararı işin türüne göre veriyorum. Bir uçta CRUD (kayıt ekleme, okuma, güncelleme ve silme) ağırlıklı ekranlar, diğer uçta alan düzeyinde atomik update ve pipeline isteyen işler var:
| İhtiyaç | Önerim | Neden |
|---|---|---|
| Yönetim ekranı, form üzerinden CRUD | EF Core provider | Change tracking, DbContext ve ekibin bildiği API |
Stok düşürme, sayaç, $push ile array'e ekleme | Native driver | Alan düzeyinde atomik update; EF Core owned parçaları yeniden yazar |
Rapor, $group, $setWindowFields, $merge | Native driver | GroupBy çevrilmiyor; pipeline'ın tamamı driver'da |
| Change stream, bulk write, retry'lı transaction | Native driver | Provider bunları sunmuyor ya da retry yapmıyor |
| Time series, Atlas Search | Native driver | Provider'ın resmî sınırı |
| İki dünya aynı collection'a yazıyor | İkisi, kurallı | Row version'ı iki taraf da artırmalı |
İkisini aynı projede kullanmak doğal: yukarıdaki kurulumda DbContext ve driver'ın collection'ları aynı IMongoClient'ı, yani aynı connection pool'u paylaşıyor. Benim tercihim Bookstore'da Catalog yönetim ekranlarını EF Core ile, Ordering ve Inventory'nin hot path'lerini (stok, sipariş, change stream) native driver ile yazmak ve aynı collection'a yazan her iki taraf için row version kuralını kod incelemesinde aramak. Entity Framework Core serisinde CQRS'i (Command Query Responsibility Segregation; okuma ve yazma tarafını ayrı modellerle kurmak) işleyen "EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş" yazısındaki "okuma tarafı ince, yazma tarafı EF Core" ayrımı burada tersine dönüyor: MongoDB'de sıcak yazma yolu native driver'da.
Üretimde Dikkat
DbContext'i scoped, MongoClient'ı singleton tut. Provider dokümanı da aynısını söylüyor: singleton bir DbContext bütün isteklerin entity'lerini change tracker'da biriktirir, bayat veri ve büyüyen bellek demek. Her DbContext için ayrı MongoClient ise 9. yazıdaki "her istekte yeni client" hatasının EF Core'daki hâli.
Komut log'unu açık tutabileceğin bir yer kur. Bu yazının bütün bulguları CommandStartedEvent'ten geldi; aynı abonelik üretimde örneğin log seviyesine bağlı olarak açılıp kapanabilir. Provider'ın kendi log'ları da var, ama owned parçaların yeniden yazılması gibi davranışları en net sunucuya giden komutta görüyorsun. Document boyutuna da dikkat: büyük owned array'ler (yüzlerce satırlık items gibi) her kayıtta bütünüyle gidiyor.
Sık Düşülen Tuzaklar
İlişkisel EF Core alışkanlıklarıyla document modelini bozmak. Include artık çalışıyor diye 4. yazıda gömdüğümüz yazar ve kategori bilgisini ayrı collection'lara bölüp her ekranda Include zinciri kurmak, 4. yazının "Sık Düşülen Tuzaklar"ındaki her okumada $lookup hatasının ta kendisi. Include ara sıra okunan, sınırsız büyüyen ilişkiler (kitap ve yorumları) için; birlikte okunan veri yine gömülü kalmalı.
Enum'ları dönüşümsüz bırakmak. HasConversion<string>() satırını silip 1 numaralı siparişin durumunu değiştirdiğimde provider "Delivered" string'ini okurken sorun çıkarmadı ama yeni değeri sayı olarak yazdı: document'te status: 2 kaldı. Hata yok, ama status: "Shipped" arayan mongosh sorguları, 6. yazının status üzerindeki partial index'i ve 9. yazının convention'la çalışan driver kodu bu siparişi artık görmüyor.
Sayaçları EF Core üzerinden artırmak. book.Stock.OnHand -= 1; SaveChanges() oku-sonra-yaz yarışıdır (3. yazıda 10 adetlik stoktan 50 satış); üstelik owned alan olduğu için bütün stock alt document'i yazılır. Sayaç, stok ve bakiye gibi alanlar driver'ın koşullu $inc'iyle güncellenmeli.
Küçük Bir Deneme
★☆☆ Database.AutoTransactionBehavior'ı Never yapıp ef-save'i çalıştır. Komut log'unda ne değişiyor? Sonra lab'ı 1. yazıdaki gibi standalone bir container'a bağla ve varsayılan ayarla aynı komutu dene.
★★☆ DbUpdateConcurrencyException'ı yakalayan, entity'yi ReloadAsync ile yenileyen ve fiyat değişikliğini yeni değerlerin üstüne bir kez daha uygulayan bir UpdatePriceAsync metodu yaz. "Önce Tahmin Et"teki iki turla dene.
★★★ 7. yazıdaki "müşteri başına sipariş sayısı" raporunu EF Core üzerinden yazmayı dene. Çevrilmeyen kısmı bul, aynı DbContext'in altındaki IMongoClient'tan driver'ın Aggregate()'iyle yaz ve iki çözümün sunucuya gönderdiği komutları karşılaştır.
Blok B burada bitiyor. Bir sonraki yazıda tutarlılığa geçiyoruz: write concern, read concern ve read preference. Lab'ı üç üyeli bir replica set'e çeviriyoruz ve ödeme sonrası siparişi secondary'den okuyunca "sipariş yok" cevabının nasıl geldiğini görüyoruz.