Application Integration
Before you start
You need: indexes (Article 06), and either C# (Track 03) or Python (Track 13).
Time: about 50 minutes, plus the practice.
Learning objective
Perform CRUD from an application with correct connection handling, type mapping, tenant filtering and error translation.
Topics
- The .NET driver
- Registering the client
- Mapping documents to classes
- CRUD from C#
- The Python driver
- CRUD from Python
- Transactions
- Error handling
- Configuration
The .NET driver
Right-click the project → Manage NuGet Packages → Browse → MongoDB.Driver → Install, or in the Package Manager Console:
Install-Package MongoDB.Driver
public sealed class Student
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
[BsonElement("schoolId")]
public int SchoolId { get; set; }
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("rollNumber")]
public string RollNumber { get; set; } = string.Empty;
[BsonElement("className")]
public string ClassName { get; set; } = string.Empty;
[BsonElement("section")]
public string Section { get; set; } = string.Empty;
[BsonElement("dateOfBirth")]
public DateTime DateOfBirth { get; set; }
[BsonElement("address")]
[BsonIgnoreIfNull]
public Address? Address { get; set; }
[BsonElement("status")]
[BsonRepresentation(BsonType.String)]
public StudentStatus Status { get; set; }
[BsonElement("totalFees")]
[BsonRepresentation(BsonType.Decimal128)]
public decimal TotalFees { get; set; }
}
| Attribute | Does |
|---|---|
[BsonId] | Maps to _id |
[BsonRepresentation(BsonType.ObjectId)] | Lets a string property hold an ObjectId |
[BsonElement("name")] | Maps a PascalCase property to a camelCase field |
[BsonIgnoreIfNull] | Omits the field when null |
[BsonIgnoreExtraElements] | Ignores document fields with no property |
[BsonRepresentation(BsonType.Decimal128)] is required for money. Without it a decimal is stored as a Double, and fee totals drift — the same rounding problem as everywhere else.
[BsonIgnoreExtraElements] on the class prevents a crash when a document has fields the class does not:
[BsonIgnoreExtraElements]
public sealed class Student { }
Without it, adding a field to documents breaks every deployed version of the application that predates it. On a flexible-schema database, that will happen.
Configuring conventions once avoids attributes on every property:
var conventions = new ConventionPack
{
new CamelCaseElementNameConvention(),
new IgnoreExtraElementsConvention(true),
new EnumRepresentationConvention(BsonType.String)
};
ConventionRegistry.Register("SchoolConventions", conventions, _ => true);
That maps every PascalCase property to camelCase, ignores extra elements everywhere, and stores enums as readable strings.
Registering the client
builder.Services.AddSingleton<IMongoClient>(_ =>
new MongoClient(builder.Configuration.GetConnectionString("MongoDb")));
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IMongoClient>()
.GetDatabase(builder.Configuration["MongoDb:Database"]));
builder.Services.AddScoped<IStudentRepository, StudentRepository>();
IMongoClient is a singleton. It manages a connection pool internally and is thread-safe. Creating one per request exhausts connections — the same mistake as a new HttpClient per call, with the same symptom: the application works under light load and fails under real traffic.
IMongoDatabase and IMongoCollection<T> are cheap and safe to resolve per request.
CRUD from C#
public sealed class StudentRepository : IStudentRepository
{
private readonly IMongoCollection<Student> _students;
public StudentRepository(IMongoDatabase database)
{
_students = database.GetCollection<Student>("students");
}
public async Task<PagedResult<Student>> SearchAsync(
int schoolId, string? term, int page, int pageSize, CancellationToken ct)
{
var builder = Builders<Student>.Filter;
var filter = builder.Eq(s => s.SchoolId, schoolId)
& builder.Ne(s => s.Status, StudentStatus.Inactive);
if (!string.IsNullOrWhiteSpace(term))
{
var escaped = Regex.Escape(term);
filter &= builder.Or(
builder.Regex(s => s.Name, new BsonRegularExpression(escaped, "i")),
builder.Regex(s => s.RollNumber, new BsonRegularExpression(escaped, "i")));
}
var totalCount = await _students.CountDocumentsAsync(filter, cancellationToken: ct);
var items = await _students
.Find(filter)
.Sort(Builders<Student>.Sort.Ascending(s => s.ClassName)
.Ascending(s => s.Name)
.Ascending(s => s.Id))
.Skip((page - 1) * pageSize)
.Limit(pageSize)
.ToListAsync(ct);
return new PagedResult<Student>
{
Items = items,
TotalCount = (int)totalCount,
Page = page,
PageSize = pageSize
};
}
}
Four things this gets right:
| Detail | Why |
|---|---|
SchoolId in every filter | The tenant boundary — MongoDB enforces nothing |
Status != Inactive | The soft-delete filter, or removed students reappear |
Regex.Escape(term) | An unescaped .* matches everything |
Ascending(s => s.Id) last | Without a unique tiebreaker, paging repeats and skips rows |
Regex.Escape is not optional. A user searching for .* otherwise returns the whole collection, and a crafted pattern can cause catastrophic backtracking.
public async Task<Student?> GetByRollNumberAsync(int schoolId, string rollNumber, CancellationToken ct)
{
return await _students
.Find(s => s.SchoolId == schoolId && s.RollNumber == rollNumber)
.FirstOrDefaultAsync(ct);
}
public async Task<string> CreateAsync(Student student, CancellationToken ct)
{
await _students.InsertOneAsync(student, cancellationToken: ct);
return student.Id!;
}
InsertOneAsync sets the Id property in place — the generated ObjectId is written back to your object.
public async Task<bool> UpdateAsync(
int schoolId, string id, StudentUpdateRequest request, CancellationToken ct)
{
var update = Builders<Student>.Update
.Set(s => s.Name, request.Name)
.Set(s => s.ClassName, request.ClassName)
.Set(s => s.Section, request.Section)
.Set(s => s.ParentPhone, request.ParentPhone)
.CurrentDate(s => s.UpdatedAt);
var result = await _students.UpdateOneAsync(
s => s.SchoolId == schoolId && s.Id == id, update, cancellationToken: ct);
return result.MatchedCount > 0;
}
Check MatchedCount, not ModifiedCount. A save with unchanged values gives Matched: 1, Modified: 0 — reporting that as "not found" is wrong.
Use UpdateOneAsync with a builder, never ReplaceOneAsync with a partial object. Replace destroys every field not supplied — MongoDB's version of the over-posting problem:
// Wrong — wipes dateOfBirth, address, status and everything else
await _students.ReplaceOneAsync(s => s.Id == id, studentFromRequest, cancellationToken: ct);
public async Task<bool> DeactivateAsync(int schoolId, string id, CancellationToken ct)
{
var result = await _students.UpdateOneAsync(
s => s.SchoolId == schoolId && s.Id == id,
Builders<Student>.Update.Set(s => s.Status, StudentStatus.Inactive),
cancellationToken: ct);
return result.MatchedCount > 0;
}
Soft delete, because MongoDB has no foreign keys — a hard delete leaves exam results referencing an id that no longer exists, with nothing to prevent or report it.
LINQ
var students = await _students.AsQueryable()
.Where(s => s.SchoolId == schoolId && s.ClassName == "10th")
.OrderBy(s => s.Name)
.Take(20)
.ToListAsync(ct);
LINQ is readable and translates a useful subset. Anything untranslatable throws at run time, not compile time — so verify the generated query:
var query = _students.AsQueryable().Where(s => s.SchoolId == schoolId);
Console.WriteLine(query.ToString());
Filter builders are more verbose and never surprise you. Use LINQ for simple queries and builders for anything conditional.
The Python driver
pip install pymongo
from pymongo import MongoClient, ASCENDING
from bson import ObjectId, Decimal128
client = MongoClient(settings.mongo_url)
database = client[settings.mongo_database]
students = database["students"]
Create one MongoClient for the application, not per request. It holds the connection pool and is thread-safe.
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
@dataclass
class Student:
id: str | None
school_id: int
name: str
roll_number: str
class_name: str
section: str
status: str = "Active"
def to_document(student: Student) -> dict:
document = {
"schoolId": student.school_id,
"name": student.name,
"rollNumber": student.roll_number,
"className": student.class_name,
"section": student.section,
"status": student.status
}
if student.id:
document["_id"] = ObjectId(student.id)
return document
def from_document(document: dict) -> Student:
return Student(
id=str(document["_id"]),
school_id=document["schoolId"],
name=document["name"],
roll_number=document["rollNumber"],
class_name=document["className"],
section=document["section"],
status=document.get("status", "Active")
)
One mapper per entity, used everywhere. Mapping inline in each function guarantees one of them eventually forgets a field or a default.
document.get("status", "Active") handles documents written before the field existed — which flexible schema makes routine.
CRUD from Python
def search(school_id: int, term: str | None, page: int, page_size: int) -> dict:
query: dict = {"schoolId": school_id, "status": {"$ne": "Inactive"}}
if term:
escaped = re.escape(term)
query["$or"] = [
{"name": {"$regex": escaped, "$options": "i"}},
{"rollNumber": {"$regex": escaped, "$options": "i"}}
]
total = students.count_documents(query)
cursor = (students.find(query)
.sort([("className", ASCENDING), ("name", ASCENDING), ("_id", ASCENDING)])
.skip((page - 1) * page_size)
.limit(page_size))
return {
"items": [from_document(d) for d in cursor],
"total_count": total,
"page": page,
"page_size": page_size
}
schoolId sits outside the $or, so it applies to both branches. Putting it inside one is a tenant leak.
def get_by_id(school_id: int, student_id: str) -> Student | None:
try:
object_id = ObjectId(student_id)
except InvalidId:
return None
document = students.find_one({"_id": object_id, "schoolId": school_id})
return from_document(document) if document else None
An id from a URL is a string and must be converted. ObjectId(...) raises InvalidId on a malformed value — catching it returns 404 rather than a 500.
def create(student: Student) -> str:
result = students.insert_one(to_document(student))
return str(result.inserted_id)
def update(school_id: int, student_id: str, changes: dict) -> bool:
result = students.update_one(
{"_id": ObjectId(student_id), "schoolId": school_id},
{"$set": changes, "$currentDate": {"updatedAt": True}}
)
return result.matched_count > 0
def record_payment(school_id: int, account_id: str, amount: Decimal) -> bool:
result = fee_accounts.update_one(
{"_id": ObjectId(account_id), "schoolId": school_id},
{"$inc": {"paidAmount": Decimal128(str(amount))}}
)
return result.matched_count > 0
Decimal128(str(amount)) — constructed from a string, not a float, or the binary rounding error is carried in.
$inc is atomic. Reading a balance, adding in Python and writing it back loses one of two concurrent payments.
Transactions
using var session = await _client.StartSessionAsync(cancellationToken: ct);
session.StartTransaction();
try
{
await _payments.InsertOneAsync(session, payment, cancellationToken: ct);
await _feeAccounts.UpdateOneAsync(session,
a => a.SchoolId == schoolId && a.Id == accountId,
Builders<FeeAccount>.Update.Inc(a => a.PaidAmount, payment.Amount),
cancellationToken: ct);
await session.CommitTransactionAsync(ct);
}
catch
{
await session.AbortTransactionAsync(ct);
throw;
}
with client.start_session() as session:
with session.start_transaction():
payments.insert_one(payment_document, session=session)
fee_accounts.update_one(
{"_id": account_id, "schoolId": school_id},
{"$inc": {"paidAmount": Decimal128(str(amount))}},
session=session
)
Every operation must be passed the session. Omitting it on one means that write runs outside the transaction and is not rolled back — a subtle bug, because the code looks correct and only fails when something else throws.
Three constraints:
Transactions require a replica set. A standalone mongod cannot run them. Atlas and Docker replica sets can; a plain local install cannot, and the error says so.
They are more expensive than in SQL Server. Keep them short, and prefer a model where related data lives in one document — a single-document update is atomic without a transaction.
A transaction has a 60-second default limit and will abort if it exceeds it.
The best transaction is the one you do not need. Embedding the fee balance with its payments, or using $inc on a single document, achieves atomicity without one.
Error handling
public async Task<string> CreateAsync(Student student, CancellationToken ct)
{
try
{
await _students.InsertOneAsync(student, cancellationToken: ct);
return student.Id!;
}
catch (MongoWriteException ex)
when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
throw new DuplicateRollNumberException(student.RollNumber, ex);
}
}
from pymongo.errors import DuplicateKeyError, ConnectionFailure, OperationFailure
try:
students.insert_one(document)
except DuplicateKeyError as error:
raise DuplicateRollNumberError(document["rollNumber"]) from error
except ConnectionFailure as error:
raise DatabaseUnavailableError("Could not reach MongoDB") from error
| Exception | Cause |
|---|---|
DuplicateKeyError / duplicate-key category | A unique index was violated |
ConnectionFailure | Cannot reach the server |
ServerSelectionTimeoutError | No reachable node within the timeout |
OperationFailure | Command rejected — often a permission or validation failure |
InvalidId | A malformed ObjectId string |
WriteError with code 121 | Document failed schema validation |
Translate driver exceptions into domain exceptions at the repository boundary. A MongoWriteException reaching a controller means the web layer must know MongoDB error categories, and the next unhandled one becomes a 500.
ServerSelectionTimeoutError almost always means the network, not the code — an Atlas IP access list missing your address, or a firewall.
Configuration
{
"ConnectionStrings": {
"MongoDb": ""
},
"MongoDb": {
"Database": "nexcoding_school"
}
}
var connectionString = builder.Configuration.GetConnectionString("MongoDb");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"MongoDB connection string is not configured. " +
"Set it via user secrets in development or ConnectionStrings__MongoDb in production.");
}
The connection string contains the password. It belongs in user secrets or environment variables, never in appsettings.json or .env committed to Git — history is permanent, and the fix is rotating the credential.
Validating at startup means a misconfigured deployment fails immediately with a message naming the setting, rather than on the first request.
var settings = MongoClientSettings.FromConnectionString(connectionString);
settings.MaxConnectionPoolSize = 100;
settings.ServerSelectionTimeout = TimeSpan.FromSeconds(5);
settings.ConnectTimeout = TimeSpan.FromSeconds(10);
settings.RetryWrites = true;
RetryWrites retries a failed write once on a transient network error — safe because the driver makes it idempotent.
builder.Services.AddHealthChecks()
.AddMongoDb(connectionString, name: "mongodb", tags: new[] { "ready" });
Diagnosing integration problems
| Symptom | Cause |
|---|---|
| Connection times out | IP not in the Atlas access list, or a firewall |
| Fields missing after an update | ReplaceOne with a partial object |
| A property is always default | Field name mismatch — check [BsonElement] |
| Crash after a schema change | Missing [BsonIgnoreExtraElements] |
| Money drifting | decimal stored as Double |
| Query returns nothing | _id passed as a string, or a type mismatch |
| Pool exhausted under load | A client created per request |
| Transaction unsupported | Standalone server, not a replica set |
| Partial write after a failure | An operation missing the session |
var settings = MongoClientSettings.FromConnectionString(connectionString);
settings.ClusterConfigurator = cb =>
cb.Subscribe<CommandStartedEvent>(e =>
logger.LogDebug("{Command}: {Json}", e.CommandName, e.Command.ToJson()));
import logging
logging.getLogger("pymongo.command").setLevel(logging.DEBUG)
Logging the generated command answers most "why does this return nothing" questions in one line — it shows the filter exactly as MongoDB received it, including the types.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Connections exhausted under load | A client created per request | The client is thread-safe — create one, reuse it |
MongoDB.Bson.BsonSerializationException | Type cannot be mapped | Configure the class map |
_id arrives as a string and breaks queries | ObjectId versus string mismatch | Match the stored type |
| Decimal values lost precision | Mapped to double | Use Decimal128/NumberDecimal |
| A query works in mongosh, not in code | Filter built differently | Log the generated filter |
The Mongo client is thread-safe and expensive to create. Register it as a singleton; creating one per request exhausts connections.
Common mistakes
- A
MongoClientper request ReplaceOnewith a partial object- Checking
ModifiedCountinstead ofMatchedCount decimalwithoutDecimal128representationDecimal128constructed from a float- Missing
[BsonIgnoreExtraElements] - The tenant filter absent from one query
schoolIdinside an$orbranch- Unescaped user input in a regex
- Non-deterministic sort with paging
- An id from a URL used without
ObjectIdconversion - No
InvalidIdhandling - An operation in a transaction missing the session
- Driver exceptions reaching the controller
- The connection string committed
- Hard delete leaving orphaned references
Practice
The course exercise is perform CRUD from an application.
- Build a
StudentRepositoryin C# or Python with search, get, create, update and soft delete. - Confirm
schoolIdappears in every filter. Remove it from one and read another school's data. - Put
schoolIdinside an$orbranch and confirm the leak. - Store
TotalFeeswithoutDecimal128. Add it to itself twenty times and compare with theDecimal128version. - Construct
Decimal128from a float and from a string. Compare. - Update with
ReplaceOneand a partial object. Read the document and list what was lost. Redo it with an update builder. - Save unchanged values and read
MatchedCountandModifiedCount. - Add a field to documents without adding it to the class. Confirm the crash, then add
[BsonIgnoreExtraElements]. - Rename a field in the database and confirm the property silently becomes default.
- Search for
.*unescaped and confirm everything matches. Add escaping. - Page with a non-unique sort over duplicate names. Find a document appearing twice.
- Add
_idas the final sort key and confirm stability. - Pass a malformed id string. Record the exception, then handle
InvalidIdand return 404. - Create a
MongoClientper request and load-test. Compare with a singleton. - Run a transaction against a standalone server and record the error. Then against a replica set.
- Omit the session from one operation in a transaction. Force a failure and confirm that write survived.
- Insert a duplicate roll number and translate the exception into a domain exception.
- Enable command logging and read the generated filter for a query returning nothing.
Exercises 6, 14 and 16 correspond to data loss, a load failure and a silent partial write.
You can now
- Perform CRUD from an application
- Register the client once and reuse it
- Map types correctly, including decimals and
ObjectId - Log the generated filter when a query misbehaves
- Keep the connection string out of source control
Review questions
- Why must
IMongoClientbe a singleton? - Why is
ReplaceOnewith a partial object dangerous? - Why must
Decimal128be constructed from a string? - What does MongoDB require before transactions can be used?
Next: Guided MongoDB project