Skip to main content
Published / updated

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; }
}
AttributeDoes
[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:

DetailWhy
SchoolId in every filterThe tenant boundary — MongoDB enforces nothing
Status != InactiveThe soft-delete filter, or removed students reappear
Regex.Escape(term)An unescaped .* matches everything
Ascending(s => s.Id) lastWithout 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
ExceptionCause
DuplicateKeyError / duplicate-key categoryA unique index was violated
ConnectionFailureCannot reach the server
ServerSelectionTimeoutErrorNo reachable node within the timeout
OperationFailureCommand rejected — often a permission or validation failure
InvalidIdA malformed ObjectId string
WriteError with code 121Document 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

SymptomCause
Connection times outIP not in the Atlas access list, or a firewall
Fields missing after an updateReplaceOne with a partial object
A property is always defaultField name mismatch — check [BsonElement]
Crash after a schema changeMissing [BsonIgnoreExtraElements]
Money driftingdecimal stored as Double
Query returns nothing_id passed as a string, or a type mismatch
Pool exhausted under loadA client created per request
Transaction unsupportedStandalone server, not a replica set
Partial write after a failureAn 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 seeCauseFix
Connections exhausted under loadA client created per requestThe client is thread-safe — create one, reuse it
MongoDB.Bson.BsonSerializationExceptionType cannot be mappedConfigure the class map
_id arrives as a string and breaks queriesObjectId versus string mismatchMatch the stored type
Decimal values lost precisionMapped to doubleUse Decimal128/NumberDecimal
A query works in mongosh, not in codeFilter built differentlyLog 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 MongoClient per request
  • ReplaceOne with a partial object
  • Checking ModifiedCount instead of MatchedCount
  • decimal without Decimal128 representation
  • Decimal128 constructed from a float
  • Missing [BsonIgnoreExtraElements]
  • The tenant filter absent from one query
  • schoolId inside an $or branch
  • Unescaped user input in a regex
  • Non-deterministic sort with paging
  • An id from a URL used without ObjectId conversion
  • No InvalidId handling
  • 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.

  1. Build a StudentRepository in C# or Python with search, get, create, update and soft delete.
  2. Confirm schoolId appears in every filter. Remove it from one and read another school's data.
  3. Put schoolId inside an $or branch and confirm the leak.
  4. Store TotalFees without Decimal128. Add it to itself twenty times and compare with the Decimal128 version.
  5. Construct Decimal128 from a float and from a string. Compare.
  6. Update with ReplaceOne and a partial object. Read the document and list what was lost. Redo it with an update builder.
  7. Save unchanged values and read MatchedCount and ModifiedCount.
  8. Add a field to documents without adding it to the class. Confirm the crash, then add [BsonIgnoreExtraElements].
  9. Rename a field in the database and confirm the property silently becomes default.
  10. Search for .* unescaped and confirm everything matches. Add escaping.
  11. Page with a non-unique sort over duplicate names. Find a document appearing twice.
  12. Add _id as the final sort key and confirm stability.
  13. Pass a malformed id string. Record the exception, then handle InvalidId and return 404.
  14. Create a MongoClient per request and load-test. Compare with a singleton.
  15. Run a transaction against a standalone server and record the error. Then against a replica set.
  16. Omit the session from one operation in a transaction. Force a failure and confirm that write survived.
  17. Insert a duplicate roll number and translate the exception into a domain exception.
  18. 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

  1. Why must IMongoClient be a singleton?
  2. Why is ReplaceOne with a partial object dangerous?
  3. Why must Decimal128 be constructed from a string?
  4. What does MongoDB require before transactions can be used?

Next: Guided MongoDB project