Document modelling
Embedding versus referencing, the 16 MB limit, unbounded arrays, and schema versioning inside a document.
A relational schema is designed around the data's structure and normalised so that every fact lives once; the queries come afterwards and joins assemble what they need. A document model is designed the other way round: start from the reads and writes the application actually makes, and shape each document so that the common ones touch one document. That is the whole skill, and every rule below — embed or reference, the 16 MB limit, the array that must not grow, the version field — follows from it.
Access patterns first
Before a single field, list the operations with their frequency: "show an order with its lines and the customer's name" (every page view), "list a customer's orders, newest first" (every visit), "update a line's quantity" (rare), "find every order containing a SKU" (a report, weekly). A document store rewards the design in which the frequent read is findOne on one document and the frequent write is updateOne on one document, and it punishes the design that needs three collections joined for the page everyone loads. The relational instinct — one table per noun — produces the second design; the document instinct produces a document per thing the application handles as a unit.
{
_id: ObjectId("..."),
orderNumber: "ORD-2026-000412",
customer: { id: ObjectId("..."), name: "Ada L." }, // embedded copy of what the page shows
status: "PAID",
placedAt: ISODate("2026-09-18T02:10:00Z"),
lines: [ { sku: "A-1", name: "Cable", qty: 2, unitPaise: 19900 } ],
totalPaise: 39800,
schemaVersion: 2
}One read for the page, one write to place it. The customer's name is copied, not joined — a deliberate denormalisation, and the honest question about every copy is what happens when the source changes. For an order, the name at the time of the order is arguably the correct value anyway; for a product catalogue embedded in ten thousand orders, a rename is ten thousand updates, and a reference is the right call.
Embed or reference
The decision, per relationship, with the questions that decide it:
| Embed when | Reference when |
|---|---|
| the child is read with the parent almost every time | the child is read on its own, or from several parents |
| the child does not exist without the parent (order lines) | the child has its own lifecycle (a customer, a product) |
| the number of children is bounded and small (tens) | the number is unbounded or large (events, comments, log entries) |
| the child rarely changes, or changing it with the parent is fine | the child changes independently and the copies would drift |
A reference is a stored _id and a second query (or a $lookup in an aggregation, which the next lesson covers and which is a join with a join's cost). The pattern that covers most real cases is both: embed the few fields the common read needs (customer.name) and reference the rest (customer.id), accepting the copy and its update cost for exactly those fields. Spring Data MongoDB maps an embedded object as a nested class and a reference with @DocumentReference (or the older @DBRef), and the second one fetches lazily or eagerly per your annotation — the JPA course's lazy-loading traps, back in a different costume.
The 16 MB limit and the unbounded array
A document may not exceed 16 MB, and long before that limit it becomes slow: every read returns the whole document, every update rewrites it, and an array that MongoDB must scan to append to costs more with each element. So the design rule is that no array may grow without bound. Order lines are bounded by how many things a person buys; a customer's orders array is not, an account's transactions array is not, a device's readings array is not. For those, the child is its own collection with a reference to the parent ({ accountId, amount, at }, indexed on (accountId, at)), or, for time series, the bucket pattern: one document per parent per hour or per day, holding that period's readings, which bounds the array by time and keeps a day's read to one document.
The tells in an existing schema: $push without $slice, an array field the application only ever appends to, a document whose size grows with the age of the account. Each one is a document that will hit the limit or the performance cliff on the customer with the most history, which is the customer you least want to lose.
Schema versioning inside the document
There is no ALTER TABLE, so a schema change is the application handling two shapes at once. The pattern is a schemaVersion field on every document and a reader that upgrades on the way in:
Order read(Document doc) {
int v = doc.getInteger("schemaVersion", 1);
if (v < 2) doc = splitCustomerNameIntoParts(doc); // the v1 → v2 change, applied in memory
return map(doc);
}Write new documents at the current version, read old ones through the upgrade, and migrate the stored documents lazily (write them back at the new version when they are next saved) or in a background job over the collection in batches. That is the SQL course's expand-contract with the migration inside the application, and the discipline is the same: never make the reader depend on every document having been upgraded, and remove the upgrade code only when a query for schemaVersion: { $lt: 2 } returns nothing. MongoDB's JSON Schema validation (validator on the collection) can enforce the current shape on writes, which is the part of a relational schema worth keeping.
Spring Data MongoDB mapping, and what it hides
@Document(collection = "orders") on a class, @Id on a String or ObjectId, nested classes for embedded objects, MongoRepository<Order, String> with derived queries — the shape is Spring Data JPA's, and that is the trap: it is easy to write a relational design in annotations. Three things to keep in view. The mapped class is the schema, so a renamed field is a document the application can no longer read until the version pattern above handles it. @DocumentReference does the second query for you, and a list of references is the N+1 problem with no JOIN FETCH to reach for. And MongoTemplate with Update objects ($set, $inc, $push) updates the fields you name, where repository.save(order) replaces the whole document — the difference between a 40-byte write and a 40-kilobyte one, and between two concurrent updates to different fields both landing and the second one erasing the first.