Escena Viva already persists to MongoDB and the controllers do not know it exists. Now comes the part of the job that separates an application that works from an application that holds up: how data relates to itself and how you query it once the questions stop being trivial.
We will make the most important modeling decision in the document world — embed or reference — see how populate really works and why it is not a JOIN, measure the N+1 problem, defend a deliberate denormalization, build Escena Viva's reports with the aggregation framework — retiring the ones we produced in Module 3 by reading CSV files — and learn to look at indexes with explain() instead of guessing.
Contents
- Embedding versus referencing
- Escena Viva's decisions, justified one by one
- References with
refandpopulate - The N+1 problem: measuring it and solving it
- Deliberate denormalization
- The aggregation framework as a pipeline
- Real Escena Viva reports
$lookupversuspopulate- Indexes taken seriously, and
explain() - Partial indexes, write cost, text and geospatial
Embedding versus referencing
In a relational database this decision does not exist: everything is split into tables and joined with a JOIN. In MongoDB you have both options, and choosing badly is the number-one cause of projects that "get slow for no apparent reason".
Embedding means putting the child data inside the parent document, like our sessions inside the Event. Referencing means storing them in another collection and keeping only their identifier, like Order.userId.
| Criterion | Embed if... | Reference if... |
|---|---|---|
| Cardinality | Few children (dozens), a bounded number | Many children, with no known ceiling |
| Size | The document stays small | The set could approach 16 MB |
| Joint access | Parent and child are almost always read together | The child is queried on its own |
| Volatility | The children change rarely, or with the parent | The children change far more often than the parent |
| Sharing | The child belongs to a single parent | Several parents reference the same child |
| Independent querying | You never look for children without knowing the parent | You list, paginate or filter children globally |
The 16 MB per document limit is not a recommendation: it is a hard BSON limit. A document that grows indefinitely will hit it, and the failure will arrive in production, with real data, on some ordinary day. But it hurts long before that: MongoDB rewrites the whole document on every update, so a 2 MB document to which you add a small element moves 2 MB of disk and network. There is a third way, the subset pattern: embed the few children that are always displayed (the last 3 reviews) and reference the rest.
Escena Viva's decisions, justified one by one
Sessions are embedded in the event. Bounded cardinality: between 1 and 7 in our catalog, maybe 60 for a play in a long run. Ridiculous size: five fields, around 150 bytes. Always accessed together: GET /events/evt-001 returns the event with its sessions and the web page shows all of them. They are not shared: a session makes no sense outside its event. The only serious objection is volatility — sold changes on every purchase and that rewrites the document — but since it is tiny the cost is negligible. Embedding wins clearly.
Orders are referenced. Growth with no ceiling: a festival can generate 20,000. Inside the event, the document would grow until it burst and every purchase would rewrite megabytes. On top of that they are queried on their own ("my orders", "today's orders") without starting from the event, and they have their own life cycle with four statuses.
Tickets are referenced. It is the highest-volume entity and they are accessed by their code at the door, individually, without knowing the order. They have their own cycle (valid → used | cancelled).
The user is referenced from the order. A user has N orders and we do not want to duplicate their name and email in each one: if the email changes, every one of them would have to be rewritten.
graph TD
subgraph events["Collection: events"]
E["Event evt-001<br/>Concierto de Otono<br/>Teatro Almendra"]
S1["session ses-001-1<br/>capacity 400 / sold 312"]
S2["session ses-001-2<br/>capacity 400 / sold 289"]
E -->|embedded| S1
E -->|embedded| S2
end
subgraph users["Collection: users"]
U["User<br/>role attendee"]
end
subgraph orders["Collection: orders"]
P["Order<br/>sessionId ses-001-1<br/>quantity 2"]
end
subgraph tickets["Collection: tickets"]
T1["Ticket EV-2026-000431"]
T2["Ticket EV-2026-000432"]
end
U -.->|references userId| P
P -.->|references orderId| T1
P -.->|references orderId| T2
T1 -.->|references sessionId| S1
The solid arrows are data living in the same document; the dashed ones are references that require an extra query. That visual distinction is exactly the difference in cost.
References with ref and populate
We already declared the references: userId: { type: Schema.Types.ObjectId, ref: 'User' }. The ref says which model is on the other side; populate replaces the identifier with the document.
// Without populate: userId is an ObjectId. With populate, the whole document.
const withUser = await Order.findById(id).populate('userId').lean();
// Selective population: only the fields you need.
const light = await Order.findById(id)
.populate({ path: 'userId', select: 'name email role -_id' })
.lean();
// Nested population: ticket -> order -> user.
const ticket = await Ticket.findOne({ code: 'EV-2026-000431' })
.populate({
path: 'orderId',
select: 'sessionId quantity status userId',
populate: { path: 'userId', select: 'name email' },
})
.lean();And now the important part, the one the documentation mentions in passing and that ruins applications: populate is not a JOIN. MongoDB joins nothing. Mongoose runs your query, collects the identifiers from the populated field, fires a second query (User.find({ _id: { $in: [...] } })) and stitches the results together in memory, inside your Node process. Consequences:
- A
populateover 50 orders is 2 queries, not 50: Mongoose groups the identifiers. A two-level nested population is 3 queries. - The stitching cost is paid by your event loop, not by the database server.
- You cannot filter the parent by a field of the child. "Orders whose user is an organizer" cannot be expressed with
populate; thematchoption does not discard parents, it leaves the field asnulland the order still shows up. For that,$lookupwith$match.
The N+1 problem: measuring it and solving it
It appears when, in order to resolve a list of N items, you fire one query per item. It is extremely easy to write without noticing:
// BAD: N+1. One query for the orders and one MORE for each order.
const orders = await Order.find({ status: 'paid' }).limit(100).lean(); // 1
for (const order of orders) {
order.user = await User.findById(order.userId).lean(); // 100
}With a modest latency of 2 ms per query, that is 202 ms of pure sequential waiting for a response that should take 5 ms. And it scales with traffic: 100 simultaneous requests are 10,100 queries. Measuring it is trivial and you should do it before optimizing anything: mongoose.set('debug', true) prints every query sent to the engine.
// Solution 1: populate. 2 queries in total. Enough 90% of the time.
await Order.find({ status: 'paid' }).limit(100)
.populate({ path: 'userId', select: 'name email' }).lean();
// Solution 2: two manual queries and a Map. Full control, no magic.
const orders = await Order.find({ status: 'paid' }).limit(100).lean();
const ids = [...new Set(orders.map((order) => String(order.userId)))];
const users = await User.find({ _id: { $in: ids } }).select('name email').lean();
const byId = new Map(users.map((user) => [String(user._id), user]));
const joined = orders.map((o) => ({ ...o, user: byId.get(String(o.userId)) }));
// Solution 3: $lookup in an aggregation. ONE single query; the engine joins.
await Order.aggregate([
{ $match: { status: 'paid' } },
{ $limit: 100 },
{ $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } },
{ $unwind: '$user' },
{ $project: { sessionId: 1, quantity: 1, 'user.name': 1 } },
]);Deliberate denormalization
session.sold is redundant information: it could be computed by counting valid tickets. We store it anyway, and not out of laziness.
In favor: checking capacity before selling is the platform's most frequent and most latency-sensitive operation, and reading an integer from an already-loaded document costs nothing, whereas counting tickets requires an aggregation over millions of documents. The catalog shows "88 tickets left" on every card: with the counter, the listing comes out of a single read. And it enables the conditional atomic update that will solve overselling, because you can only compare against a field that exists in the document.
The price, stated plainly: there are two sources of truth for the same fact and they can diverge. If tickets are created without incrementing the counter, or the other way round, or a process dies between the two operations, the catalog lies. And no engine mechanism prevents it: MongoDB knows nothing about that relationship. That price is paid at three levels:
- Atomicity of the set: reserving capacity, creating the order and issuing tickets must happen all or nothing. That is lesson 07-06.
- Periodic reconciliation: a scheduled job that recounts and corrects, leaving a trace. The safety net.
- A single write door: nobody touches
soldor creates tickets outside the repository. If ten places touch the counter, incoherence is a matter of time.
// Reconciliation: the real ticket count per session, for comparison.
const actual = await Ticket.aggregate([
{ $match: { status: { $in: ['valid', 'used'] } } },
{ $group: { _id: '$sessionId', actual: { $sum: 1 } } },
]);The aggregation framework as a pipeline
If Module 3 left you comfortable with pipeline() and streams, aggregation will feel familiar: it is a pipeline of stages where each one receives documents, transforms them and passes the result to the next. The difference is that it runs inside the database server, next to the data, and not in your process.
| Stage | What it does | Array analogue |
|---|---|---|
$match |
Filters documents | filter |
$project / $addFields |
Picks or computes fields | map |
$group |
Groups by key and accumulates | reduce |
$sort / $limit / $skip |
Sorts and trims | sort, slice |
$unwind |
Unfolds an array into one document per element | flatMap |
$lookup |
Joins with another collection | JOIN |
$facet |
Several pipelines in parallel over the same input | Several reduces at once |
Two golden rules about ordering, because performance depends on it. $match as early as possible: it is the only stage that can use indexes, and only if it comes first; filtering at the end means processing the entire collection. And $project to drop heavy fields early: fewer bytes down the pipeline and less memory, because every stage has a 100 MB limit (which can be lifted with allowDiskUse: true, but if you need it, rethink the query).
Real Escena Viva reports
In Module 3 we computed occupancy by reading data/sales.csv with streams. That was an excellent pipeline exercise, but the data now lives in the database and the engine aggregates it better than we do.
// src/reports/aggregates.js — revenue by venue.
async function revenueByVenue() {
return Event.aggregate([
// 1. Only what is on sale or closed; drafts are dropped.
{ $match: { status: { $in: ['published', 'finished'] } } },
// 2. One row per session: the array becomes independent documents.
{ $unwind: '$sessions' },
// 3. We group by venue and accumulate. Money, integer by integer.
{ $group: {
_id: '$venue',
revenueCents: { $sum: { $multiply: ['$sessions.sold', '$sessions.priceCents'] } },
ticketsSold: { $sum: '$sessions.sold' },
totalCapacity: { $sum: '$sessions.capacity' },
sessions: { $sum: 1 },
} },
// 4. We shape the output and compute the derived occupancy.
{ $project: {
_id: 0, venue: '$_id', revenueCents: 1, ticketsSold: 1, sessions: 1,
occupancy: { $round: [{ $divide: ['$ticketsSold', '$totalCapacity'] }, 4] },
} },
{ $sort: { revenueCents: -1 } },
]);
}With our seed catalog (7 sessions, total capacity 3000, 1811 sold), this pipeline returns the three venues sorted by revenue and an overall occupancy of 60.4%.
// Average occupancy per category. $addToSet accumulates unique values; with $size
// it is equivalent to SQL's COUNT(DISTINCT ...).
async function occupancyByCategory() {
return Event.aggregate([
{ $match: { status: { $in: ['published', 'finished'] } } },
{ $unwind: '$sessions' },
{ $group: {
_id: '$category',
sold: { $sum: '$sessions.sold' },
capacity: { $sum: '$sessions.capacity' },
events: { $addToSet: '$eventId' },
} },
{ $project: {
_id: 0, category: '$_id', eventCount: { $size: '$events' },
occupancy: { $round: [{ $divide: ['$sold', '$capacity'] }, 4] },
} },
{ $sort: { occupancy: -1 } },
]);
}
// Ranking of best-selling sessions.
async function topSellingSessions(limit = 10) {
return Event.aggregate([
{ $match: { status: 'published' } },
{ $unwind: '$sessions' },
{ $project: {
_id: 0, sessionId: '$sessions.sessionId', event: '$title', venue: '$venue',
dateTime: '$sessions.dateTime', sold: '$sessions.sold',
available: { $subtract: ['$sessions.capacity', '$sessions.sold'] },
} },
{ $sort: { sold: -1 } },
{ $limit: limit },
]);
}
// Sales per month, over the orders collection. We group by the string
// 'yyyy-MM', which also sorts alphabetically the right way.
async function salesByMonth(year) {
return Order.aggregate([
{ $match: {
status: { $in: ['paid', 'issued'] },
createdAt: { $gte: new Date(`${year}-01-01`), $lt: new Date(`${year + 1}-01-01`) },
} },
{ $group: {
_id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } },
orders: { $sum: 1 },
tickets: { $sum: '$quantity' },
amountCents: { $sum: '$totalCents' },
} },
{ $sort: { _id: 1 } },
]);
}And a complete dashboard in a single query with $facet: each branch receives the same input documents and produces its own array, replacing three round trips to the database with one. It is the perfect stage for control panels.
async function dashboard() {
const [panel] = await Event.aggregate([
{ $match: { status: 'published' } },
{ $unwind: '$sessions' },
{ $facet: {
byVenue: [{ $group: { _id: '$venue', sold: { $sum: '$sessions.sold' } } }],
byCategory: [{ $group: { _id: '$category', sold: { $sum: '$sessions.sold' } } }],
totals: [{ $group: {
_id: null, sessions: { $sum: 1 },
capacity: { $sum: '$sessions.capacity' }, sold: { $sum: '$sessions.sold' },
} }],
} },
]);
return panel;
}$lookup versus populate
populate (Mongoose) |
$lookup (aggregation) |
|
|---|---|---|
| Where the join happens | In your Node process | In the MongoDB server |
| Number of queries | 1 + 1 per populated level | 1 |
| Filter the parent by child fields | No | Yes, with a later $match |
| Aggregate over the joined result | No | Yes, it is just another stage |
| Uses the schema and its types | Yes | No: you work with the raw collection |
Practical criterion: populate to serve documents to the API; $lookup for reports and whenever you need to filter or aggregate over the relationship. One detail that trips people up: in $lookup, from is the real collection name (users, plural and lowercase, exactly as Mongoose pluralizes it from the User model), not the model name.
Indexes taken seriously, and explain()
An index is a B-tree sorted by the values of one or more fields, with pointers to the documents. Without one, "give me the events at Teatro Almendra" forces reading every document: a collection scan, or COLLSCAN. With one, the engine walks down the tree: an IXSCAN.
In a compound index the order of the fields rules, just like a phone book sorted by last name and then first name: it works for searching by last name, and by last name + first name, but not by first name alone. That is the prefix principle, applied to eventSchema.index({ status: 1, venue: 1, title: 1 }):
| Query | Does it use the index? |
|---|---|
{ status: 'published' } |
Yes (1-field prefix) |
{ status, venue } |
Yes (2-field prefix) |
{ status, venue } sorted by title |
Yes, the whole thing: filter + sort |
{ venue: 'Sala Boveda' } or { title: 'Jazz' } |
No: they are not a prefix |
The mnemonic is ESR: Equality-compared fields first, then the Sort field and finally the Range ones. A range field before the sort field forces the engine to sort in memory, which is exactly what you want to avoid.
const plan = await Event.find({ venue: 'Teatro Almendra', status: 'published' })
.explain('executionStats');
plan.executionStats.executionTimeMillis; // actual time
plan.executionStats.totalDocsExamined; // documents read
plan.executionStats.nReturned; // documents returned
plan.queryPlanner.winningPlan.inputStage.stage; // 'IXSCAN' or 'COLLSCAN'You read it with two indicators. stage: 'COLLSCAN' means there is no usable index: on a small collection it does not matter, on a large one it is an alarm. And the totalDocsExamined / nReturned ratio: the ideal is 1, examining exactly what you return; if you examine 50,000 to return 20, the index is not doing its job even though it exists. One excellent special case: if the index contains every field the query needs (filter and projection), MongoDB answers without touching the documents — a covered query — and totalDocsExamined is 0.
Partial indexes, write cost, text and geospatial
A normal unique index rejects duplicates including nulls: if ten tickets have externalCode: null, the second one already violates uniqueness. The solution is the partial index, which only indexes the documents matching a filter.
// Unique only among the tickets that really have an external code.
ticketSchema.index(
{ externalCode: 1 },
{ unique: true, partialFilterExpression: { externalCode: { $type: 'string' } } },
);
// A user cannot have two PENDING orders for the same session,
// but can have several paid ones.
orderSchema.index(
{ userId: 1, sessionId: 1 },
{ unique: true, partialFilterExpression: { status: 'pending' } },
);And the uncomfortable reminder: every index is paid for on every write. Inserting a document with six indexes means seven structures to update. It takes memory — ideally indexes fit in RAM — and disk. An index no query uses is pure cost, and MongoDB lets you audit it with Event.collection.aggregate([{ $indexStats: {} }]): if accesses.ops is still 0 after weeks in production, it is surplus.
// Text index: word search, with per-field weights.
eventSchema.index({ title: 'text', category: 'text' }, { weights: { title: 10 } });
await Event.find({ $text: { $search: 'jazz primavera' } }).lean();
// Geospatial index: venues near a coordinate.
venueSchema.index({ location: '2dsphere' });There can only be one text index per collection, and for serious search (synonyms, spell correction, tuned relevance) the usual approach is to delegate to a specialized engine such as Elasticsearch or Meilisearch, rather than forcing MongoDB.
Common Mistakes and Tips
- Embedding something that grows without bound. The day an event accumulates 30,000 embedded orders, it will pass 16 MB and become unwritable. There is no patch, there is a migration.
- Believing
populateis aJOIN. They are extra queries, and inside a loop they turn into N+1. - Filtering by a populated field.
populatewithmatchdoes not discard parents: it leaves the field asnull. Use$lookup+$match. - Putting
$matchat the end of the aggregation. You lose the index and process the whole collection. - Forgetting
$unwindbefore grouping by a field inside an array. You would be summing whole arrays instead of their elements. - Creating indexes "just in case". Each one slows writes down. Create them from measured queries and audit them with
$indexStats. - Tip: run
explain('executionStats')on any query in a hot route, before calling it done. - Tip: an aggregation with three nested
$lookups is usually the sign that this subsystem would fit better in a relational model. A good moment to read the next lesson without prejudice.
Exercises
Exercise 1: organizer report
Write an aggregation summaryByOrganizer() returning, for each organizerId, the number of published events, the total number of sessions, tickets sold, revenue in cents and average occupancy, sorted by revenue descending.
Exercise 2: hunting an N+1
This code lists the tickets of a session along with the buyer's name. State how many queries it fires for 200 tickets and rewrite it with a single one.
const tickets = await Ticket.find({ sessionId, status: 'valid' }).lean();
for (const ticket of tickets) {
const order = await Order.findById(ticket.orderId).lean();
const user = await User.findById(order.userId).lean();
ticket.buyer = user.name;
}Exercise 3: designing the index
Escena Viva adds an "upcoming sessions with tickets at a venue" screen: it filters by status: 'published' and venue, requires sessions with dateTime >= now and sorts by sessions.dateTime ascending. Propose the index, justify the order with the ESR rule and say what you would expect to see in explain().
Solutions
Exercise 1.
async function summaryByOrganizer() {
return Event.aggregate([
{ $match: { status: 'published' } },
{ $unwind: '$sessions' },
{ $group: {
_id: '$organizerId',
events: { $addToSet: '$eventId' },
sessions: { $sum: 1 },
sold: { $sum: '$sessions.sold' },
capacity: { $sum: '$sessions.capacity' },
revenueCents: {
$sum: { $multiply: ['$sessions.sold', '$sessions.priceCents'] },
},
} },
{ $project: {
_id: 0, organizerId: '$_id', sessions: 1, sold: 1, revenueCents: 1,
eventCount: { $size: '$events' },
averageOccupancy: { $round: [{ $divide: ['$sold', '$capacity'] }, 4] },
} },
{ $sort: { revenueCents: -1 } },
]);
}Exercise 2. It fires 401 queries: 1 for the tickets, 200 for the orders and 200 for the users. With $lookup it is resolved in one:
const tickets = await Ticket.aggregate([
{ $match: { sessionId, status: 'valid' } },
{ $lookup: { from: 'orders', localField: 'orderId', foreignField: '_id', as: 'order' } },
{ $unwind: '$order' },
{ $lookup: { from: 'users', localField: 'order.userId', foreignField: '_id', as: 'buyer' } },
{ $unwind: '$buyer' },
{ $project: { code: 1, status: 1, buyer: '$buyer.name' } },
]);Exercise 3. Index: { status: 1, venue: 1, 'sessions.dateTime': 1 }. By ESR, status and venue are compared by equality and go first; sessions.dateTime plays the role of both sort and range, and goes last. Because it is a field inside an array of subdocuments, MongoDB treats it as a multikey index. In explain('executionStats') we would expect stage: 'IXSCAN', the absence of an in-memory SORT stage — the ordering is served by the index itself — and a totalDocsExamined / nReturned ratio close to 1.
Conclusion
You have learned to model relationships in MongoDB with judgment: when to embed and when to reference, with the criteria table and Escena Viva's decisions justified one by one. You know what populate really does — extra queries stitched together in your process, not a JOIN — how to detect and kill an N+1, and why we keep sold denormalized while consciously accepting the coherence debt it creates. With the aggregation framework you have built the reports that used to come out of a CSV file in Module 3: revenue by venue, occupancy by category, session ranking and sales by month, plus a complete dashboard with $facet. And you no longer assume anything about indexes: you design them with the ESR rule and verify them with explain().
You have also seen the limits. Nested $lookups, referential integrity nobody guarantees, a sold <= capacity invariant that depends on your discipline rather than on the engine. None of this disqualifies MongoDB — it is Escena Viva's official persistence layer and it works — but it leaves a legitimate question hanging: what would this look like in a relational system?
In the next lesson we answer that by modeling the same domain in PostgreSQL with Sequelize: tables, foreign keys, normalization, a CHECK (sold <= capacity) constraint the engine enforces no matter what, include as a real single-query JOIN, parameterized SQL versus injection, and a second implementation of src/repositories/events.js that will prove the architecture of lesson 07-01 was not theory.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
