Skip to main content
Published / updated

Data Modelling

Before you start

You need: arrays and embedded documents (Article 04).

Time: about 50 minutes, plus the practice.

Learning objective

Design a document model driven by how the data is read, and add the validation a flexible schema does not give you.

Topics

  • Model for the query, not the entity
  • Embed versus reference
  • One-to-one, one-to-many, many-to-many
  • Controlled duplication
  • Referential integrity
  • Schema validation
  • Common anti-patterns
  • Migrating a model

Model for the query

In SQL you normalise first and join later. In MongoDB you start from the queries.

The right question is not "what are the entities" but "what does each screen read, and how often".

ScreenReads
Student listName, roll number, class — for 400 students
Student profileEverything about one student, plus recent results
Class reportMarks for every student in a class
Fee reportBalances across every student

A model that makes the profile one read may make the class report expensive, and the reverse. There is no normalised answer that is right regardless of usage — which is the main mental shift from relational design.

Two guiding numbers:

  • Read/write ratio. Data read constantly and written rarely can be duplicated; data written constantly should not be.
  • Cardinality. A few, many, or unbounded — this decides embed versus reference more than anything else.

Embed versus reference

// Embedded
{
_id: ObjectId("s1"),
name: "Ravi Kumar",
address: { city: "Hyderabad", pinCode: "500001" },
parent: { name: "Suresh Kumar", phone: "9951510727" }
}

// Referenced
{ _id: ObjectId("s1"), name: "Ravi Kumar" }
{ _id: ObjectId("r1"), studentId: ObjectId("s1"), subject: "Maths", marks: 87 }
Embed whenReference when
Always read with the parentQueried on its own
Bounded and smallUnbounded growth
Owned by the parentShared between parents
Updated with the parentUpdated independently and often

The 16 MB document limit makes unbounded arrays a design error, not a scaling concern. Attendance over five years, an audit trail, log entries — all reference.

Large arrays are also slow to write: MongoDB rewrites the whole document, so appending to a 5,000-element array rewrites all 5,000.

Rule of thumb: embed a few, reference many.

The three patterns by cardinality

// One-to-few — embed
{
_id: ObjectId("s1"),
name: "Ravi Kumar",
address: { city: "Hyderabad", pinCode: "500001" },
phones: ["9951510727", "9848012345"]
}
// One-to-many — reference by storing the parent id on the child
{ _id: ObjectId("s1"), name: "Ravi Kumar" }

{ _id: ObjectId("r1"), studentId: ObjectId("s1"), examId: ObjectId("e1"), marks: 87 }

Store the reference on the "many" side, exactly as a foreign key sits on the child table. Storing an array of result ids on the student recreates the unbounded-array problem.

// One-to-squillions — reference, and never store the list on the parent
{ _id: ObjectId("s1"), name: "Ravi Kumar" }

{ _id: ObjectId("a1"), studentId: ObjectId("s1"), date: ISODate("2024-06-15"), present: true }

Many-to-many

// Array of references on one side — when one side is bounded
{
_id: ObjectId("t1"),
name: "Dr. Mehta",
subjectIds: [ObjectId("sub1"), ObjectId("sub2")]
}
// A junction collection — when both sides grow, or the link has its own data
{
_id: ObjectId("ts1"),
teacherId: ObjectId("t1"),
subjectId: ObjectId("sub1"),
assignedOn: ISODate("2024-06-01"),
assignedBy: "admin"
}

A teacher teaching a handful of subjects suits the array. A relationship carrying its own fields, or growing without limit, needs the junction collection — the same decision as in SQL.

Controlled duplication

// Result documents carry the student's name and roll number
{
_id: ObjectId("r1"),
schoolId: 1,
studentId: ObjectId("s1"),
studentName: "Ravi Kumar",
rollNumber: "NCA-2024-0012",
examId: ObjectId("e1"),
subjectName: "Mathematics",
marks: NumberDecimal("87.00"),
isAbsent: false
}

A class report now reads one collection with no join. In SQL that duplication would be a normalisation error; here it is the extended reference pattern, and it is deliberate.

The cost is real. A student's name changes, and every result document carries the old one:

db.examResults.updateMany(
{ studentId: studentId },
{ $set: { studentName: newName } }
)

Three rules for duplicating:

  • Duplicate values that rarely change. A name, a roll number, a subject title — yes. A fee balance — no.
  • Have one writer. A duplicated value updated from three places drifts, and nobody can tell which is correct.
  • Keep the source of truth. studentId stays on the document, so the authoritative value is always reachable.

Do not duplicate a value that changes often. Copying a fee balance onto every payment means every payment must be rewritten when it changes — the opposite of the intended saving.

Referential integrity

MongoDB has no foreign keys. Nothing prevents this:

db.examResults.insertOne({ studentId: ObjectId("does-not-exist"), marks: 87 })
db.students.deleteOne({ _id: ObjectId("s1") }) // results now reference nothing

Both succeed. There is no error, and the orphaned results appear in reports as blank rows.

Integrity is entirely the application's responsibility, which is the biggest practical difference from SQL Server. Three consequences:

Validate references before inserting:

const student = await students.findOne({ _id: studentId, schoolId });

if (!student) {
throw new NotFoundError("Student not found");
}

Never hard-delete a referenced document. Soft delete, and filter on read:

db.students.updateOne({ _id: id }, { $set: { status: "Inactive" } })

db.students.find({ schoolId: 1, status: { $ne: "Inactive" } })

Handle orphans defensively in every read that joins:

db.examResults.aggregate([
{ $match: { examId } },
{ $lookup: { from: "students", localField: "studentId", foreignField: "_id", as: "student" } },
{ $unwind: { path: "$student", preserveNullAndEmptyArrays: true } }
])

preserveNullAndEmptyArrays: true keeps results whose student is missing rather than dropping them silently — so an orphan is visible rather than a quietly wrong count.

Embedding removes the problem entirely, which is one of its strongest arguments: an embedded address cannot be orphaned.

Schema validation

Flexible schema does not have to mean unvalidated.

db.createCollection("students", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["schoolId", "name", "rollNumber", "className", "section", "status"],
properties: {
schoolId: { bsonType: "int" },
name: { bsonType: "string", minLength: 2, maxLength: 100 },
rollNumber: { bsonType: "string", pattern: "^NCA-[0-9]{4}-[0-9]{4}$" },
className: { bsonType: "string", maxLength: 10 },
section: { enum: ["A", "B", "C"] },
dateOfBirth: { bsonType: "date" },
parentPhone: { bsonType: "string", pattern: "^[6-9][0-9]{9}$" },
status: { enum: ["Active", "Inactive", "Graduated", "Transferred"] },
address: {
bsonType: "object",
properties: {
city: { bsonType: "string" },
pinCode: { bsonType: "string", pattern: "^[0-9]{6}$" }
}
}
}
}
},
validationLevel: "strict",
validationAction: "error"
})

bsonType: "date" on dateOfBirth prevents the string-date mistake at the database level — the single most valuable rule here, because a string date passes every application test until someone tries a range query.

SettingEffect
validationLevel: "strict"Every insert and update
validationLevel: "moderate"New documents, and updates to already-valid ones
validationAction: "error"Reject
validationAction: "warn"Log and accept

moderate plus warn is how you add validation to an existing collection: log violations, fix the data, then tighten.

db.runCommand({
collMod: "students",
validator: { $jsonSchema: { /* ... */ } },
validationLevel: "strict"
})
db.students.find({ $nor: [{ $jsonSchema: schema }] })

That finds every existing document violating the schema — run it before switching to strict.

Validation is not a substitute for application validation, but it is the only thing enforcing consistency against a script, a support engineer in Compass, or a second application.

Unique constraints

db.students.createIndex({ schoolId: 1, rollNumber: 1 }, { unique: true })

Uniqueness in a multi-tenant system is scoped to the tenant. A unique index on rollNumber alone stops the second school using a roll number the first already has — invisible with one school of test data, and a blocker with two.

db.students.createIndex(
{ schoolId: 1, email: 1 },
{ unique: true, partialFilterExpression: { email: { $exists: true } } }
)

A plain unique index treats missing fields as equal, so only one document may omit email. The partial filter excludes them.

A unique index is the only real constraint MongoDB offers, and it is what makes an upsert safe against a race.

Anti-patterns

Unbounded arrays.

// Fails at 16 MB, years in, on the busiest document
{ _id: ObjectId("s1"), name: "Ravi Kumar", attendance: [ /* 5 years of records */ ] }

Massive documents. Fetching a 10 MB document to display a name transfers 10 MB. Split by access pattern.

A collection per tenant.

students_school1, students_school2, students_school3

Every query needs the collection name computed, indexes multiply, and a report across schools becomes impossible. Use one collection with a schoolId field, exactly as a relational design would.

Relational modelling in documents.

// Five collections joined on every read — SQL Server would do this better
students, addresses, phones, parents, guardians

If every read needs four $lookup stages, the model is fighting the database. Either embed, or use a relational database.

One giant collection. Storing students, teachers and payments in records with a type field makes every index and every query worse. Separate collections.

Inconsistent field names.

{ className: "10th" }
{ class_name: "10th" }
{ class: "10th" }

A query on className silently misses two thirds of the data. Schema validation prevents this; nothing else does.

Modelling the School Management System

// students — the address and parent are embedded, results are not
{
_id: ObjectId("s1"),
schoolId: 1,
name: "Ravi Kumar",
rollNumber: "NCA-2024-0012",
className: "10th",
section: "A",
dateOfBirth: ISODate("2009-05-14"),
address: { line1: "12 MG Road", city: "Hyderabad", pinCode: "500001" },
parent: { name: "Suresh Kumar", phone: "9951510727" },
status: "Active",
recentResults: [
{ subject: "Mathematics", marks: NumberDecimal("87.00"), examDate: ISODate("2024-06-15") }
],
resultCount: 12
}
// examResults — referenced, with an extended reference for reporting
{
_id: ObjectId("r1"),
schoolId: 1,
studentId: ObjectId("s1"),
studentName: "Ravi Kumar",
rollNumber: "NCA-2024-0012",
examId: ObjectId("e1"),
subjectName: "Mathematics",
marks: NumberDecimal("87.00"),
isAbsent: false,
recordedAt: ISODate("2024-06-16T09:00:00Z")
}
// attendance — one document per student per day, never embedded
{
_id: ObjectId("a1"),
schoolId: 1,
studentId: ObjectId("s1"),
date: ISODate("2024-06-15"),
present: true
}

The decisions, and why:

DecisionReason
Address and parent embeddedAlways read with the student, bounded, not shared
Results referencedGrows without limit, queried by exam and by class
recentResults duplicated, capped at fiveThe profile page reads one document
studentName on resultsThe class report reads one collection
Attendance referencedUnbounded — the clearest case
schoolId on every documentTenant filter on every query
marks as Decimal128Exact; Double drifts
Dates as ISODateRange queries and sorting

marks: null with isAbsent: true, never marks: 0. The same rule this curriculum returns to: a zero and an absence must be distinguishable, or an absent student prints as having failed.

Even so, SQL Server remains the better fit for this system — transactions across fee payments, constraints that guarantee the absent rule, and reporting across every entity. This model exists to show what a document design looks like when done deliberately, not to argue it is the right choice here.

Migrating a model

Adding a field needs no migration — old documents simply lack it. Reads must handle that:

const section = student.section ?? "A";

Changing a field's meaning or type does need one:

// Backfill in batches
let lastId = ObjectId("000000000000000000000000");

while (true) {
const batch = db.students.find({ _id: { $gt: lastId }, marks: { $type: "string" } })
.sort({ _id: 1 })
.limit(1000)
.toArray();

if (batch.length === 0) break;

const operations = batch.map(doc => ({
updateOne: {
filter: { _id: doc._id },
update: { $set: { marks: NumberDecimal(doc.marks) } }
}
}));

db.students.bulkWrite(operations, { ordered: false });

lastId = batch[batch.length - 1]._id;
}

Batch by _id range, not skip. skip gets slower as it advances, and documents shifting between batches can be missed.

The safe sequence:

  1. Write both the old and new shape
  2. Backfill existing documents
  3. Switch reads to the new shape
  4. Stop writing the old one
  5. Remove it

Each step is independently deployable and reversible. Changing everything at once is not.

Errors you will hit

What you seeCauseFix
Documents approaching 16MBEmbedded an unbounded collectionReference instead
Updating one field rewrites a huge documentEverything embeddedSplit it
The same data is stale in two placesDuplicated without a refresh strategyDecide the source of truth
Every screen needs several round tripsModelled relationally in a document storeModel for the access pattern
Money totals are slightly wrongStored as DoubleNumberDecimal

Model for how the data is read, not how it relates. That is the difference from relational design, and it is the whole skill.

Common mistakes

  • Modelling entities rather than queries
  • Unbounded embedded arrays
  • Storing an array of child ids on the parent
  • Duplicating values that change often
  • Several writers for one duplicated value
  • No soft delete, leaving orphaned references
  • No schema validation on a shared collection
  • Unique index on the business key without the tenant
  • A collection per tenant
  • Inconsistent field names
  • Dates as strings
  • Double for money
  • marks: 0 for an absent student
  • skip-based batching in a migration
  • Changing the model in one deployment

Practice

The course exercise is model an embedded structure, and the assignment is design a task database.

  1. List the four screens for the School Management System and what each reads. Design the model from that list, not from the entities.
  2. Model a student with an embedded address and parent. Read one and confirm it is a single document.
  3. Model attendance as an embedded array. Insert 5,000 days and measure the document size and the time to append.
  4. Restructure it as a separate collection and compare.
  5. Add studentName to result documents. Write the class report as one query with no lookup.
  6. Change a student's name and confirm the results are stale. Write the update that fixes them.
  7. Duplicate a fee balance onto every payment. Record a payment and count how many documents must change.
  8. Create the schema validator with bsonType: "date" and the roll-number pattern. Insert a string date and confirm the rejection.
  9. Set validationAction: "warn" and repeat. Find the warning in the log.
  10. Insert documents violating the schema, then find them with $nor: [{ $jsonSchema: schema }].
  11. Create a unique index on rollNumber alone. Insert the same roll number for a second school and confirm the failure. Fix it with the composite index.
  12. Insert two documents with no email under a plain unique index. Confirm the failure, then use partialFilterExpression.
  13. Hard-delete a student with 20 result documents. Run the class report and confirm the blank rows.
  14. Soft-delete instead, and confirm a report without the status filter still shows them.
  15. Add $lookup with and without preserveNullAndEmptyArrays over data containing an orphan. Compare the counts.
  16. Write a batched migration converting a string field to Decimal128, using _id ranges.

Exercises 3, 11 and 13 correspond to a document that eventually fails, a multi-tenant blocker and silent data corruption.

You can now

  • Design a document model driven by access patterns
  • Choose between embedding and referencing
  • Respect the 16MB document limit
  • Say when duplication is a deliberate trade-off
  • Use NumberDecimal for money

Review questions

  1. Why does document modelling start from queries rather than entities?
  2. When is duplicating a value the right decision, and what obligation does it create?
  3. What replaces foreign keys in MongoDB?
  4. Why must a unique index include the tenant field?

Next: Indexes and performance