.NET'te MongoDB Driver: MongoClient, POCO Mapping, Convention'lar, Guid/Decimal/DateTime ve LINQ
.NET'te MongoDB driver'ı doğru kullanmanın temeli, MongoClient'ı uygulama boyunca tek instance olarak tutmak ve serialization kurallarını (convention, Guid temsili) ilk kullanımdan önce bir kez kaydetmektir. Gerçek çıktılarla 3.x'te ayarsız Guid'in hata vermesi, decimal'in Decimal128, DateTimeOffset'in document olarak saklanması, LINQ'in ürettiği pipeline, collation'lı sorgu, client-side projection hatası, _t ile polymorphic serialization ve eski Guid verisi tuzağı.
Sürüm notu: Bu yazıdaki kodlar .NET 10, MongoDB.Driver 3.14 ve Microsoft.Extensions.DependencyInjection 10.0 ile derlenip MongoDB 9.0.8 Community Server üzerinde çalıştırılmıştır. Aynı komutlar MongoDB 8.0.38'de de aynı çıktıyı verdi; bu yazıdaki davranışların hepsi driver tarafında.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) Inventory tarafı, ödeme beklenirken stok ayırmak için stock_reservations collection'ına kısa ömürlü kayıtlar yazıyor; 6. yazıda bunlara TTL (süresi dolan document'leri silen) index koymuştuk. Bu sefer .NET'ten, anahtarı Guid olan bir record ile:
// Guid anahtar: driver onu nasıl saklayacağını bilmeli
public sealed record StockReservation(Guid Id, int BookId, int OrderId, int Quantity, DateTime ExpiresAt);
IMongoCollection<StockReservation> reservations = new MongoClient(connectionString)
.GetDatabase("bookstore").GetCollection<StockReservation>("stock_reservations");
await reservations.InsertOneAsync(new StockReservation(Guid.NewGuid(), 26, 380, 1, DateTime.UtcNow.AddMinutes(15)));
Driver'ın 2.x sürümleriyle yazılmış birçok projede bu kod sorunsuz çalışıyordu. 3.14'te ne olur?
BsonSerializationException: An error occurred while serializing the Id property of class Bookstore.MongoLab.StockReservation: GuidSerializer cannot serialize a Guid when GuidRepresentation is Unspecified.
inner BsonSerializationException: GuidSerializer cannot serialize a Guid when GuidRepresentation is Unspecified.
Insert hiç sunucuya gitmedi; hata serialization'da. Guid için BSON'da (MongoDB'nin ikili document formatı) iki biçim var: eski, driver'dan driver'a byte sırası değişen subtype 3 ve bütün driver'ların aynı yazdığı subtype 4. Driver 3.0 ile eski GuidRepresentationMode.V2 kaldırıldı ve hangi biçimin kullanılacağına artık driver tahmin etmiyor; sen söylemezsen Unspecified kalıyor ve serialization reddediliyor. Bu yazıda .NET driver'ının temellerini bu türden ayrıntılarla kuruyoruz: MongoClient'ın yaşam döngüsü, POCO (hiçbir framework'e bağlı olmayan düz C# class'ı) mapping ve convention'lar, .NET tiplerinin BSON karşılıkları, LINQ (C#'ta sorguyu dilin içinde yazmayı sağlayan özellik) ve Builders ile sorgu ve polymorphic serialization.
Kod repodaki src/Bookstore.MongoLab konsol uygulamasında; her bölüm bir komut. 2. yazıdaki gibi connection string'i ortam değişkenine verip çalıştırıyorsun:
$env:BOOKSTORE_MONGO = "mongodb://bookstore_app:<password>@localhost:27017/bookstore?authSource=bookstore&replicaSet=rs0"
dotnet run --project src/Bookstore.MongoLab -- mapping
MongoClient: Tek Instance ve DI
MongoClient hafif bir nesne değil: her sunucu için bir connection pool'u ve sunucuları izleyen arka plan monitoring bağlantılarını tutuyor ve thread-safe. Dokümanın önerisi uygulama boyunca tek bir instance. DI (dependency injection; bağımlılıkların dışarıdan verilmesi) ile bu, singleton kaydı demek:
public static IServiceCollection AddBookstoreMongo(this IServiceCollection services, string connectionString)
{
// Bütün uygulama için tek MongoClient: connection pool'ları ve sunucu izlemeyi o tutar
services.AddSingleton<IMongoClient>(_ =>
{
MongoClientSettings settings = MongoClientSettings.FromConnectionString(connectionString);
settings.ApplicationName = "Bookstore.MongoLab";
settings.ServerSelectionTimeout = TimeSpan.FromSeconds(5);
settings.ConnectTimeout = TimeSpan.FromSeconds(5);
settings.MaxConnectionPoolSize = 100;
return new MongoClient(settings);
});
services.AddSingleton(sp => sp.GetRequiredService<IMongoClient>().GetDatabase("bookstore"));
services.AddSingleton(sp => sp.GetRequiredService<IMongoDatabase>().GetCollection<Book>("books"));
services.AddSingleton(sp => sp.GetRequiredService<IMongoDatabase>().GetCollection<Order>("orders"));
services.AddSingleton(sp => sp.GetRequiredService<IMongoDatabase>().GetCollection<Review>("reviews"));
return services;
}
ASP.NET Core'da bu builder.Services.AddBookstoreMongo(builder.Configuration.GetConnectionString("Bookstore")!) satırı oluyor; konsol uygulaması aynı extension'ı bir ServiceCollection üzerinde çağırıyor. IMongoDatabase ve IMongoCollection<T> de thread-safe ve ucuz, onları da singleton tutmak güvenli. Ayarlardan üçü en sık karıştırılanlar: ServerSelectionTimeout (varsayılan 30 sn) uygun bir sunucu bulunamadığında ne kadar bekleneceği, 2. yazıdaki 5 saniyelik TimeoutException buradan geliyordu; ConnectTimeout (varsayılan 30 sn) tek bir TCP bağlantısının kurulması için süre; MaxConnectionPoolSize (varsayılan 100) sunucu başına en fazla bağlantı. SocketTimeout varsayılan olarak sıfır, yani sınırsız. İstek başına süre sınırı için her async metoda CancellationToken ver; ASP.NET Core'da HttpContext.RequestAborted bunun doğal kaynağı.
Mapping: Convention'lar ve Record'lar
Driver bir class'ı ilk gördüğünde otomatik bir class map kuruyor: public property'ler alan, Id adındaki property _id oluyor. Bizim document'lerimizde alan adları camelCase, C# tarafında PascalCase; enum'ları da sayı değil string olarak saklamak istiyoruz. Bunları her property'ye attribute yazmak yerine convention'larla tek yerde söylüyoruz:
// Serialization ayarları global: herhangi bir class ilk kullanılmadan önce bir kez kaydet
public static void RegisterSerialization()
{
ConventionPack conventions =
[
new CamelCaseElementNameConvention(),
new IgnoreExtraElementsConvention(true),
new EnumRepresentationConvention(BsonType.String)
];
ConventionRegistry.Register("bookstore", conventions, type => type.Namespace == "Bookstore.MongoLab");
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
}
public sealed record Book
{
public int Id { get; init; }
public required string Isbn { get; init; }
public required string Title { get; init; }
public string? Subtitle { get; init; }
public List<AuthorRef> Authors { get; init; } = [];
public CategoryRef? Category { get; init; }
public decimal ListPrice { get; init; }
public DateTime ListedOn { get; init; }
public Stock? Stock { get; init; }
}
public sealed record AuthorRef(int AuthorId, string Name);
public sealed record CategoryRef(int CategoryId, string Name);
(Book'un Language, PageCount gibi diğer property'leri yer kazanmak için kısaltıldı; tam hâli repoda.) Convention'lar ve serializer kaydı global ve bir class için class map ilk kullanımda bir kez kurulup cache'leniyor. Bu yüzden RegisterSerialization uygulama açılırken, ilk sorgudan önce çağrılmalı; sonradan kaydedilen bir convention, map'i kurulmuş class'ları etkilemiyor. Filtre (type.Namespace == ...) convention'ı yalnız kendi tiplerimize uyguluyor. IgnoreExtraElementsConvention önemli: 5. yazıda kitaplara rating ve recentReviews gibi alanlar ekledik; bu convention olmasa, C# tarafında karşılığı olmayan her alan okuma sırasında FormatException üretirdi. Positional record'lar (AuthorRef) da çalışıyor: driver constructor parametrelerini alanlarla eşleştiriyor. Convention'ın yetmediği yerde iki araç var: property üzerine [BsonElement], [BsonRepresentation], [BsonIgnore] gibi attribute'lar ya da model class'larını driver'a bağlamak istemiyorsan aynı ayarları kodda yapan BsonClassMap.RegisterClassMap<T>().
.NET Tipleri BSON'da: decimal, DateTime, DateTimeOffset, Guid
Dune'u okuyup tiplerine bakalım, sonra farklı .NET tiplerini içeren bir örneği yazıp ham BSON olarak geri okuyalım:
IMongoCollection<Book> books = services.GetRequiredService<IMongoCollection<Book>>();
Book dune = await books.Find(b => b.Id == 26).FirstAsync();
await samples.InsertOneAsync(new TypeSample(
1, 390.50m,
new DateTime(2026, 6, 30, 9, 0, 0, DateTimeKind.Utc),
new DateTimeOffset(2026, 6, 30, 12, 0, 0, TimeSpan.FromHours(3)),
Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"),
OrderStatus.Paid));
BsonDocument raw = await database.GetCollection<BsonDocument>("type_samples").Find(new BsonDocument()).FirstAsync();
foreach (BsonElement element in raw)
{
string subtype = element.Value is BsonBinaryData binary ? $" (subtype {binary.SubType})" : "";
Console.WriteLine($" {element.Name,-10} {element.Value.BsonType}{subtype}");
}
Console.WriteLine(raw.ToJson());
Dune by Frank Herbert, Science Fiction
ListPrice 390.50 (Decimal)
ListedOn 2024-06-01T00:00:00.0000000Z (Kind = Utc)
Stock 51 on hand
_id Int32
price Decimal128
createdAt DateTime
localTime Document
token Binary (subtype UuidStandard)
status String
{ "_id" : 1, "price" : { "$numberDecimal" : "390.50" }, "createdAt" : { "$date" : "2026-06-30T09:00:00Z" }, "localTime" : { "DateTime" : { "$date" : "2026-06-30T09:00:00Z" }, "Ticks" : 639184176000000000, "Offset" : 180 }, "token" : { "$binary" : { "base64" : "D4+tW9nLRp+hZXCGdyiVDg==", "subType" : "04" } }, "status" : "Paid" }
Satır satır: decimal Decimal128 oldu ve 390.50 ondalık hassasiyetiyle geri geldi; 1. yazıdaki double yuvarlama sorunu yok. Bu 3.0 ile gelen bir değişiklik, 2.x varsayılan olarak decimal'i string yazıyordu; eski veride fiyatlar string'se [BsonRepresentation(BsonType.String)] gerekir. DateTime BSON'da milisaniye hassasiyetinde bir UTC (eşgüdümlü evrensel zaman) anı ve geri Kind = Utc olarak geliyor. Driver yazarken Local ya da Unspecified bir DateTime'ı uygulamanın çalıştığı makinenin saat dilimine göre UTC'ye çeviriyor; aynı kod iki farklı saat dilimindeki sunucuda farklı anlar yazabilir. Kuralı basit tut: veritabanına giden her DateTime UTC olsun. DateTimeOffset ise 3.0'dan beri varsayılan olarak üç alanlı bir document (DateTime, Ticks, Offset); offset korunuyor, ama bu alan üzerinde tarih aralığı sorgusu yazmak bir DateTime alanına göre zahmetli. Guid, kaydettiğimiz GuidSerializer sayesinde subtype 4 ve OrderStatus convention'la string. Son satır 3.0'ın bir değişikliğini daha gösteriyor: ToJson() artık varsayılan olarak Relaxed Extended JSON üretiyor.
Sorgu: LINQ ve Builders
3. yazıdaki "500 TL altındaki bilim kurgu kitapları, pahalıdan ucuza" sorgusunu LINQ ile yazalım ve driver'ın ne ürettiğine bakalım:
// LINQ: aggregation pipeline'a çevrilir ve sunucuda çalışır
var query = books.AsQueryable()
.Where(b => b.Category!.Name == "Science Fiction" && b.ListPrice < 500m)
.OrderByDescending(b => b.ListPrice)
.Select(b => new { b.Title, b.ListPrice })
.Take(3);
Console.WriteLine(query.ToString());
foreach (var book in await query.ToListAsync())
{
Console.WriteLine($" {book.Title,-22} {book.ListPrice}");
}
// Builders: aynı türden sorgu, Türkçe collation'la (6. yazı)
FilterDefinition<Book> filter = Builders<Book>.Filter.Eq(b => b.Title, "ince memed");
FindOptions options = new() { Collation = new Collation("tr", strength: CollationStrength.Secondary) };
Book? found = await books.Find(filter, options)
.Project<Book>(Builders<Book>.Projection.Include(b => b.Title).Include(b => b.Isbn).Include(b => b.Language))
.FirstOrDefaultAsync();
// Tipli, atomik update: stoğu yalnız yeterliyse düşür, yeni değeri geri al
Book? updated = await books.FindOneAndUpdateAsync(
Builders<Book>.Filter.Eq(b => b.Id, 26) & Builders<Book>.Filter.Gte(b => b.Stock!.OnHand, 2),
Builders<Book>.Update.Inc(b => b.Stock!.OnHand, -2).CurrentDate(b => b.Stock!.UpdatedAt),
new FindOneAndUpdateOptions<Book> { ReturnDocument = ReturnDocument.After });
bookstore.books.Aggregate([{ "$match" : { "category.name" : "Science Fiction", "listPrice" : { "$lt" : { "$numberDecimal" : "500" } } } }, { "$sort" : { "listPrice" : -1 } }, { "$project" : { "Title" : "$title", "ListPrice" : "$listPrice", "_id" : 0 } }, { "$limit" : 3 }])
Neuromancer 480.50
The War of the Worlds 450.50
Dune 390.50
collation tr: İnce Memed
stock after buying 2: 49
ToString() sunucuya gidecek pipeline'ı gösteriyor ve bu bir alışkanlık olmalı: property adları convention sayesinde category.name ve listPrice olarak çevrildi, 500m Decimal128 oldu ve projection sunucuda yapıldı. Builders aynı şeyi tip güvenli bir API ile kuruyor; dinamik filtrelerde ("kullanıcı hangi filtreleri seçtiyse") & ve | ile birleştirmek LINQ'ten kolay. 6. yazıda söz verdiğimiz collation burada: FindOptions.Collation ile "ince memed" araması "İnce Memed"i buldu ve { title: 1 } collation'lı index'i kullanabiliyor. Son satır 3. yazıdaki koşullu $inc'in tipli hâli: filtre ve update tek bir atomik operasyon, ReturnDocument.After güncellenmiş document'i döndürüyor.
LINQ'in sınırı, sunucuya çevrilemeyen ifadeler. Projection içinde bir .NET metodu çağıralım:
var labels = await books.AsQueryable()
.Where(b => b.Id == 26)
.Select(b => PriceLabel(b))
.ToListAsync();
private static string PriceLabel(Book book) => $"{book.Title}: {book.ListPrice:0.00} TRY";
ExpressionNotSupportedException: Expression not supported: PriceLabel(b).
2.x'teki LINQ2 (driver'ın eski LINQ çeviricisi) provider'ı çevrilemeyen böyle bir projection'ı istemcide çalıştırıyordu. 3.0'da LINQ2 kaldırıldı ve yeni çevirici LINQ3 (3.0'ın tek LINQ çeviricisi) client-side projection'ı varsayılan olarak reddediyor; gerçekten istiyorsan TranslationOptions.EnableClientSideProjections ile sorgu ya da client bazında açabilirsin. Doğru yol çoğu zaman sunucudan yalnız gereken alanları alıp biçimlendirmeyi C#'ta, sorgunun dışında yapmak. .NET 10'a özgü bir ayrıntı daha: .NET 10 System.Linq.AsyncEnumerable ile kendi ToListAsync metodunu getiriyor. using MongoDB.Driver.Linq; yazmadığımda bu sorgu derlenmedi ve CS0411: The type arguments for method 'AsyncEnumerable.ToListAsync<TSource>(...)' cannot be inferred from the usage hatası verdi; driver'ın IQueryable extension'ları bu namespace'te.
Polymorphic Serialization: _t ile Baskı Türleri
5. yazıda basılı, e-kitap ve sesli kitap baskılarını tek bir editions collection'ında format alanıyla tuttuk. .NET'te bunun doğal karşılığı bir class hiyerarşisi; driver hangi alt tipin yazıldığını _t adlı bir discriminator alanıyla saklıyor:
// Polymorphic baskılar (5. yazı): tek collection, tipleri _t alanı ayırır
[BsonKnownTypes(typeof(PrintEdition), typeof(Ebook), typeof(Audiobook))]
public abstract record Edition
{
public required string Id { get; init; }
public int BookId { get; init; }
public decimal Price { get; init; }
}
public sealed record Ebook : Edition
{
public List<string> FileFormats { get; init; } = [];
public bool Drm { get; init; }
}
// OfType<T> _t üzerine filtre ekler; sonuç tiplidir
List<Edition> all = await editions.Find(e => e.BookId == 26).SortBy(e => e.Price).ToListAsync();
Console.WriteLine("read back: " + string.Join(", ", all.Select(e => e.GetType().Name)));
Ebook ebook = await editions.OfType<Ebook>().Find(FilterDefinition<Ebook>.Empty).FirstAsync();
{ "_id" : "26-print", "_t" : "PrintEdition", "price" : { "$numberDecimal" : "390.50" } }
{ "_id" : "26-ebook", "_t" : "Ebook", "price" : { "$numberDecimal" : "210.00" } }
{ "_id" : "26-audio", "_t" : "Audiobook", "price" : { "$numberDecimal" : "320.00" } }
read back: Ebook, Audiobook, PrintEdition
ebook formats: epub/pdf
Her document'e _t alanı eklendi ve okurken her biri doğru alt tipe çevrildi. [BsonKnownTypes] driver'a alt tipleri baştan tanıtıyor; olmasa, bir alt tip kod içinde hiç kullanılmadan okunduğunda driver onu tanımaz. OfType<Ebook>() sorguya _t: "Ebook" filtresi ekliyor, yani _t üzerindeki bir index burada da işe yarar. İki uyarı: _t değerleri class adları, class'ı yeniden adlandırmak eski document'leri okunamaz yapar, bu yüzden kalıcı bir ad için [BsonDiscriminator("ebook")] ile sabitlemek daha güvenli. İkincisi, 5. yazıda mongosh ile yazdığımız document'lerde _t yoktu, format vardı; aynı collection'ı iki dünyadan da yazacaksan discriminator alanını ortak bir sözleşmeye bağlaman gerekiyor.
Repository mi, Doğrudan Collection mı?
IMongoCollection<Book> zaten tipli bir veri erişim API'si; filtre, update ve aggregation'ı servis kodunda doğrudan kullanmak çoğu uygulama için yeterli ve MongoDB'nin güçlü taraflarını (atomik update, $push ile $slice, pipeline) gizlemiyor. Her collection için GetById, Add, Update metotları olan genel bir repository ise bu yetenekleri ortak bir paydaya indiriyor: "stoğu koşullu düşür" işini GetById ve Update ile yazmak, 3. yazıda ölçtüğümüz oku-sonra-yaz yarışının ta kendisi. Orta yol, kullanım senaryosuna göre adlandırılmış ince bir sınıf: ReserveStockAsync(bookId, quantity) gibi metotlar, içinde doğrudan driver API'si. Entity Framework Core (EF Core) serisinde repository ve CQRS (Command Query Responsibility Segregation; okuma ve yazma tarafını ayrı modellerle kurmak) konusunu işleyen "EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş" yazısındaki tartışma burada da geçerli; test edilebilirlik için de 10. yazıda gerçek bir MongoDB'ye karşı integration testi kuracağız, collection'ı mock'lamak yerine.
Üretimde Dikkat
Serialization ayarlarını açılışta, tek yerde yap. Convention, serializer ve class map kayıtları global ve ilk kullanımda dondurulduğu için Program.cs'in başında, AddBookstoreMongo'dan önce çağrılan tek bir metotta toplanmalı. Bir test projesi ya da ikinci bir host aynı kayıtları farklı sırayla yaparsa aynı class iki farklı şekilde serialize edilebilir.
Driver'ı gözlemlenebilir yap. ApplicationName sunucu log'larında ve currentOp çıktısında bağlantının kime ait olduğunu gösteriyor. Driver'ın ILoggerFactory desteği ve command event'leri (ClusterConfigurator ile CommandStartedEvent/CommandSucceededEvent) yavaş sorguları ve bağlantı sorunlarını uygulama tarafından görmeyi sağlıyor; izlemenin ayrıntısı 14. yazıda.
Sık Düşülen Tuzaklar
Her istekte yeni MongoClient. Her yeni client kendi connection pool'unu ve monitoring bağlantılarını açar; yük altında sunucudaki bağlantı sayısı istek sayısıyla birlikte büyür, her istek TCP, TLS (bağlantıyı şifreleyen protokol) ve authentication (kimlik doğrulama) maliyetini baştan öder. MongoClient'ı scoped ya da transient kaydetmek de aynı hata.
Guid serializer'ı ayarlamadan eski veriyi okumak. 2.x ile yazılmış eski bir uygulama Guid'leri subtype 3 ile saklamış olabilir. Standard serializer'la bu veriyi okumayı denedim:
await raw.InsertOneAsync(new BsonDocument
{
{ "_id", new BsonBinaryData(id, GuidRepresentation.CSharpLegacy) },
{ "bookId", 26 }, { "orderId", 380 }, { "quantity", 1 }, { "expiresAt", new DateTime(2026, 6, 30, 10, 0, 0, DateTimeKind.Utc) }
});
FormatException: An error occurred while deserializing the Id property of class Bookstore.MongoLab.StockReservation: Expected BsonBinarySubType to be UuidStandard, but it is UuidLegacy.
filter with the same Guid finds 0 document(s)
Okuma hata verdi; daha sinsisi ikinci satır: aynı Guid ile yazılan filtre sıfır document buldu, çünkü sorgu subtype 4 byte'larıyla arıyor. Hata vermeyen ama hiçbir şey bulamayan bir sorgu, "kayıt yok" diye yanlış bir iş kararına dönüşebilir. Eski veride subtype 3 varsa ya o property'ye [BsonGuidRepresentation(GuidRepresentation.CSharpLegacy)] koy ya da veriyi bir kez subtype 4'e taşıyan bir migration yaz (5. yazıdaki küçük batch'li yaklaşımla).
Küçük Bir Deneme
★☆☆ Order için "bir müşterinin son 10 siparişi" sorgusunu LINQ ile yaz ve ToString() çıktısının 6. yazıdaki { customerId, orderedAt } index'ini kullanacak bir $match ve $sort ürettiğini explain ile doğrula.
★★☆ DateTimeOffset'i document yerine string olarak saklayan bir [BsonRepresentation] ekle ve iki biçimde "son 24 saatte oluşturulan kayıtlar" sorgusunu yazıp hangisinin doğru sıralandığını karşılaştır.
★★★ 5. yazıdaki editions document'lerini (alan format, _t yok) okuyabilen bir IDiscriminatorConvention yaz: discriminator olarak format alanını kullansın ve print, ebook, audiobook değerlerini alt tiplere eşlesin. mongosh ile yazılmış ve .NET ile yazılmış document'leri aynı sorguda oku.
Bir sonraki yazıda driver'ın ileri tarafına geçiyoruz: fluent Aggregate() ile pipeline, BulkWriteAsync ve 8.0'ın client-level bulk write'ı, WithTransactionAsync ile transaction ve otomatik retry, change stream ile sipariş değişikliklerini bir BackgroundService'te dinlemek ve Testcontainers ile replica set'li integration testi.