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