Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 044 / 100

MongoDB with .NET: a MongoDB & NoSQL guide for developers

The C# driver in practice.

Expona Admin7 min read

Getting started with MongoDB in your .NET stack

Many teams migrating from SQL Server to document databases face the same question: how do we actually write this code in C#? MongoDB with .NET: a MongoDB & NoSQL guide for developers answers that directly. We'll walk through the official MongoDB C# driver—the practical, production-ready way to work with MongoDB from your .NET applications.

Why the C# driver matters

The MongoDB C# driver is your bridge between .NET's type system and MongoDB's document model. It handles serialization, connection pooling, and query translation so you don't rebuild those wheels. After working across e-commerce platforms, CRM systems, and SaaS products, we've seen teams either embrace the driver's abstractions early or spend weeks fighting impedance mismatches later.

The driver supports both synchronous and asynchronous operations, LINQ queries, and strongly-typed collections—which means your IDE catches mistakes before your tests do.

Map documents to C# classes

MongoDB stores JSON documents. C# works with objects. The driver's serialization layer bridges this gap through class mapping. The simplest approach: create a POCO (Plain Old C# Object) that mirrors your document structure.

javascript
public class Product
{
    [BsonId]
    public ObjectId Id { get; set; }

    [BsonElement("name")]
    public string Name { get; set; }

    [BsonElement("sku")]
    public string Sku { get; set; }

    public decimal Price { get; set; }

    public List<string> Categories { get; set; } = new();

    [BsonIgnore]
    public string DisplayName => $"{Name} ({Sku})";

    [BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
    public DateTime CreatedAt { get; set; }
}

Use [BsonId] to mark your document identifier. [BsonElement] maps property names to BSON field names—useful when your C# naming differs from your documents. [BsonIgnore] tells the driver not to serialize a property. For timestamps, always use [BsonDateTimeOptions(Kind = DateTimeKind.Utc)]; this prevents timezone confusion across regions.

In a trading platform we worked on, we mapped complex nested structures like order fills and position snapshots the same way. For nested types, define them as nested classes or separate public classes—the driver handles both transparently.

LINQ support for queries

The MongoDB C# driver implements IQueryable<T>, which means you write LINQ queries that translate to MongoDB aggregation pipelines. This is one of the driver's superpowers: your query syntax stays C#, but it executes on the server.

javascript
// Synchronous example
var expensiveProducts = collection
    .Find(p => p.Price > 1000)
    .SortByDescending(p => p.CreatedAt)
    .Limit(10)
    .ToList();

// Async example with aggregation
var report = await collection
    .Aggregate()
    .Match(p => p.Price > 100)
    .Group(
        p => p.Categories[0],
        g => new
        {
            Category = g.Key,
            Count = g.Count(),
            AvgPrice = g.Average(p => p.Price)
        }
    )
    .ToListAsync();

// Complex filter with logical operators
var filter = Builders<Product>.Filter;
var combined = filter.Eq(p => p.Sku, "SKU-123") &
               filter.Gt(p => p.Price, 50) &
               filter.In(p => p.Categories, new[] { "Electronics", "Premium" });

var matches = await collection.Find(combined).ToListAsync();

The Builders<T> API provides type-safe filter, projection, and sort construction. For complex queries—especially across multiple conditions—it's cleaner than lambda expressions and easier to test in isolation. We've used this pattern in ride-hailing backends to build dynamic passenger search filters without string concatenation or raw BSON.

One practical tip: prefer .Find() for single-document or small result sets, and .Aggregate() for server-side grouping, joins, or reshaping. The aggregation pipeline executes entirely on MongoDB, reducing data transfer and CPU cost on your application server.

Use IMongoCollection<T>

IMongoCollection<T> is your primary interface for CRUD operations. It's the repository layer built into the driver. Working with it forces a consistent pattern and makes testing simpler.

javascript
public class ProductRepository
{
    private readonly IMongoCollection<Product> _collection;

    public ProductRepository(IMongoCollection<Product> collection)
    {
        _collection = collection;
    }

    public async Task<Product> GetBySkuAsync(string sku)
    {
        return await _collection
            .Find(p => p.Sku == sku)
            .FirstOrDefaultAsync();
    }

    public async Task<long> InsertAsync(Product product)
    {
        product.CreatedAt = DateTime.UtcNow;
        await _collection.InsertOneAsync(product);
        return 1; // Return affected count for consistency
    }

    public async Task<bool> UpdatePriceAsync(ObjectId id, decimal newPrice)
    {
        var update = Builders<Product>.Update
            .Set(p => p.Price, newPrice);

        var result = await _collection.UpdateOneAsync(
            p => p.Id == id,
            update
        );

        return result.ModifiedCount > 0;
    }

    public async Task<bool> DeleteAsync(ObjectId id)
    {
        var result = await _collection.DeleteOneAsync(p => p.Id == id);
        return result.DeletedCount > 0;
    }

    public async Task<List<Product>> GetByCategoryAsync(string category, int skip = 0, int take = 20)
    {
        return await _collection
            .Find(p => p.Categories.Contains(category))
            .Skip(skip)
            .Take(take)
            .ToListAsync();
    }
}

Wrapping IMongoCollection<T> in a repository class gives you a single point to test, refactor, or swap implementations. In e-commerce systems, we've used this to add audit logging, retry logic for transient failures, and circuit-breaker patterns for degraded MongoDB availability.

Notice InsertAsync sets CreatedAt server-side; this prevents client clock skew. For bulk operations, use InsertManyAsync(). For updates, always verify the returned result's ModifiedCount—it tells you whether the query actually matched anything.

Register the client as a singleton

The MongoClient itself is expensive to construct—it establishes connection pools and reads server metadata. Build it once at startup, register it as a singleton, and inject the collections or database into your services.

javascript
// Program.cs or Startup.cs
services.AddSingleton<IMongoClient>(sp =>
{
    var mongoUrl = MongoUrl.Create("mongodb+srv://user:pass@cluster.mongodb.net/mydb");
    var settings = MongoClientSettings.FromUrl(mongoUrl);
    
    // Optional: configure timeouts, read preferences
    settings.ConnectTimeout = TimeSpan.FromSeconds(10);
    settings.SocketTimeout = TimeSpan.FromSeconds(30);
    settings.ReadPreference = ReadPreference.Secondary; // For read-heavy workloads

    return new MongoClient(settings);
});

// Register database
services.AddSingleton<IMongoDatabase>(sp =>
{
    var client = sp.GetRequiredService<IMongoClient>();
    return client.GetDatabase("mydb");
});

// Register collections
services.AddSingleton<IMongoCollection<Product>>(sp =>
{
    var database = sp.GetRequiredService<IMongoDatabase>();
    return database.GetCollection<Product>("products");
});

// Register repository
services.AddScoped<IProductRepository, ProductRepository>();

The client is a singleton; collections and databases are cheap views into it, so they can be singletons too. Repositories, which may hold state or cache, should typically be scoped. This pattern ensures your connection pool stays warm and reused, which matters in microservices where you spin up many request handlers.

In SaaS deployments running on Kubernetes, we've also added health checks that test client.GetServer() periodically. MongoDB Atlas connection strings change rarely, but network hiccups do—graceful reconnection saves support tickets.

Common mistakes

  • Creating a new MongoClient per request. This exhausts connection pools and slows down every query. Always use a singleton.
  • Forgetting to specify DateTimeKind.Utc. MongoDB stores all times as UTC; mismatching this causes silent bugs in multi-region systems.
  • Storing large arrays without indexing. If you query by array element frequently, add an index. Without it, MongoDB scans the entire collection.
  • Mixing synchronous and asynchronous drivers. Pick one per collection. Sync-over-async (blocking on async calls) causes thread pool starvation in ASP.NET Core.
  • Not checking write operation results. InsertOneAsync and UpdateOneAsync return acknowledgment; verify InsertedId or ModifiedCount to catch silent failures.
  • Querying without pagination. .ToList() on a million-document collection will crash your server. Always use .Skip().Take() or cursor-based pagination.

Key takeaways

  • Map your MongoDB documents to strongly-typed C# POCOs using [BsonId], [BsonElement], and [BsonIgnore] attributes.
  • Use LINQ and Builders<T> for type-safe queries; prefer .Aggregate() for server-side data transformation.
  • Wrap IMongoCollection<T> in a repository layer to centralize query logic and simplify testing.
  • Create MongoClient once at startup and register it as a singleton; inject collections and databases into your services.
  • Always use async/await in production .NET applications; avoid blocking calls on async operations.
  • Set DateTimeKind.Utc for all timestamp properties to prevent timezone bugs across regions.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles