The MongoDB .NET Driver: MongoClient, POCO Mapping, Conventions, Guid/Decimal/DateTime and LINQ
The foundation of using the MongoDB driver correctly in .NET is keeping MongoClient as a single instance for the whole application and registering serialization rules (conventions, Guid representation) once before first use. With real output: an unconfigured Guid failing in 3.x, decimal stored as Decimal128 and DateTimeOffset as a document, the pipeline LINQ generates, a query with collation, the client-side projection error, polymorphic serialization with _t and the legacy Guid data trap.
Version note: The code in this post was built with .NET 10, MongoDB.Driver 3.14 and Microsoft.Extensions.DependencyInjection 10.0 and run against MongoDB 9.0.8 Community Server. The same commands gave the same output on MongoDB 8.0.38; all the behavior in this post is on the driver side.
Guess First
Bookstore's Inventory side, Bookstore being the online bookstore used as the shared example across this site's series, writes short-lived records to the stock_reservations collection to hold stock while a payment is pending; in post 6 we put a TTL index on them. This time from .NET, with a record whose key is a Guid:
// A Guid key: the driver needs to know how to store it
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)));
In many projects written with the 2.x versions of the driver, this code worked without trouble. What happens in 3.14?
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.
The insert never reached the server; the error is in serialization. BSON has two forms for a Guid: the old subtype 3, whose byte order differs from driver to driver, and subtype 4, which every driver writes the same way. Driver 3.0 removed the old "GuidRepresentationMode V2", and the driver no longer guesses which form to use; if you do not say, it stays Unspecified and serialization is rejected. In this post we build the foundations of the .NET driver with details of this kind: the lifetime of MongoClient, POCO mapping and conventions, the BSON counterparts of .NET types, querying with LINQ and Builders, and polymorphic serialization.
The code is in the repo's src/Bookstore.MongoLab console application; each section is a command. As in post 2, you put the connection string in an environment variable and run it:
$env:BOOKSTORE_MONGO = "mongodb://bookstore_app:<password>@localhost:27017/bookstore?authSource=bookstore&replicaSet=rs0"
dotnet run --project src/Bookstore.MongoLab -- mapping
MongoClient: a Single Instance and DI
MongoClient is not a lightweight object: it holds a connection pool per server and background monitoring connections that watch the servers, and it is thread-safe. The documentation recommends a single instance for the whole application. With DI, that means a singleton registration:
public static IServiceCollection AddBookstoreMongo(this IServiceCollection services, string connectionString)
{
// One MongoClient for the whole application: it owns the connection pools and server monitoring
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;
}
In ASP.NET Core this becomes the line builder.Services.AddBookstoreMongo(builder.Configuration.GetConnectionString("Bookstore")!); the console application calls the same extension on a ServiceCollection. IMongoDatabase and IMongoCollection<T> are thread-safe and cheap too, so keeping them as singletons is safe. Three settings are the most often confused: ServerSelectionTimeout (30 s by default) is how long to wait when no suitable server is found, and the 5-second TimeoutException in post 2 came from here; ConnectTimeout (30 s by default) is the time to open a single TCP connection; MaxConnectionPoolSize (100 by default) is the maximum number of connections per server. SocketTimeout is zero by default, meaning no limit. For a per-request time limit, pass a CancellationToken to every async method; in ASP.NET Core HttpContext.RequestAborted is its natural source.
Mapping: Conventions and Records
When the driver first sees a class, it builds an automatic class map: public properties become fields, and the property called Id becomes _id. In our documents the field names are camelCase, on the C# side PascalCase; we also want enums stored as strings, not numbers. Instead of writing attributes on every property, we state these in one place with conventions:
// Serialization settings are global: register them once, before the first use of any class
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's other properties such as Language and PageCount are cut here to save space; the full version is in the repo.) Conventions and serializer registrations are global, and a class's class map is built once on first use and cached. So RegisterSerialization must be called at application startup, before the first query; a convention registered later does not affect classes whose map has already been built. The filter (type.Namespace == ...) applies the conventions only to our own types. IgnoreExtraElementsConvention matters: in post 5 we added fields such as rating and recentReviews to the books; without this convention, every field with no counterpart on the C# side would produce a FormatException when read. Positional records (AuthorRef) work too: the driver matches constructor parameters to fields. Where conventions are not enough there are two tools: attributes on properties such as [BsonElement], [BsonRepresentation] and [BsonIgnore], or, if you do not want to tie your model classes to the driver, BsonClassMap.RegisterClassMap<T>(), which makes the same settings in code.
.NET Types in BSON: decimal, DateTime, DateTimeOffset, Guid
Let's read Dune and look at its types, then write a sample containing different .NET types and read it back as raw BSON:
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" }
Line by line: decimal became Decimal128 and came back as 390.50 with decimal precision; there is none of the double rounding problem from post 1. This is a change that came with 3.0; 2.x wrote decimal as a string by default, and if prices are strings in old data you need [BsonRepresentation(BsonType.String)]. DateTime is a UTC instant with millisecond precision in BSON, and it comes back with Kind = Utc. When writing, the driver converts a Local or Unspecified DateTime to UTC using the time zone of the machine the application runs on; the same code can write different instants on two servers in different time zones. Keep the rule simple: every DateTime going to the database is UTC. DateTimeOffset, since 3.0, is by default a document with three fields (DateTime, Ticks, Offset); the offset is kept, but writing a date range query on this field is more work than on a DateTime field. Guid is subtype 4 thanks to the GuidSerializer we registered, and OrderStatus is a string through the convention. The last line shows another 3.0 change: ToJson() now produces Relaxed Extended JSON by default.
Querying: LINQ and Builders
Let's write the "science fiction books under 500 TRY, most expensive first" query from post 3 with LINQ and look at what the driver produces:
// LINQ: translated to an aggregation pipeline and run on the server
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: the same kind of query, with a Turkish collation (post 6)
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();
// Typed, atomic update: decrement stock only if enough is left, get the new value back
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() shows the pipeline that will go to the server, and checking it should be a habit: thanks to the convention, property names were translated to category.name and listPrice, 500m became Decimal128 and the projection was done on the server. Builders build the same thing through a type-safe API; for dynamic filters ("whatever filters the user picked") combining them with & and | is easier than LINQ. The collation we promised in post 6 is here: with FindOptions.Collation, the search for "ince memed" found "İnce Memed" and can use the { title: 1 } index with the same collation. The last line is the typed version of the conditional $inc from post 3: filter and update are one atomic operation, and ReturnDocument.After returns the updated document.
LINQ's limit is expressions that cannot be translated for the server. Let's call a .NET method inside a projection:
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).
The LINQ2 provider in 2.x ran such an untranslatable projection on the client. In 3.0 LINQ2 was removed, and LINQ3 rejects client-side projections by default; if you really want one, you can enable it per query or per client with TranslationOptions.EnableClientSideProjections. The right way is usually to fetch only the fields you need from the server and do the formatting in C#, outside the query. One more detail specific to .NET 10: .NET 10 brings its own ToListAsync with System.Linq.AsyncEnumerable. When I did not write using MongoDB.Driver.Linq;, this query did not compile and failed with CS0411: The type arguments for method 'AsyncEnumerable.ToListAsync<TSource>(...)' cannot be inferred from the usage; the driver's IQueryable extensions live in that namespace.
Polymorphic Serialization: Edition Types with _t
In post 5 we kept print, ebook and audiobook editions in a single editions collection with a format field. In .NET the natural counterpart is a class hierarchy; the driver stores which subtype was written in a discriminator field named _t:
// Polymorphic editions (post 5): one collection, the _t field tells the types apart
[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> adds a filter on _t; the result is typed
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
A _t field was added to every document, and on reading each one was turned into the right subtype. [BsonKnownTypes] introduces the subtypes to the driver up front; without it, the driver would not recognize a subtype read before it is used anywhere in the code. OfType<Ebook>() adds a _t: "Ebook" filter to the query, so an index on _t helps here too. Two warnings: the _t values are class names, and renaming a class makes old documents unreadable, so pinning a permanent name with [BsonDiscriminator("ebook")] is safer. Second, the documents we wrote with mongosh in post 5 had no _t, they had format; if you will write the same collection from both worlds, you have to tie the discriminator field to a shared contract.
A Repository or the Collection Directly?
IMongoCollection<Book> is already a typed data access API; using filters, updates and aggregations directly in service code is enough for most applications, and it does not hide MongoDB's strengths (atomic updates, $push with $slice, pipelines). A generic repository with GetById, Add and Update methods for every collection reduces these capabilities to a common denominator: writing "decrement stock conditionally" with GetById and Update is exactly the read-then-write race we measured in post 3. The middle ground is a thin class named after the use cases: methods such as ReserveStockAsync(bookId, quantity) with the driver API directly inside. The discussion in the EF Core series post "EF Core and Architecture: Repositories, the Read Side of CQRS and Moving to Dapper" applies here too; for testability, in post 10 we will set up an integration test against a real MongoDB instead of mocking the collection.
In Production
Do serialization setup at startup, in one place. Since convention, serializer and class map registrations are global and frozen on first use, they belong in a single method called at the top of Program.cs, before AddBookstoreMongo. If a test project or a second host makes the same registrations in a different order, the same class can be serialized in two different ways.
Make the driver observable. ApplicationName shows whose connection it is in server logs and in the currentOp output. The driver's ILoggerFactory support and command events (CommandStartedEvent/CommandSucceededEvent through ClusterConfigurator) let you see slow queries and connection problems from the application side; monitoring in detail is in post 14.
Common Pitfalls
A new MongoClient on every request. Each new client opens its own connection pool and monitoring connections; under load the number of connections on the server grows with the number of requests, and every request pays the TCP, TLS and authentication cost again. Registering MongoClient as scoped or transient is the same mistake.
Reading old data without setting up the Guid serializer. An old application written with 2.x may have stored Guids as subtype 3. I tried reading such data with the Standard serializer:
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)
The read failed; the second line is sneakier: a filter written with the same Guid found zero documents, because the query searches with subtype 4 bytes. A query that does not fail but finds nothing can turn into a wrong business decision such as "no such record". If old data contains subtype 3, either put [BsonGuidRepresentation(GuidRepresentation.CSharpLegacy)] on that property or write a migration that moves the data to subtype 4 once (with the small-batch approach from post 5).
A Small Experiment
★☆☆ Write the query "a customer's last 10 orders" for Order with LINQ and verify with explain that the ToString() output produces a $match and $sort that can use the { customerId, orderedAt } index from post 6.
★★☆ Add a [BsonRepresentation] that stores DateTimeOffset as a string instead of a document, write the query "records created in the last 24 hours" for both forms and compare which one sorts correctly.
★★★ Write an IDiscriminatorConvention that can read the editions documents from post 5 (field format, no _t): it should use the format field as the discriminator and map the values print, ebook and audiobook to the subtypes. Read documents written with mongosh and with .NET in the same query.
In the next post we move to the advanced side of the driver: pipelines with the fluent Aggregate(), BulkWriteAsync and the client-level bulk write of 8.0, transactions with WithTransactionAsync and automatic retries, listening to order changes in a BackgroundService with change streams, and an integration test with a replica set using Testcontainers.