Not every application fits a relational database well. Sometimes your data is document shaped, evolves often or does not need rigid schemas and joins.
In those cases, forcing everything into tables and migrations can slow you down more than it helps.
That's where MongoDB comes in. In this post, we'll wire up the official MongoDB.Driver.
What is MongoDB?
MongoDB is a document database. Instead of rows in tables, you store BSON documents in collections.
Documents map naturally to .NET objects. Related data can live together in one document, which often means fewer round trips and simpler reads.
For .NET, the official client is MongoDB.Driver. It gives you typed collections, filters, projections and async APIs that fit ASP.NET Core well.
Without further ado, let's get started.
Getting Started
Install the driver package:
dotnet add package MongoDB.Driver
You'll also need a running MongoDB instance. In the sample project I use Docker Compose with MongoDB and mongo-express, but any local or cloud cluster works.
Configuration
Keep connection details in configuration. A simple appsettings.json section looks like this:
{
"MongoDb": {
"ConnectionString": "mongodb://admin:admin@localhost:27017",
"Database": "Products",
"Collection": "products"
}
}
Bind that section to a strongly typed options class:
public class MongoDbOptions
{
public string ConnectionString { get; set; }
public string Database { get; set; }
public string Collection { get; set; }
}
For more details on binding and validating settings, check out my previous blog post on the Options Pattern.
Registering MongoClient
Register IMongoClient as a singleton. The client is thread-safe and meant to be reused for the lifetime of the app:
public static IServiceCollection AddDatabase(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<MongoDbOptions>(
configuration.GetSection("MongoDb"));
services.AddSingleton<IMongoClient>(serviceProvider =>
{
var options = serviceProvider
.GetRequiredService<IOptions<MongoDbOptions>>()
.Value;
return new MongoClient(options.ConnectionString);
});
return services;
}
From the client you resolve a database, then a typed collection. Handlers will do that with the options you just configured.
Modeling Documents
Here's a simple Product entity mapped for MongoDB:
public sealed class Product
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; }
[BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
public DateTime CreatedAt { get; set; }
[BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
public DateTime? ModifiedAt { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public Product(
string id,
DateTime createdAt,
string name,
string description,
decimal price)
{
Id = id;
CreatedAt = createdAt;
Name = name;
Description = description;
Price = price;
ModifiedAt = null;
}
}
BsonId marks the document identifier. BsonRepresentation(BsonType.ObjectId) stores it as a MongoDB ObjectId while you keep a string in C#.
BsonDateTimeOptions keeps timestamps in UTC, which is what you usually want for APIs.
Creating Documents
To insert a document, resolve the collection and call InsertOneAsync:
internal sealed class Handler : IRequestHandler<Command, Result<string>>
{
private readonly IMongoCollection<Product> _collection;
public Handler(
IMongoClient client,
IOptions<MongoDbOptions> mongoDbOptions)
{
var database = client.GetDatabase(mongoDbOptions.Value.Database);
_collection = database.GetCollection<Product>(
mongoDbOptions.Value.Collection);
}
public async Task<Result<string>> Handle(
Command request,
CancellationToken cancellationToken)
{
var product = new Product(
ObjectId.GenerateNewId().ToString(),
DateTime.UtcNow,
request.Name,
request.Description,
request.Price);
await _collection.InsertOneAsync(product, cancellationToken);
return Result.Success(product.Id);
}
}
ObjectId.GenerateNewId() creates a new MongoDB id before insert. The handler returns that id to the caller.
Reading Documents
Reads use Builders<T>.Filter for predicates and Project when you only need part of the document.
Fetching a single product by id:
public async Task<Result<ProductResponse>> Handle(
Query request,
CancellationToken cancellationToken)
{
var filter = Builders<Product>.Filter
.Eq(x => x.Id, request.Id);
var product = await _collection
.Find(filter)
.Project(x => new ProductResponse(
x.Id,
x.Name,
x.Description,
x.Price))
.FirstOrDefaultAsync(cancellationToken);
return product is null
? Result.NotFound()
: Result.Success(product);
}
Listing everything is just as straightforward. Use an empty filter:
public async Task<IEnumerable<ProductResponse?>> Handle(
Query request,
CancellationToken cancellationToken) =>
await _collection
.Find(FilterDefinition<Product>.Empty)
.Project(p => new ProductResponse(
p.Id,
p.Name,
p.Description,
p.Price))
.ToListAsync(cancellationToken);
Projection keeps responses lean. You only pull the fields your API actually returns.
Updating Documents
Partial updates use Builders<T>.Update with UpdateOneAsync:
public async Task<Result> Handle(
Command request,
CancellationToken cancellationToken)
{
var filter = Builders<Product>.Filter
.Eq(p => p.Id, request.Id);
var update = Builders<Product>.Update
.Set(p => p.Name, request.Name)
.Set(p => p.Description, request.Description)
.Set(p => p.Price, request.Price);
var result = await _collection.UpdateOneAsync(
filter,
update,
cancellationToken: cancellationToken);
return result.MatchedCount == 0
? Result.NotFound()
: Result.Success();
}
You do not need to load the full document first. Set only the fields that change and check MatchedCount to see if anything matched.
Deleting Documents
Deletes follow the same pattern with DeleteOneAsync:
public async Task<Result> Handle(
Command request,
CancellationToken cancellationToken)
{
var deleteResult = await _collection.DeleteOneAsync(
x => x.Id == request.Id,
cancellationToken);
return deleteResult.DeletedCount == 0
? Result.NotFound()
: Result.Success();
}
If nothing was deleted, treat it as not found. Otherwise return success.
Conclusion
Getting started with MongoDB in ASP.NET Core is mostly package setup, options binding and a singleton IMongoClient.
From there, typed collections plus filters, projections and update builders cover everyday CRUD without an ORM.
The sample behind this post also organizes Minimal API endpoints with MediatR. If you want that structure, I've written a dedicated blog post on organizing Minimal APIs that you can check out here if you're interested.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
