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

  1. Embedding versus referencing
  2. Escena Viva's decisions, justified one by one
  3. References with ref and populate
  4. The N+1 problem: measuring it and solving it
  5. Deliberate denormalization
  6. The aggregation framework as a pipeline
  7. Real Escena Viva reports
  8. $lookup versus populate
  9. Indexes taken seriously, and explain()
  10. 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 populate over 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; the match option does not discard parents, it leaves the field as null and the order still shows up. For that, $lookup with $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:

  1. Atomicity of the set: reserving capacity, creating the order and issuing tickets must happen all or nothing. That is lesson 07-06.
  2. Periodic reconciliation: a scheduled job that recounts and corrects, leaving a trace. The safety net.
  3. A single write door: nobody touches sold or 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 populate is a JOIN. They are extra queries, and inside a loop they turn into N+1.
  • Filtering by a populated field. populate with match does not discard parents: it leaves the field as null. Use $lookup + $match.
  • Putting $match at the end of the aggregation. You lose the index and process the whole collection.
  • Forgetting $unwind before 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

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved