We already know what NoSQL is, why it exists and how each of its four families is operated. And yet, with all of that, we still do not know how to do the most important thing: design well.
This is the lesson where most projects go wrong. MongoDB's syntax is learned in an afternoon; document modeling is where it is decided whether the system will still be fine in two years or whether it will have to be rewritten. And the trap is that a bad design works perfectly on day one: with 200 documents everything is fast and nothing fails. The damage appears when the collection grows, when a document approaches its size limit or when somebody discovers that a branch name is duplicated across 40,000 documents and has just changed.
We are going to invert the mental order we learned in module 2, define precisely what an aggregate is, settle the most important decision in document modeling —embed or reference— with explicit criteria instead of intuition, learn the five design patterns that solve almost every real case and the four anti-patterns that spoil them, and recover part of the lost guarantees with schema validation.
The deliverable at the end is concrete: the definitive design of BiblioRed's three collections —reviews, catalog and activity— with example documents and the justification for every decision.
Contents
- The change of mindset: from the domain to the queries
- The aggregate: unit of reading, writing and atomicity
- The central decision: embed or reference
- One-to-few, one-to-many, one-to-squillions
- Controlled duplication and how to keep it consistent
- Pattern: extended reference
- Pattern: subset
- Pattern: bucket
- Pattern: outlier
- Pattern: computed field
- Anti-patterns you have to recognize
- Schema validation with
$jsonSchema - Document versioning and schema evolution
- Indexes, briefly
- Deliverable: the final design of BiblioRed's collections
- Common mistakes and tips
- Exercises
- Conclusion
- The change of mindset: from the domain to the queries
In module 2 we followed, without naming it, a very specific method:
- Identify the entities of the domain: branches, members, books, copies, loans.
- Give each one its table, with its keys and its relationships.
- Normalize to eliminate redundancy (we will formalize this in module 5).
- And afterwards write the queries, confident that the model will withstand any question.
That last point is the great virtue of the relational model: a well-normalized schema answers questions nobody had foreseen. The design is independent of the use.
In NoSQL the order is inverted:
- List the queries the application needs to serve, with their frequency and their latency requirement.
- Design the documents so that every frequent query is resolved in a single access.
- Accept whatever duplication is needed to achieve that.
- And afterwards check that the domain entities are still recognizable.
flowchart LR
subgraph REL["Relational model"]
R1["Domain<br/>entities"] --> R2["Normalized<br/>tables"] --> R3["Queries<br/>(any of them)"]
end
subgraph DOC["Document model"]
D1["Application<br/>queries"] --> D2["Tailor-made<br/>documents"] --> D3["Recognizable<br/>entities"]
end
This has an uncomfortable consequence that is best accepted early: the same domain admits completely different document designs depending on how it is queried. There is no such thing as "the correct model of a library" in MongoDB; there is the correct model for BiblioRed's portal with these queries. If the usage changes radically tomorrow, the model can become obsolete even though the domain has not changed.
That is why the first step of document modeling is not drawing entities, it is writing the list of queries. BiblioRed's, prioritized:
| # | Query | Frequency | Requirement |
|---|---|---|---|
| C1 | Full record of a material with its data and its 5 best reviews | Very high | < 50 ms |
| C2 | All the reviews of a material, paginated | High | < 100 ms |
| C3 | Reviews written by a member (their profile) | Medium | < 200 ms |
| C4 | Search materials by title, author or tag | Very high | < 100 ms |
| C5 | Publish a review / vote a review as helpful | Medium | < 100 ms |
| C6 | Record an activity event | Very high (write) | < 10 ms |
| C7 | A member's activity over a date range | Low | < 1 s |
| C8 | Most searched terms of the month | Low (report) | < 10 s |
Everything that follows is the answer to this table.
- The aggregate: unit of reading, writing and atomicity
We introduced the concept in lesson 03-01. Now we make it precise, because it is the main working tool.
An aggregate is a set of data that meets all three conditions at once:
- It is read together: the application almost never needs one part without the others.
- It is written together: changes affect the set coherently.
- It has a root: a main entity that gives the set its identity and through which it is accessed.
And from there comes the property that governs the whole design:
The aggregate is the boundary of atomicity. In MongoDB, writing a document is atomic: either it is applied in full or it is not applied at all. Everything inside the document is updated in one piece, with no transaction. Everything outside needs an explicit transaction or it will be exposed to temporary inconsistencies.
That sentence is the most useful design criterion you take away from this lesson. When you hesitate between putting something inside or outside, ask yourself: do I need this to change atomically together with the rest? If the answer is yes, inside.
An example at BiblioRed. A review with its text, its score, its tags and its vote count is an aggregate: when a reader edits their review, the text and the tags change at the same time, and nobody should see the new text with the old tags. The review and the material's record, on the other hand, do not form an aggregate: the record is edited from the librarian's panel, the review from the public portal, at different rhythms and by different people.
- The central decision: embed or reference
The whole practice of document modeling comes down to this question: when two entities are related, does the child go inside the parent's document or into its own collection with a reference?
Embedding
{
"_id": "MAT-0331",
"title": "The Map of Time",
"reviews": [
{ "member_id": 14, "name": "Marta Alsina", "score": 5, "text": "A novel that..." },
{ "member_id": 15, "name": "Iván Pereda", "score": 3, "text": "It starts very well..." }
]
}Referencing
{ "_id": "REV-1001", "material_id": "MAT-0331", "member_id": 14, "score": 5, "text": "A novel that..." }flowchart TD
subgraph EMB["EMBED"]
E1["Document MAT-0331<br/>title + reviews[ ]<br/>1 read, everything together"]
end
subgraph REF["REFERENCE"]
R1["catalog<br/>MAT-0331"] -.->|material_id| R2["reviews<br/>REV-1001, REV-1002, ...<br/>2 reads, grow without limit"]
end
The six decision criteria
Do not decide by intuition. Go through these six criteria in order:
| # | Criterion | Favors embedding | Favors referencing |
|---|---|---|---|
| 1 | Cardinality | Few children, with a known cap | Many or unlimited |
| 2 | Is the child queried on its own? | No: always with the parent | Yes: it has a life of its own |
| 3 | Volatility | The child changes little | The child changes a lot or grows constantly |
| 4 | Size | The set stays well below 16 MB | It approaches or exceeds it |
| 5 | Growth | Bounded by nature | Unlimited over time |
| 6 | Atomicity | Must change together with the parent | Can change independently |
Criterion 5 deserves a special warning, because it is the one that has broken the most systems: anything that grows over time with no cap ends up being a problem if it is embedded. The reviews of a popular book, activity events, the messages of a chat, the loan history. Even if there are three today, in five years there will be thousands. And there is a cost you do not see coming: MongoDB, when updating a document that has grown, may have to rewrite it whole on disk. An 8 MB document to which a 200-byte element is added costs 8 MB to rewrite.
A very practical auxiliary criterion
If the child makes no sense without the parent and disappears with it, it almost always goes embedded. An address without its member means nothing: inside. A review, on the other hand, has an identity of its own —it is linked, voted on, reported, it appears on its author's profile—: outside.
- One-to-few, one-to-many, one-to-squillions
The fastest way to apply the six criteria is to classify the relationship by its cardinality. It is the most quoted rule of thumb in document modeling and it works surprisingly well.
| Type | Indicative cardinality | Recommendation | Example at BiblioRed |
|---|---|---|---|
| One-to-few | Up to ~100, with a natural cap | Embed the complete object | A material and its 3–8 tags; a material and its cover data; a member and their 2 addresses |
| One-to-many | Hundreds or thousands, with moderate growth | Reference, and keep in the parent a subset of the most relevant children | A material and its reviews; a material and its copies |
| One-to-squillions | Tens of thousands or unlimited | Reference from the child, never keep the list in the parent | A member and their activity events; a material and its views |
The difference between the last two rows is subtle and crucial. In one-to-many, the parent can keep the list of its children's identifiers, because it fits. In one-to-squillions, that list would be an array of 40,000 elements growing every day: the link must go only in the child, pointing upwards.
Applied to BiblioRed, case by case:
Material and tags → embed. A material has between three and eight tags, they do not grow out of control, they are always displayed with the record and they are not queried separately (search by tag is resolved with an index over the embedded array, not with another collection). All six criteria point to embedding.
Material and reviews → reference, with a subset. Reviews grow without a cap, they are queried separately (member profile, moderation), they change often (helpful votes, edits, replies) and they are the main entity of query C2. They go into their own collection. But query C1 —the most frequent in the system— wants the record with its best reviews in a single access, so the material's document also keeps a copy of the top five. This is the subset pattern of section 7.
Material and copies → neither one nor the other. The physical copies, with their status and their branch, stay in PostgreSQL. They are intimately tied to loans, which are transactional. MongoDB's catalog keeps only a denormalized availability counter so it can render "3 available at Central" without querying the other database. Recognizing that a piece of data must not migrate is a modeling decision too.
Member and activity → one-to-squillions. Each event is a document (or better, an element inside a bucket document, section 8) pointing at the member. The member's document keeps no list of events.
- Controlled duplication and how to keep it consistent
In module 2 redundancy was the enemy. In document modeling it is a tool. But it is a sharp tool, and you have to hold it by the handle.
Duplication is justified when the cost of maintaining it is lower than the cost of rebuilding the data on every read. And that calculation almost always depends on a single question: how often does the duplicated data change, compared with how often it is read?
There are three categories of duplicated data, and they are treated differently:
Category A: data that is immutable by nature
It never changes, so duplicating it is free.
A book's ISBN and its year of publication are fixed. Copy them without remorse.
Category B: historical data, which must be frozen
Here duplication is not an optimization: it is semantic correctness.
{
"_id": "REV-1001",
"member": { "member_id": 14, "display_name": "Marta Alsina" },
"date": "2026-03-14T10:25:00Z"
}If Marta changes her surname in 2027, do her 34 reviews have to be rewritten? It depends on what the field represents:
- If it is "the name shown now", yes: it has to be propagated.
- If it is "who signed this in March 2026", no: it is a historical fact and rewriting it would be falsifying it.
It is the same distinction as on an invoice: the price of the product at the moment of sale is copied into the invoice line and never touched again, even though the catalog changes tomorrow. Explicitly deciding which category each duplicated field falls into —and writing it in the project documentation— avoids arguments and errors later on.
Category C: live data that has to be propagated
These are the dangerous ones. A material's title appears in its reviews; if the librarian corrects it, there are reviews with the old title.
Three strategies, with their cost:
| Strategy | How it works | When to use it |
|---|---|---|
| Immediate propagation | On changing the original, an updateMany updates every copy |
The data changes rarely and the copies are few |
| Deferred propagation | The change is queued and a background process applies it | Many copies; a lag of minutes is tolerable |
| No propagation, with a re-read | The copy is only a hint; the critical view re-reads the original | The lag is unacceptable at some specific point |
// Immediate propagation: the librarian fixes a title with a typo
db.catalog.updateOne(
{ _id: "MAT-0331" },
{ $set: { title: "The Map of Time" } }
)
// ...and the copies in the reviews are updated next
db.reviews.updateMany(
{ material_id: "MAT-0331" },
{ $set: { material_title: "The Map of Time" } }
){ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
{ acknowledged: true, matchedCount: 47, modifiedCount: 47 }And here the price of NoSQL we announced in 03-01 appears in all its rawness: those two operations are not atomic with respect to each other. If the process goes down between the first and the second, the catalog has the new title and 47 reviews have the old one. In PostgreSQL this problem would simply not exist, because the title would be in a single place.
The available defenses: wrapping both writes in a MongoDB transaction (possible since 2018, with a performance cost), or —more commonly— designing the system so that the temporary lag is tolerable and scheduling a periodic reconciliation process that detects and corrects the divergences.
Golden rule: duplicate only what is displayed, never what is used to decide. Duplicating a material's title to render it in a list is reasonable. Duplicating its price or its availability status to make a business decision based on the copy is not.
- Pattern: extended reference
Problem. Referencing is correct, but it forces a second query in order to display four fields from the referenced document.
Solution. Alongside the reference, copy the few fields the view needs. Not all of them and not none of them: the ones that get rendered.
// WITHOUT the pattern: two queries for every review displayed
db.reviews.find({ material_id: "MAT-0331" })
db.catalog.findOne({ _id: "MAT-0331" }) // only to learn the title and the cover
// WITH an extended reference: a single query
db.reviews.findOne({ _id: "REV-1001" }){
"_id": "REV-1001",
"material": {
"material_id": "MAT-0331",
"title": "The Map of Time",
"cover": "/img/catalog/0331-s.webp",
"type": "book"
},
"member": { "member_id": 14, "display_name": "Marta Alsina" },
"score": 5,
"text": "A novel that plays with time without making the reader dizzy."
}Field selection criterion: copy what is stable and displayed. The title and the type are stable; the cover changes rarely. Do not copy the synopsis (long and editable) or the number of available copies (it changes with every loan). That is what the reference is for.
When to apply it: it is the most used and most useful pattern in document modeling. Any list showing elements from two collections is a candidate.
- Pattern: subset
Problem. The material's document could contain all of its reviews, but a popular title has 800 and the record only shows the best five. Embedding 800 documents in order to render five means loading 160 times more data than necessary in the system's most frequent query.
Solution. Keep in the parent a copy of the subset the view needs, and the complete set in its own collection.
{
"_id": "MAT-0331",
"title": "The Map of Time",
"rating": { "average": 4.3, "total_reviews": 812 },
"featured_reviews": [
{ "review_id": "REV-1001", "name": "Marta Alsina", "score": 5,
"excerpt": "A novel that plays with time without making the reader dizzy.", "helpful_votes": 41 },
{ "review_id": "REV-1244", "name": "Nuria Bastos", "score": 5,
"excerpt": "The Victorian setting is beautifully rendered.", "helpful_votes": 33 },
{ "review_id": "REV-1533", "name": "Iván Pereda", "score": 4,
"excerpt": "You enjoy it more if you know Wells's novel.", "helpful_votes": 28 }
]
}With that, query C1 —the full record, the most frequent in the portal— is a single findOne. Anyone clicking "see all 812 reviews" will make a second query against reviews, but only a fraction of visitors do that.
How the subset is maintained. Every time a review gains votes, a check is made on whether it should enter the featured list:
// Recompute a material's featured list after a change in its reviews
const top = db.reviews.find(
{ material_id: "MAT-0331", status: "published", spoiler: false },
{ _id: 1, "member.display_name": 1, score: 1, text: 1, helpful_votes: 1 }
).sort({ helpful_votes: -1 }).limit(3).toArray()
db.catalog.updateOne(
{ _id: "MAT-0331" },
{ $set: { featured_reviews: top.map(r => ({
review_id: r._id,
name: r.member.display_name,
score: r.score,
excerpt: r.text.substring(0, 140),
helpful_votes: r.helpful_votes
})) } }
)This recomputation does not have to be immediate: it can run every few minutes in the background. Nobody minds a review taking ten minutes to appear in the featured list, and in exchange you avoid running the recomputation on every single vote.
When to apply it: whenever the main view needs only the first N of a large collection.
- Pattern: bucket
Problem. The activity log generates 17 million events a year. One document per event means 17 million tiny documents, each with the overhead of its _id, its index entry and its internal metadata. In many cases, the overhead weighs more than the data.
Solution. Group the events of the same entity and the same period into a single container document.
{
"_id": "ACT-14-2026-08-02",
"member_id": 14,
"day": "2026-08-02",
"branch_id": 1,
"event_count": 4,
"first_event": "2026-08-02T09:14:02Z",
"last_event": "2026-08-02T09:31:55Z",
"events": [
{ "t": "2026-08-02T09:14:02Z", "type": "search", "term": "jules verne" },
{ "t": "2026-08-02T09:14:31Z", "type": "record", "material_id": "MAT-0331" },
{ "t": "2026-08-02T09:22:10Z", "type": "filter", "field": "language", "value": "ca" },
{ "t": "2026-08-02T09:31:55Z", "type": "record", "material_id": "MAT-0412" }
]
}Inserting an event becomes a single operation that creates the container if it does not exist and adds the event if it does:
db.activity.updateOne(
{ _id: "ACT-14-2026-08-02" },
{
$push: { events: { t: new Date(), type: "record", material_id: "MAT-0508" } },
$inc: { event_count: 1 },
$max: { last_event: new Date() },
$setOnInsert: { member_id: 14, day: "2026-08-02", branch_id: 1 }
},
{ upsert: true }
)upsert: true is the key: if the day's document does not exist, it is created; if it exists, it is updated. $setOnInsert sets the fixed fields only on creation, so they are not rewritten every time.
The numbers behind the change, with BiblioRed's estimates:
| One document per event | With bucketing by member and day | |
|---|---|---|
| Documents per year | ~17,000,000 | ~624,000 (12,000 members × ~52 active days) |
| Index entries per event | 1 or more | ~0.04 |
| Overhead space | Very high | Low |
| "Member 14's activity on 2 August" | Find N documents | One findOne |
| Writing an event | insertOne |
updateOne with upsert |
How to choose the container size. Per member and day is the natural choice at BiblioRed. But a very heavy user could generate hundreds of events a day, so a cap is needed: when a container reaches, say, 500 events, another one is opened (ACT-14-2026-08-02-2). A container with no cap becomes an unbounded array, which is precisely the anti-pattern of the next section.
When to apply it: series of events, telemetry, measurements, audit logs. It is the document answer to the problem we assigned to Cassandra in 03-02, and the reason BiblioRed can solve it without deploying another database.
- Pattern: outlier
Problem. 99.8% of BiblioRed's materials have fewer than 50 reviews, and for them embedding them would be perfect. But three or four bestsellers have thousands. If you design for the extreme case, you penalize the 39,996 normal materials; if you design for the normal case, the four extremes break the system.
Solution. Design for the common case and flag the exceptional ones with a flag that activates an alternative path.
{
"_id": "MAT-0412",
"title": "The Pillars of the Earth",
"reviews": [ "...the first 50, embedded..." ],
"reviews_overflow": true,
"total_reviews": 1240
}// The application queries according to the flag
const mat = db.catalog.findOne({ _id: "MAT-0412" })
let reviews = mat.reviews
if (mat.reviews_overflow) {
reviews = db.reviews.find({ material_id: mat._id })
.sort({ helpful_votes: -1 }).limit(50).toArray()
}Cost: the application has two read paths, and that is real complexity that has to be documented and tested. That is why this pattern is applied only when the distribution really is asymmetric and the extreme case is a tiny minority. If 20% of materials overflow, there is no outlier: there is a bad design and it is time to reference for everybody.
- Pattern: computed field
Problem. A material's record shows its average score. Computing it on every visit means going through its 812 reviews with an aggregation, for a page that is requested thousands of times a day.
Solution. Store the already computed result in the document and update it when the source data changes.
{
"_id": "MAT-0331",
"title": "The Map of Time",
"rating": {
"average": 4.3,
"total_reviews": 812,
"score_sum": 3492,
"distribution": { "1": 12, "2": 31, "3": 88, "4": 264, "5": 417 },
"updated": "2026-08-02T09:00:00Z"
}
}Notice score_sum: storing the sum as well as the average makes it possible to update it incrementally, without going through anything.
// A new review arrives with a score of 5
db.catalog.updateOne(
{ _id: "MAT-0331" },
{
$inc: {
"rating.total_reviews": 1,
"rating.score_sum": 5,
"rating.distribution.5": 1
},
$currentDate: { "rating.updated": true }
}
)
// The average is recomputed from two numbers, not from 813 documents
db.catalog.updateOne(
{ _id: "MAT-0331" },
[ { $set: { "rating.average": {
$round: [ { $divide: ["$rating.score_sum", "$rating.total_reviews"] }, 2 ] } } } ]
)
db.catalog.findOne({ _id: "MAT-0331" }, { _id: 0, rating: 1 }){
rating: {
average: 4.3,
total_reviews: 813,
score_sum: 3497,
distribution: { '1': 12, '2': 31, '3': 88, '4': 264, '5': 418 },
updated: ISODate('2026-08-02T09:47:22.108Z')
}
}Risk: the computed field can drift away from reality if some write fails or if a review is deleted without being discounted. The usual defense: a nightly process that recomputes from scratch and corrects. A counter that only goes up never finds its way back on its own.
When to apply it: when the read/write ratio is very high. Here it is thousands to one; the pattern pays for itself.
- Anti-patterns you have to recognize
11.1 Unbounded arrays
The number one anti-pattern, and the easiest one to commit.
{
"_id": "MAT-0412",
"title": "The Pillars of the Earth",
"views": [ "...41,238 elements and counting..." ]
}What happens, in order of appearance: the document grows until it approaches the 16 MB limit; every $push forces the rewriting of an ever-larger document; reads transfer megabytes in order to use two fields; the indexes over the array explode in size; and one day a write fails with BSONObjectTooLarge and there is no quick fix.
Warning sign: if you cannot state the maximum number of elements an array will have, do not embed it. Reference or bucket.
11.2 Giant documents
Even with no unlimited arrays, a document can put on weight by accumulation: the full synopsis, the cover in base64, the text extracted from the PDF, the change history... all inside the material's record.
Consequence: every read of the record —to show the title and the thumbnail cover— transfers the whole document from disk to memory and from there to the network. The server's cache fills up with data nobody looks at, and documents that are actually used get evicted.
Rule: large data that is rarely queried goes into its own collection, or straight out of the database —images into an object store or a file system, with the URL in the document—.
11.3 Massive collections of tiny documents
The opposite extreme: 17 million documents of 80 bytes. The per-document overhead (identifier, index entries, metadata) exceeds the useful data, the indexes do not fit in memory and range queries force the reading of millions of scattered documents.
Solution: the bucket pattern of section 8.
11.4 Using MongoDB as if it were relational
It is the most expensive anti-pattern because it does not show its face: the system works, it just works worse than PostgreSQL would.
// Five normalized collections and a pipeline stitching them together with $lookup
db.reviews.aggregate([
{ $lookup: { from: "members", localField: "member_id", foreignField: "_id", as: "member" } },
{ $lookup: { from: "catalog", localField: "material_id", foreignField: "_id", as: "material" } },
{ $lookup: { from: "authors", localField: "material.author_id", foreignField: "_id", as: "author" } },
{ $lookup: { from: "tags", localField: "tag_ids", foreignField: "_id", as: "tags" } },
{ $unwind: "$member" }, { $unwind: "$material" }
])That code is a relational schema written in MongoDB, and it inherits the worst of both worlds: the slowness of joining without the optimizations of a mature relational planner, and the document database's lack of referential integrity. If your design ends up here, the right conclusion is not "the pipeline needs optimizing": it is "this domain wanted PostgreSQL".
$lookup is legitimate for occasional reports and batch processes. It is not legitimate as the habitual mechanism of the main read path.
| Anti-pattern | Symptom you will see | Fix |
|---|---|---|
| Unbounded array | Documents that grow endlessly; slow writes | Reference or bucket |
| Giant document | Reads that transfer a lot to use a little | Move the large data out |
| Massive tiny documents | Huge indexes, slow range queries | The bucket pattern |
| Relational in disguise | $lookup in every query |
Redesign or go back to SQL |
- Schema validation with
$jsonSchema
$jsonSchemaThe flexible schema is an advantage as long as the team is disciplined. MongoDB lets you recover part of the safety net voluntarily and gradually, which is exactly what is needed: strong rules where they matter, freedom where it suits.
db.createCollection("reviews", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["material_id", "member", "score", "text", "date", "status", "schema_v"],
properties: {
schema_v: { bsonType: "int", minimum: 1, description: "document version" },
material_id: { bsonType: "string", pattern: "^MAT-[0-9]{4}$" },
member: {
bsonType: "object",
required: ["member_id", "display_name"],
properties: {
member_id: { bsonType: "int", minimum: 1 },
display_name: { bsonType: "string", maxLength: 80 }
}
},
score: { bsonType: "int", minimum: 1, maximum: 5 },
text: { bsonType: "string", minLength: 10, maxLength: 4000 },
tags: { bsonType: "array", maxItems: 10, items: { bsonType: "string" } },
status: { enum: ["published", "pending", "hidden", "reported"] },
date: { bsonType: "date" }
}
}
},
validationLevel: "moderate",
validationAction: "error"
})Proof that it works:
db.reviews.insertOne({
material_id: "MAT-0331",
member: { member_id: 14, display_name: "Marta Alsina" },
score: 9, // outside the 1–5 range
text: "Short", // fewer than 10 characters
date: "2026-08-02", // a string, not a date
status: "published",
schema_v: 1
})MongoServerError: Document failed validation
Additional information: {
failingDocumentId: ObjectId('66ab21f35c9e1b2f3d4a6c90'),
details: {
operatorName: '$jsonSchema',
schemaRulesNotSatisfied: [
{ operatorName: 'properties', propertiesNotSatisfied: [
{ propertyName: 'score', description: 'maximum: 5', consideredValue: 9 },
{ propertyName: 'text', description: 'minLength: 10', consideredValue: 'Short' },
{ propertyName: 'date', description: 'bsonType: date', consideredValue: '2026-08-02' }
]}
]
}
}Three errors caught on the write, which is exactly where we wanted them. The one about the date as a string is the most valuable: it is the mistake from lesson 03-01 that ruins every later aggregation, and here it does not even get in.
The two behavior parameters:
| Parameter | Value | Effect |
|---|---|---|
validationLevel |
strict |
Validates every insert and every update |
moderate |
Validates inserts and only updates of documents that already complied | |
off |
Does not validate | |
validationAction |
error |
Rejects the write |
warn |
Accepts it and notes a warning in the log |
Recommended strategy for a collection that already has data: start with validationAction: "warn" to discover how many documents would fail without breaking anything, fix them, and only then move to error. And use moderate while there are still old documents to migrate, so as not to block updates of what does not yet comply.
What $jsonSchema does not do, so as not to create false expectations: it does not check that material_id points at an existing material. Referential integrity still does not exist. It validates shape, not references.
- Document versioning and schema evolution
In a relational database, changing the shape of the data is an ALTER TABLE that affects every row at once, with its lock and its window. In a document database there is a far more comfortable alternative: migrate nothing and live with several versions.
The technique is simple and it consists of one field:
{ "_id": "REV-1001", "schema_v": 2, "score": 5, "text": "...",
"tags": ["historical fiction"], "helpful_votes": 41, "spoiler": false }The application reads the field and knows what to expect:
function normalizeReview(doc) {
if (doc.schema_v === 1) {
return { ...doc, tags: [], helpful_votes: 0, spoiler: false, schema_v: 2 }
}
return doc
}Three migration strategies, in order of aggressiveness:
| Strategy | How it works | When |
|---|---|---|
| Lazy | The document is upgraded to the new version the next time it is written | The usual one: zero cost, progressive migration |
| In the background | A process goes through the collection in batches, updating as it goes | When you want it finished by a deadline, without stopping the service |
| Bulk | An updateMany over the whole collection |
Only if there are few documents or there is a downtime window |
// Lazy migration: when a new field is added, the version is updated
db.reviews.updateOne(
{ _id: "REV-0450" },
{ $set: { tags: [], helpful_votes: 0, spoiler: false, schema_v: 2 } }
)
// Background migration, in batches of 1000
db.reviews.updateMany(
{ schema_v: 1 },
{ $set: { tags: [], helpful_votes: 0, spoiler: false, schema_v: 2 } }
)// Check how the migration is going
db.reviews.aggregate([ { $group: { _id: "$schema_v", n: { $sum: 1 } } }, { $sort: { _id: 1 } } ])The indispensable warning: living with several versions has a cost, and it is the code that manages them. If the team accumulates six active versions, the normalization function becomes a maze of conditionals nobody dares touch. Live with two, maybe three, and close your migrations: when the count of an old version reaches zero, delete its branch of code.
- Indexes, briefly
Everything said about modeling assumes that the queries are resolved efficiently, and that depends on indexes in MongoDB just as much as in PostgreSQL.
The good news is that the logic is the same one you will learn in lesson 06-03: an index is an auxiliary structure —usually a B-tree— that avoids going through all the data; it speeds up reads, slows down writes, takes up space, and the order of the fields in a composite index determines which queries it can serve.
The document-specific quirks, in four lines:
- You can index any field, even a nested one:
db.reviews.createIndex({ "member.member_id": 1 }). - You can index the content of an array (a multikey index): one entry per element. That is what makes searching by tag fast.
- The TTL index deletes documents automatically after a period of time, and it is what BiblioRed will use to expire activity after two years.
explain()is the equivalent ofEXPLAINin SQL and it tells you whether the query used an index or went through the whole collection.
db.reviews.createIndex({ material_id: 1, helpful_votes: -1 })
db.reviews.createIndex({ tags: 1 })
db.activity.createIndex({ day: 1 }, { expireAfterSeconds: 63072000 }) // 2 yearsWe will not go any deeper: indexes have a lesson of their own in module 6.
- Deliverable: the final design of BiblioRed's collections
Here is the result of applying everything above. Three collections in MongoDB's bibliored database, with every decision justified.
15.1 The catalog collection
Aggregate root: the material. Answers: C1 (full record) and C4 (search).
{
"_id": "MAT-0331",
"schema_v": 2,
"type": "book",
"title": "The Map of Time",
"normalized_title": "the map of time",
"language": "es",
"publication_year": 2008,
"synopsis": "In the London of 1896, a young aristocrat seeks a way to travel into the past...",
"cover": { "large": "/img/catalog/0331-g.webp", "thumbnail": "/img/catalog/0331-s.webp" },
"authors": [
{ "author_id": 77, "full_name": "Félix J. Palma", "role": "author" }
],
"tags": ["historical fiction", "science fiction", "victorian", "award-winning"],
"metadata": {
"isbn": "9788401339097",
"publisher": "Ediciones Vallmar",
"page_count": 612,
"binding": "hardcover"
},
"availability": {
"total_copies": 6,
"by_branch": { "1": 2, "2": 1, "3": 2, "4": 1 },
"updated": "2026-08-02T06:00:00Z"
},
"rating": {
"average": 4.3,
"total_reviews": 812,
"score_sum": 3492,
"distribution": { "1": 12, "2": 31, "3": 88, "4": 264, "5": 417 }
},
"featured_reviews": [
{ "review_id": "REV-1001", "name": "Marta Alsina", "score": 5,
"excerpt": "A novel that plays with time without making the reader dizzy.", "helpful_votes": 41 },
{ "review_id": "REV-1244", "name": "Nuria Bastos", "score": 5,
"excerpt": "The Victorian setting is beautifully rendered.", "helpful_votes": 33 }
],
"created": "2024-11-03T10:00:00Z",
"active": true
}And a document of another type, in the same collection, so the heterogeneity is visible:
{
"_id": "MAT-0802",
"schema_v": 2,
"type": "magazine",
"title": "Vallmar Cultural",
"normalized_title": "vallmar cultural",
"language": "ca",
"publication_year": 2026,
"cover": { "thumbnail": "/img/catalog/0802-s.webp" },
"tags": ["local culture", "periodicals archive"],
"metadata": {
"issn": "2604-1188",
"issue": 42,
"volume": 7,
"frequency": "monthly"
},
"availability": { "total_copies": 4, "by_branch": { "1": 1, "2": 1, "3": 1, "4": 1 } },
"rating": { "average": 0, "total_reviews": 0, "score_sum": 0 },
"featured_reviews": [],
"created": "2026-05-11T09:04:00Z",
"active": true
}| Decision | Justification |
|---|---|
Natural _id "MAT-0331" |
Readable in logs and URLs; saves an extra index |
metadata as a free subdocument |
Isolates what is specific to each material type; adding "comic" in 2027 does not touch the rest of the document |
authors embedded with the name |
One-to-few + extended reference: the record shows the name without going to another collection |
tags as an array of strings |
One-to-few with a cap; a multikey index resolves C4 |
featured_reviews |
Subset pattern: C1 is resolved with a single findOne |
rating with sum and distribution |
Computed field pattern, updatable with $inc without going through anything |
Denormalized availability |
A read-only copy from PostgreSQL, refreshed hourly; the truth remains in copies |
normalized_title |
Without accents or capitals, for accent-insensitive searches |
| Embedded synopsis | It is a few kilobytes of text and it is shown on the same record: it does not justify another collection |
| Covers as paths, not as binaries | Giant document anti-pattern avoided: the images live outside |
Indexes: { normalized_title: 1 }, { tags: 1 }, { type: 1, "rating.average": -1 }, { "authors.author_id": 1 }.
15.2 The reviews collection
Aggregate root: the review. Answers: C2, C3 and C5.
{
"_id": "REV-1001",
"schema_v": 2,
"material": {
"material_id": "MAT-0331",
"title": "The Map of Time",
"type": "book",
"cover": "/img/catalog/0331-s.webp"
},
"member": {
"member_id": 14,
"display_name": "Marta Alsina",
"branch_id": 1
},
"score": 5,
"text": "A novel that plays with time without making the reader dizzy. The Victorian setting is beautifully rendered and the three acts stand up on their own.",
"tags": ["historical fiction", "science fiction"],
"spoiler": false,
"status": "published",
"helpful_votes": 41,
"voters": [15, 16, 22, 31],
"librarian_reply": {
"branch_id": 1,
"text": "If you liked it, we have the sequel available at the Central branch.",
"date": "2026-03-16T09:10:00Z"
},
"date": "2026-03-14T10:25:00Z",
"edited": null
}| Decision | Justification |
|---|---|
Its own collection, not embedded in catalog |
One-to-many with no cap, with a life of its own (profile, moderation) and high volatility |
material as an extended reference |
Four stable fields make it possible to render the member's profile (C3) without querying catalog |
Duplicated member.display_name |
Category B: it is "who signed this", a historical fact frozen on purpose |
helpful_votes + voters embedded |
A vote has to change the counter and the list atomically: they go inside the aggregate |
voters with only the member_id |
An array bounded in practice; if it ever overflowed, it would move to its own collection (outlier) |
librarian_reply as an optional subdocument |
Zero or one per review; the field simply does not exist if there is none |
status with closed values |
Allows moderation without deleting; validated with enum in $jsonSchema |
Embedded tags |
One-to-few with a cap of 10 imposed by the validation |
Indexes: { "material.material_id": 1, helpful_votes: -1 }, { "member.member_id": 1, date: -1 }, { status: 1, date: -1 }, { tags: 1 }.
15.3 The activity collection
Aggregate root: the set of a member's events on one day. Answers: C6, C7 and C8.
{
"_id": "ACT-14-2026-08-02",
"schema_v": 1,
"member_id": 14,
"day": "2026-08-02T00:00:00Z",
"branch_id": 1,
"event_count": 5,
"first_event": "2026-08-02T09:14:02Z",
"last_event": "2026-08-02T09:41:07Z",
"events": [
{ "t": "2026-08-02T09:14:02Z", "type": "search", "term": "jules verne", "results": 7 },
{ "t": "2026-08-02T09:14:31Z", "type": "record", "material_id": "MAT-0331" },
{ "t": "2026-08-02T09:22:10Z", "type": "filter", "field": "language", "value": "ca" },
{ "t": "2026-08-02T09:31:55Z", "type": "record", "material_id": "MAT-0412" },
{ "t": "2026-08-02T09:41:07Z", "type": "reservation", "material_id": "MAT-0412" }
]
}| Decision | Justification |
|---|---|
| Bucket pattern by member and day | From ~17M documents a year to ~624K; C7 is resolved with a findOne |
Composite _id "ACT-<member>-<date>" |
Deterministic: it allows the upsert without a prior lookup |
Short field names (t) inside events |
In an array of thousands of elements, field names repeat in every one and add weight |
Counters event_count, first_event, last_event |
They answer without opening the array |
| A cap of 500 events per container | Prevents the unbounded array anti-pattern; on exceeding it, a -2 is opened |
branch_id copied from the member |
Allows the per-branch report without querying PostgreSQL |
TTL index on day at 2 years |
Old activity deletes itself; expiration as a property of the data |
No reference to reviews or catalog |
Activity is an immutable log: it needs consistency with nothing |
Indexes: { member_id: 1, day: -1 }, { day: 1 } with expireAfterSeconds: 63072000, { "events.material_id": 1 }.
15.4 The complete map
flowchart TD
subgraph PG["PostgreSQL - biblioredb (the transactional truth)"]
T["branches - members - authors - books<br/>copies - loans - reservations"]
end
subgraph MG["MongoDB - bibliored (content and activity)"]
C["catalog<br/>_id MAT-nnnn"]
R["reviews<br/>_id REV-nnnn"]
A["activity<br/>_id ACT-member-date"]
end
T -->|hourly synchronization:<br/>availability| C
R -->|subset pattern:<br/>featured_reviews| C
R -.->|extended reference:<br/>material_id| C
A -.->|reference:<br/>material_id, member_id| C
Look at the style of the arrows: the solid ones are copies of data that have to be kept consistent; the dashed ones are references the application resolves when needed. Every solid arrow is a consistency responsibility somebody has to take on in the code, and that is why it is best that they are few and documented. Here there are exactly two.
Common Mistakes and Tips
Mistake 1: starting by drawing entities.
It is the reflex we bring from the relational model and here it leads to normalized collections stitched together with $lookup. Always start from the list of queries.
Mistake 2: embedding "because that is what you do in MongoDB". Embedding is right for one-to-few. For one-to-many and one-to-squillions it is a time bomb that goes off in production, not in development.
Mistake 3: duplicating without deciding the data's category. Every duplicated field has to be classified: immutable, frozen historical or live with propagation. If you do not write it down, in six months nobody will know whether it has to be updated.
Mistake 4: trusting that the flexible schema documents itself.
It does not. Write the $jsonSchema even if you set it to warn: it is executable documentation of the collection's contract.
Mistake 5: testing the design only with toy data. A design with 200 documents always looks good. Generate 500,000 synthetic documents with the real expected distribution —outliers included— and measure before signing off on anything.
Mistake 6: forgetting that the application is now the one responsible.
There is no FOREIGN KEY preventing a review about a nonexistent material. If deleting a material has to drag its reviews along, you are the one who writes that CASCADE.
Tip 1: write a decision sheet for every collection. Aggregate root, the queries it serves, what is embedded and why, what is duplicated and of which category, indexes. Half a page per collection that saves weeks.
Tip 2: name fields consistently, and short where they repeat a lot.
Inside an array of thousands of elements, t instead of timestamp saves real megabytes. Outside of that, prioritize readability.
Tip 3: put the schema_v field in from the very first document.
It costs four bytes and the day you need it —and you will need it— it will save you a blind bulk migration.
Tip 4: review the design when the queries change, not when the domain changes. It is the corollary of section 1 and the most important warning in the lesson: in the document world, a change in how the data is used can force a redesign even though the data is the same.
Exercises
Exercise 1
BiblioRed wants to add book clubs: groups with a name, a meeting branch, between 8 and 25 member members, one book assigned each month and a comment thread for each book read (some 30–60 comments per book, and the club may have been running for years). Decide for each relationship whether you embed or reference, justifying it with the six criteria of section 3, and write the example document for the clubs collection.
Exercise 2
This document has four design problems. Identify them, say which anti-pattern or bad criterion each one represents and propose the corrected design.
{
"_id": ObjectId("..."),
"member_id": 14,
"name": "Marta Alsina",
"email": "marta.alsina@example.org",
"profile_photo_base64": "iVBORw0KGgoAAAANSUhEUgAAB...(1.8 MB)...",
"loan_history": [ "...318 loans since 2019, one per element..." ],
"browsing_events": [ "...11,402 events, one per element..." ],
"written_reviews": [ "...34 reviews with their full text duplicated..." ]
}Exercise 3
Write the $jsonSchema validation for the activity collection designed in section 15.3, requiring: a mandatory positive integer member_id; a mandatory day of date type; an integer event_count between 0 and 500; a mandatory events array of at most 500 elements, where each element must have t (a date) and type (one of search, record, filter, reservation, download). Then explain why it would be advisable to deploy it with validationAction: "warn" rather than with "error".
Solutions
Solution 1
A club and its members → embed. Cardinality of 8 to 25, with a natural cap imposed by the club's own rules (criterion 1: few). They are always shown with the club's record and they are not queried separately (criterion 2). They change little: somebody joins or leaves a few times a year (criterion 3). They take up a few kilobytes (criterion 4). Growth is bounded by the maximum number of seats (criterion 5). And joining and leaving have to stay consistent with the count of free seats, which favors atomicity within the same document (criterion 6). Six out of six in favor of embedding, with an extended reference to the member's name.
A club and its reading calendar → embed, but keep an eye on it. Twelve books a year. After five years that is sixty elements: it is still one-to-few and it fits comfortably. It is shown in full on the club's record ("what we have read"). It gets embedded, with a note that if a club ever exceeded ~200 elements it would be worth separating the old history.
A club and each book's comments → reference.
Here criterion 5 rules: 40 comments per book × 12 books a year × several years is growth with no cap (criteria 1 and 5). They also have a life of their own —they are moderated, replied to, linked— (criterion 2), they change frequently (criterion 3) and they do not need to change atomically with the club (criterion 6). Their own club_comments collection, with the subset of the three most recent embedded in the club so "latest activity" can be shown without a second query.
{
"_id": "CLUB-007",
"schema_v": 1,
"name": "Vallmar Tuesdays",
"branch_id": 3,
"meeting_day": "tuesday",
"meeting_time": "19:00",
"seats": 25,
"active": true,
"members": [
{ "member_id": 14, "display_name": "Marta Alsina", "created": "2025-09-02T00:00:00Z", "role": "coordinator" },
{ "member_id": 16, "display_name": "Nuria Bastos", "created": "2025-10-07T00:00:00Z", "role": "member" },
{ "member_id": 15, "display_name": "Iván Pereda", "created": "2026-01-13T00:00:00Z", "role": "member" }
],
"member_count": 3,
"calendar": [
{ "month": "2026-06", "material_id": "MAT-0331", "title": "The Map of Time", "status": "read" },
{ "month": "2026-07", "material_id": "MAT-0412", "title": "The Pillars of the Earth", "status": "read" },
{ "month": "2026-08", "material_id": "MAT-0508", "title": "The Shadow of the Lighthouse", "status": "in progress" }
],
"latest_comments": [
{ "comment_id": "COM-4471", "member_id": 16, "display_name": "Nuria Bastos",
"material_id": "MAT-0412", "excerpt": "The cathedral chapter is worth rereading.",
"date": "2026-07-28T20:14:00Z" }
],
"total_comments": 187,
"created": "2025-09-02T00:00:00Z"
}Solution 2
| # | Problem | Anti-pattern or criterion broken | Fix |
|---|---|---|---|
| 1 | A 1.8 MB profile_photo_base64 inside the document |
Giant document: every read of the member, even when only the name is wanted, transfers 1.8 MB and evicts the cache | Store the image in an object store or in the file system and keep only the path: "photo": "/img/members/14.webp" |
| 2 | loan_history with 318 elements and growing |
Unbounded array + data that should not be here: loans are transactional and live in PostgreSQL | Remove it from the document. If a summary is needed for the profile, a computed field: "stats": { "total_loans": 318, "last": "2026-07-19" } |
| 3 | browsing_events with 11,402 elements |
Unbounded array in its most serious form: it grows every day and without limit. It is one-to-squillions | An activity collection with the bucket pattern (section 15.3). The member's document keeps no list at all |
| 4 | written_reviews with the full text duplicated |
Category C duplication badly applied: the text is bulky, editable and already lives in reviews; every edit would force updating two places |
Reference. If the profile needs to show the latest ones, apply the subset pattern with only review_id, the material's title, the score and an excerpt |
{
"_id": 14,
"schema_v": 2,
"display_name": "Marta Alsina",
"email": "marta.alsina@example.org",
"branch_id": 1,
"photo": "/img/members/14.webp",
"preferences": { "language": "es", "email_notifications": true },
"stats": {
"total_loans": 318,
"total_reviews": 34,
"last_loan": "2026-07-19T00:00:00Z",
"updated": "2026-08-02T06:00:00Z"
},
"recent_reviews": [
{ "review_id": "REV-1001", "material_id": "MAT-0331", "title": "The Map of Time",
"score": 5, "excerpt": "A novel that plays with time without making the reader...",
"date": "2026-03-14T10:25:00Z" }
]
}A deliberate detail: the _id is 14, the same identifier as PostgreSQL's member_id. When an entity exists in both databases, sharing the identifier is the decision that causes the fewest headaches.
Solution 3
db.runCommand({
collMod: "activity",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["member_id", "day", "event_count", "events", "schema_v"],
properties: {
schema_v: { bsonType: "int", minimum: 1 },
member_id: { bsonType: "int", minimum: 1 },
day: { bsonType: "date" },
branch_id: { bsonType: "int", minimum: 1, maximum: 4 },
event_count: { bsonType: "int", minimum: 0, maximum: 500 },
events: {
bsonType: "array",
maxItems: 500,
items: {
bsonType: "object",
required: ["t", "type"],
properties: {
t: { bsonType: "date" },
type: { enum: ["search", "record", "filter", "reservation", "download"] },
term: { bsonType: "string", maxLength: 200 },
material_id: { bsonType: "string", pattern: "^MAT-[0-9]{4}$" },
field: { bsonType: "string" },
value: { bsonType: "string" },
results: { bsonType: "int", minimum: 0 }
}
}
}
}
}
},
validationLevel: "moderate",
validationAction: "warn"
})Notice two things about the design of the validation. First, maxItems: 500 turns the cap that in section 15.3 was only a convention into a rule checked by the server: the unbounded array stops being possible by oversight. Second, the fields term, material_id, field and value are not in required: each type of event uses some and not others, and that heterogeneity is precisely what makes this data live in MongoDB.
Why deploy with warn first. The collection already has data written before the validation existed, and it is practically certain that some of it does not comply: old events with t stored as a string, a type that was called "view" before it was renamed, containers generated in testing with more than 500 elements. With validationAction: "error" those writes would start failing all at once in production and on the system's most frequent write path (C6, event logging, with a requirement of under 10 ms). With warn, MongoDB accepts the write and notes the breach in the log: over a few days the warnings are collected, the problem is quantified, the old documents and the code paths generating them are fixed, and only then do you move to error with the certainty that nothing will break.
And validationLevel: "moderate" complements the strategy: while the cleanup lasts, updates of documents that already failed are not blocked, so the correction process can work without fighting its own validation.
Conclusion
This has been the design lesson, and its content can be summarized like this:
- The order is inverted: in the relational model you model the domain and then query it; in the document model you start from the list of queries and design documents that resolve them in a single access. The same domain admits different designs depending on its use.
- An aggregate is read together, written together and has a root. And above all: it is the boundary of atomicity. What is inside the document changes in one piece; what is outside needs a transaction or tolerance of lag.
- Embed or reference is decided with six criteria: cardinality, whether the child is queried separately, volatility, size against the 16 MB limit, bounded or unlimited growth, and the need for joint atomicity.
- One-to-few → embed. One-to-many → reference with a subset in the parent. One-to-squillions → reference only from the child, with no list in the parent.
- Duplication is a tool, not a mistake, and every duplicated field belongs to a category: immutable (free), frozen historical (semantically correct) or live (requiring immediate propagation, deferred propagation or a re-read). Duplicate what is displayed, never what is used to decide.
- Five patterns: extended reference (copy the few fields that get rendered), subset (the first N in the parent), bucket (series of events in containers per period, with
upsertand$setOnInsert), outlier (a flag for the overflowing minority) and computed field (store the aggregate and maintain it with$inc). - Four anti-patterns: unbounded arrays, giant documents, massive collections of tiny documents and MongoDB used as a relational database by way of
$lookup—whose correct diagnosis is usually "this wanted PostgreSQL"—. $jsonSchemavoluntarily and gradually recovers part of the lost safety net: types, ranges, required fields, patterns and closed value sets, with adjustablevalidationLevelandvalidationAction. It does not validate references: referential integrity is still the application's business.- Versioning with
schema_vallows evolution without a bulk migration, with lazy, background or bulk strategies. Live with two or three versions and close your migrations. - And the deliverable:
catalog(rooted in the material, with freemetadataper type, computedrating,featured_reviewsas a subset andavailabilitycopied from PostgreSQL),reviews(rooted in the review, withmaterialandmemberas extended references and votes embedded for atomicity) andactivity(rooted in the member-day set, with the bucket pattern, a deterministic_id, a cap of 500 events and a TTL index at two years).
We now have the course's two halves built: biblioredb in PostgreSQL and bibliored in MongoDB, each with the design it deserves. In lesson 03-04, Comparison between Relational and Non-Relational Databases, we put them face to face dimension by dimension and add the theoretical pieces we have been postponing: the CAP theorem without the usual misunderstanding and its refinement PACELC, the contrast between ACID and BASE, what eventual consistency really means for the reader who has just published a review, MongoDB's tunable consistency levels, and the decisive nuance that the border has blurred —PostgreSQL stores documents with jsonb and MongoDB has multi-document transactions—. We will close with an honest decision guide, the most repeated myths dismantled and the name of the architecture BiblioRed has arrived at: polyglot persistence.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
