We already have a connection and models. Time to move data. CRUD is the set of four operations that hold up any stateful application: create, read, update and delete. With Mongoose each one can be written in several ways, and they are not all equivalent: some validate and some do not, some return the previous document and some the new one, some execute the query and some merely build it.

This lesson walks through all four with their real traps, translates Mongoose errors into the Module 6 hierarchy, and culminates in the moment announced back in the module's first lesson: src/catalog-data.js retires and src/repositories/events.js takes its place with the same public signature, without the controllers or the domain noticing.

Contents

  1. Creating documents
  2. Duplicate key errors and the Module 6 hierarchy
  3. Reading: lazy Query objects, projections and lean()
  4. Sorting, limiting and paginating
  5. Essential query operators
  6. Updating: runValidators, $set, $inc
  7. Deleting and soft deletes
  8. The repository: retiring catalog-data.js
  9. POST /orders against the database
  10. Mongoose errors and their HTTP status

Creating documents

There are two routes, and the difference is not cosmetic.

// Route 1: instantiate and save. Lets you manipulate the document before writing.
const event = new Event({
  eventId: 'evt-001', title: 'Concierto de Otono', venue: 'Teatro Almendra',
  organizerId: 'org-almendra', category: 'music', status: 'published', durationMinutes: 95,
  sessions: [
    { sessionId: 'ses-001-1', dateTime: new Date('2026-10-03T20:00:00Z'), capacity: 400, sold: 312, priceCents: 2500 },
    { sessionId: 'ses-001-2', dateTime: new Date('2026-10-04T19:00:00Z'), capacity: 400, sold: 289, priceCents: 2500 },
  ],
});
// Nothing is in the database yet: the document lives in memory.
const saved = await event.save(); // validates, fires pre('save') and writes

// Route 2: create directly. Equivalent to new + save in one line.
const another = await Event.create({ eventId: 'evt-002', /* ... */ });
// Bulk insert: a single round trip for many documents.
const several = await Event.insertMany([documentA, documentB], { ordered: false });

save() and create() return the already persisted document, with its _id, its timestamps and its virtuals. insertMany is far faster than a loop of create calls, but by default it is ordered: if the third document fails, the first two are already written and the rest are never attempted; with { ordered: false } it attempts them all and reports the failures, which is what you will want in a seed. Which to use: new + save() lets you apply logic between building and writing (computing the total, generating codes); create() is more direct when the data already arrives complete.

Duplicate key errors and the Module 6 hierarchy

eventId and code have unique indexes. If you insert a duplicate, MongoDB answers with E11000 duplicate key error. If you do not translate it, the Module 6 handler will treat it as a bug and return a 500. But it is not a bug: it is an operational state conflict, and it deserves a 409.

// src/repositories/mongo-errors.js
'use strict';

const { StateConflict, ValidationError } = require('../errors.js');

/**
 * Translates a Mongoose/MongoDB error into the application hierarchy. If it does
 * not recognize it, it returns it as is: it will be a 500, and rightly so, because
 * that means it is a failure we had not anticipated.
 */
function translateMongoError(error) {
  // Duplicate key: a unique index was violated -> 409.
  if (error.code === 11000) {
    const field = Object.keys(error.keyPattern ?? {})[0] ?? 'unknown';
    return new StateConflict(`A record with the same ${field} already exists`, {
      appCode: 'INVALID_STATE', details: { field },
    });
  }
  // Schema validation: one or more fields break the rules -> 400.
  if (error.name === 'ValidationError') {
    return new ValidationError('The document does not match the schema', {
      appCode: 'INVALID_DATA',
      details: Object.entries(error.errors).map(([field, f]) => ({ field, message: f.message })),
    });
  }
  // Identifier with an invalid shape ('hello' is not an ObjectId) -> 400, not 500.
  if (error.name === 'CastError') {
    return new ValidationError(`The value of '${error.path}' is not valid`, {
      appCode: 'INVALID_DATA', details: { field: error.path, value: String(error.value) },
    });
  }
  return error;
}

module.exports = { translateMongoError };

Notice where this translation lives: in the repository layer, not in the controller. It is consistent with the architecture of lesson 07-01: the controller must not know MongoDB exists, and therefore must not know what an E11000 is either. The repository translates from the engine's language into the application's.

Reading: lazy Query objects, projections and lean()

Here is the surprise that throws almost everyone the first time:

// This runs NO query at all: it builds a Query object.
const query = Event.find({ venue: 'Teatro Almendra' });
console.log(query.constructor.name); // 'Query'

// Query has a .then(), so await executes it: it is a "thenable", not a
// promise. That is why it can be refined before running.
const published = await query.where('status').equals('published').sort({ title: 1 }).limit(20);

Being lazy is an advantage: you can build the query in pieces according to the optional filters arriving in the query string and run it at the end. The trap is that a query without await does nothing and raises no error. Rule: always await, or .exec(), which returns a real promise and improves stack traces.

const catalog = await Event.find({ status: 'published' });      // array, empty if none
const one = await Event.findOne({ eventId: 'evt-001' });        // document or null
const byId = await Event.findById('507f1f77bcf86cd799439011');  // document or null

None of them throws when nothing is found: they return null or [], and turning that null into a ResourceNotFound is the job of the repository or the controller. With .orFail() it throws instead of returning null, which is sometimes more convenient.

Projection: asking only for the fields you need. Fewer bytes, less memory, less work for the engine.

// Inclusive: only these fields (plus _id, unless you exclude it).
await Event.find({ status: 'published' }, { eventId: 1, title: 1, _id: 0 });
// Exclusive: everything but this. Inclusion and exclusion cannot be mixed
// (except for _id, which is the only exception).
await Event.find({}).select('-sessions');

That last example matters: the catalog landing page does not need the 7 sessions with their capacity and price, only the title and the venue.

lean() returns plain JavaScript objects instead of Mongoose documents. By default, Mongoose wraps every result in an instance with change tracking, getters, virtuals and methods: building it is expensive. A lean object has no save(), no virtuals and no getter conversion — you get the raw BSON types — but it is built almost for free.

The rule: use lean() on every read that ends in res.json(); do not use it when you are going to modify the document. On large listings the difference is several times over in both time and memory. In Escena Viva there is a nuance: our repository returns domain objects, so it will call lean() and then Event.fromJSON(...), which is cheaper than hydrating twice.

Sorting, limiting and paginating

// Classic offset pagination.
const events = await Event.find({ status: 'published' })
  .sort({ title: 1 })
  .skip((page - 1) * perPage)
  .limit(perPage)
  .lean();

For the first pages it is perfect. But skip degrades linearly: to serve page 5,000 the engine locates and discards 100,000 documents before it starts returning anything; it does not skip them magically, it walks them. On top of that, if somebody inserts or deletes between two requests, you will see repeated items or miss some, because the offset is computed over a set that has changed. The alternative is cursor pagination (also called keyset): instead of "skip 100,000", you say "give me the ones after this value".

/** Cursor pagination over a sorted, unique field (here, the _id). */
async function paginateEvents({ after = null, limit = 20 } = {}) {
  const filter = { status: 'published' };
  // Only documents after the last one seen: uses the index, discards nothing.
  if (after) filter._id = { $gt: after };
  const documents = await Event.find(filter).sort({ _id: 1 }).limit(limit).lean();
  return { documents, next: documents.length === limit ? documents.at(-1)._id : null };
}

The cost is constant regardless of depth and it is stable in the face of inserts. The price: you cannot jump to page 37, only move forward. For a catalog with infinite scroll, or an API consumed by machines, it is the right choice.

Essential query operators

Operator Meaning Example in Escena Viva
$eq / $ne Equal (implicit) / not equal { status: { $ne: 'draft' } }
$gt / $gte / $lt / $lte Comparisons { priceCents: { $lte: 3000 } }
$in / $nin Is / is not in the list { venue: { $in: ['Teatro Almendra', 'Auditorio Ribera'] } }
$and / $or Logical Published, or belonging to the organizer
$regex / $exists Regular expression / the field exists Search by title
$expr Compare two fields with each other sold < capacity
$elemMatch Conditions on the same element of an array Sessions within a range
// 1. Events with some session in a date range.
// CAREFUL: without $elemMatch, the two conditions can be met by DIFFERENT sessions.
const range = { $gte: new Date('2026-03-01'), $lt: new Date('2026-03-16') };
await Event.find({ sessions: { $elemMatch: { dateTime: range } } }).lean();

// 2. Events with some session that still has tickets: comparing two fields of the
// same document requires $expr, because a normal filter compares against literals.
await Event.find({
  status: 'published',
  sessions: { $elemMatch: { $expr: { $lt: ['$sold', '$capacity'] } } },
}).lean();

// 3. Case-insensitive search by title. We escape the user's input: an unescaped
// regex is a denial-of-service vector.
await Event.find({ title: { $regex: escape(term), $options: 'i' } }).lean();

Point 1 is a classic mistake: { 'sessions.dateTime': { $gte: a, $lt: b } } does not mean "a session inside the range", it means "some session after a and some session before b", which an event with no March session at all can satisfy.

Updating: runValidators, $set, $inc

// Without fetching the document: fast, but it does not fire pre('save').
// Returns { acknowledged, matchedCount, modifiedCount, upsertedId }.
await Event.updateOne({ eventId: 'evt-003' }, { $set: { status: 'published' } });

// Update and receive the resulting document.
const updated = await Event.findByIdAndUpdate(
  id,
  { $set: { durationMinutes: 120 } },
  { new: true, runValidators: true },
);

Both options are practically mandatory:

  • new: true returns the document after the change. By default — and this surprises everyone — it returns the previous one. Without it you will reply to the client with stale data.
  • runValidators: true runs the schema validators. Without it you skip validation entirely: you could set status: 'invented' even though there is an enum, or capacity: -5 even though there is a min: 1. It is Mongoose's biggest and quietest hole. Turn it on globally with mongoose.set('runValidators', true). And watch out for a real limitation: when validating an update, this is the query, not the document, so a validator comparing sold with capacity has no access to capacity unless you are updating that too. The critical invariant cannot rest there alone.
Operator What it does Use in Escena Viva
$set / $unset Assigns / removes a field Changing status
$inc Adds (or subtracts) atomically Incrementing sold
$push / $pull Adds to / removes from an array Adding or withdrawing a session
$addToSet / $min / $max Adds if absent / assigns if smaller or larger Tags, historical marks

And here comes the first piece of the capacity problem:

// ATOMIC increment of one specific session's counter. The positional operator $
// points at the array element that matched the filter.
await Event.updateOne(
  { eventId: 'evt-001', 'sessions.sessionId': 'ses-001-1' },
  { $inc: { 'sessions.$.sold': 2 } },
);

Why is this progress? Because $inc does not do read-modify-write in your process: it sends the engine the instruction "add 2 to this field", and the engine applies it under its own document lock. Two simultaneous requests with $inc: 1 leave the counter at +2, never at +1. No more lost updates.

But it is not enough, and it is worth seeing that now so you do not walk away with a false sense of security: $inc adds unconditionally. If one ticket was left and two purchases arrive, both add and sold ends up at capacity + 1. We have solved the lost write, not the checking of the condition. The missing piece — a filter that is part of the same atomic operation — arrives in lesson 07-06.

Deleting and soft deletes

await Event.deleteOne({ eventId: 'evt-009' });
await Event.findByIdAndDelete(id);                  // returns the deleted one, or null
const { deletedCount } = await Ticket.deleteMany({ status: 'cancelled' });

// But in Escena Viva we hardly ever delete orders or tickets: we mark them.
await Order.findByIdAndUpdate(orderId, { $set: { status: 'cancelled' } }, { new: true, runValidators: true });
await Ticket.updateMany({ orderId }, { $set: { status: 'cancelled' } });

The reasons are business reasons: accounting (an order that was charged and later cancelled must still show up in the month's close), traceability (with the requestId of Module 6 we can reconstruct what happened, but not if the document no longer exists), referential integrity (with no foreign keys, deleting an order leaves orphan tickets) and human error (a soft delete is reverted by changing a field; a hard one, by restoring a backup, with luck). The price — every query has to filter by status — is low and predictable.

The repository: retiring catalog-data.js

The moment has arrived. src/catalog-data.js exposed getCatalog and getEventById by reading data/events.json. The new module exposes exactly the same, reading MongoDB.

// src/repositories/events.js
'use strict';

const { Event: EventModel } = require('../models/event.js');
const { Event } = require('../domain/event.js');
const { translateMongoError } = require('./mongo-errors.js');

/**
 * Converts a plain Mongo document into a domain instance.
 * This is the boundary: from here upwards, nobody knows Mongo exists.
 */
function toDomain({ eventId, sessions, ...rest }) {
  return Event.fromJSON({
    ...rest,
    id: eventId,
    sessions: sessions.map(({ sessionId, dateTime, ...data }) => ({
      ...data, // capacity, sold, priceCents
      id: sessionId,
      dateTime: dateTime.toISOString(),
    })),
  });
}

/** The full catalog, as domain instances. */
async function getCatalog() {
  try {
    // lean(): we only read and transform, we do not need Mongoose documents.
    const docs = await EventModel.find({ status: { $ne: 'draft' } }).sort({ title: 1 }).lean();
    return docs.map(toDomain);
  } catch (error) {
    throw translateMongoError(error);
  }
}

/** One event by its business identifier, or null if it does not exist. */
async function getEventById(eventId) {
  const document = await EventModel.findOne({ eventId }).lean();
  return document ? toDomain(document) : null;
}

module.exports = { getCatalog, getEventById };

The change in the controllers is literally one line: where src/controllers/events.js used to require('../catalog-data.js'), it now does require('../repositories/events.js'). That is all. listEvents, loadEvent, getEvent and listEventSessions keep working without touching a comma, because they keep receiving domain Event instances with their occupancy, soldOut and revenueCents getters. This is what the repository pattern buys you, and it is the same door Sequelize will walk through in lesson 07-05.

POST /orders against the database

The controller already receives data validated by zod in req.validatedData. What changes is where it comes from and where it goes.

// src/repositories/orders.js (requires omitted for brevity)
'use strict';

// Generates a ticket code EV-<year>-<6 digits>.
const generateCode = (year, n) => `EV-${year}-${String(n).padStart(6, '0')}`;

async function createOrder({ userId, sessionId, quantity, channel }) {
  const event = await Event.findOne({ 'sessions.sessionId': sessionId });
  if (!event) {
    throw new ResourceNotFound(`Session ${sessionId} does not exist`, { appCode: 'SESSION_NOT_FOUND' });
  }
  const session = event.sessions.find((element) => element.sessionId === sessionId);
  const available = session.capacity - session.sold;
  if (available < quantity) {
    throw new StateConflict('Not enough tickets left', {
      appCode: 'INSUFFICIENT_CAPACITY',
      details: { available, requested: quantity },
    });
  }

  try {
    // WARNING: between the capacity check and this $inc there is a window in which
    // another request can slip through. Overselling is still possible. We solve it
    // in lesson 07-06 with a conditional atomic update.
    await Event.updateOne(
      { eventId: event.eventId, 'sessions.sessionId': sessionId },
      { $inc: { 'sessions.$.sold': quantity } },
    );

    const order = await Order.create({
      userId, eventId: event.eventId, sessionId, quantity, channel,
      totalCents: session.priceCents * quantity,
      status: 'paid',
    });

    const year = new Date().getUTCFullYear();
    const base = await Ticket.countDocuments();
    const tickets = await Ticket.insertMany(
      Array.from({ length: quantity }, (unused, i) => ({
        code: generateCode(year, base + i + 1), orderId: order._id, sessionId, status: 'valid',
      })),
    );
    return { order, tickets };
  } catch (error) {
    throw translateMongoError(error);
  }
}

module.exports = { createOrder };

Leave that all-caps comment exactly where it is. It is the course's problem, now with a name and a specific line: we have gained atomicity per operation, but not per set, and on top of that the order and the tickets can be created without the capacity having been reserved, or the other way round. In 07-06 this function will be rewritten from scratch and the comment will disappear.

Mongoose errors and their HTTP status

Error When it appears Translation Status
ValidationError A field breaks the schema INVALID_DATA 400
CastError findById('hello'), an unparseable date INVALID_DATA 400
E11000 A unique index was violated INVALID_STATE 409
DocumentNotFoundError orFail() with no result ..._NOT_FOUND 404
MongoServerSelectionError / MongoNetworkError The database does not answer EXTERNAL_SERVICE_DOWN 503

CastError deserves special attention because it is the most frequent failure in production: a client requests GET /orders/12345 with an identifier that is not an ObjectId, Mongoose cannot cast it and throws. Without translation, your application returns a 500 and your alerting dashboard fills with errors that are not bugs: they are malformed requests and they deserve a 400. The STATUS_BY_CODE table from Module 6 does the rest; you only have to hand it the right code.

Common Mistakes and Tips

  • Forgetting runValidators: true. Your enums and your mins stop existing on updates.
  • Forgetting new: true. You return the state before the change and spend half an afternoon hunting the bug in the frontend.
  • Not using await. An unexecuted Query does not fail: nothing simply happens, or it happens at the wrong time.
  • Hydrating full documents only to serialize them. lean() plus a projection is the cheapest optimization there is.
  • Paginating with skip on a large catalog. It works until it does not, and it fails exactly when you have traffic.
  • Interpolating user input into a $regex. A pattern like (a+)+$ can hang the engine. Always escape.
  • Tip: use .orFail() when absence is a real error, and turn on mongoose.set('debug', true) in development: you will see every query that goes out and discover queries you did not know you were making.

Exercises

Exercise 1: orders per user

Add to src/repositories/orders.js a function listUserOrders(userId, { page, perPage }) returning a user's non-cancelled orders, newest first, projecting sessionId, quantity, totalCents, status and createdAt, using lean(). State which index it needs.

Exercise 2: catalog search

Write searchEvents({ venue, category, from, to, onlyAvailable }) that builds the filter incrementally (adding only the conditions it receives) and returns the published events that match. Use $elemMatch for the date range and $expr for the available tickets.

Exercise 3: cancelling an order

Implement cancelOrder(orderId) that checks the order exists and is in paid or issued status (otherwise, a StateConflict with INVALID_STATE), marks it as cancelled, marks its tickets as cancelled and returns the capacity. Note down what happens if the process dies between two of those operations.

Solutions

Exercise 1.

async function listUserOrders(userId, { page = 1, perPage = 20 } = {}) {
  return Order.find({ userId, status: { $ne: 'cancelled' } })
    .select('sessionId quantity totalCents status createdAt')
    .sort({ createdAt: -1 })
    .skip((page - 1) * perPage)
    .limit(perPage)
    .lean();
}

Index: { userId: 1, status: 1, createdAt: -1 }, following the ESR rule — equality first, then the sort field and in the same direction.

Exercise 2.

async function searchEvents({ venue, category, from, to, onlyAvailable = false } = {}) {
  const filter = { status: 'published' };
  if (venue) filter.venue = venue;
  if (category) filter.category = category;

  const conditions = {};
  if (from) conditions.dateTime = { $gte: new Date(from) };
  if (to) conditions.dateTime = { ...conditions.dateTime, $lte: new Date(to) };
  if (onlyAvailable) conditions.$expr = { $lt: ['$sold', '$capacity'] };
  if (Object.keys(conditions).length > 0) filter.sessions = { $elemMatch: conditions };

  return Event.find(filter).sort({ title: 1 }).lean();
}

Exercise 3.

async function cancelOrder(orderId) {
  const order = await Order.findById(orderId).orFail();
  if (!['paid', 'issued'].includes(order.status)) {
    throw new StateConflict(`A ${order.status} order cannot be cancelled`, { appCode: 'INVALID_STATE' });
  }
  order.status = 'cancelled';
  await order.save();
  await Ticket.updateMany({ orderId }, { $set: { status: 'cancelled' } });
  await Event.updateOne(
    { eventId: order.eventId, 'sessions.sessionId': order.sessionId },
    { $inc: { 'sessions.$.sold': -order.quantity } },
  );
  // If the process dies between these three writes, the state is left incoherent:
  // a cancelled order with valid tickets, or capacity never returned. Each operation
  // is atomic on its own, but the SET is not. That is transactions (07-06).
  return order;
}

Conclusion

You now know how to move data with Mongoose for real: creating with save() or create() knowing what each one returns, reading with the understanding that a Query is lazy and that lean() plus projections are the most profitable optimization, paginating without falling into the skip trap, filtering with the right operators — including $elemMatch and $expr, which avoid subtle mistakes with arrays — updating without skipping validation and deleting softly so as not to lose the history. And you know how to translate engine errors into the Module 6 hierarchy from the right layer. Above all, src/catalog-data.js is retired: src/repositories/events.js has taken its place with the same signature, the controllers never noticed and the domain still receives its Event instances. The architecture of lesson 07-01 has just proved it was good for something.

One debt remains, written in capitals inside createOrder: $inc has given us atomicity per operation, but not per set, and overselling is still alive. In the next lesson we park that problem a little longer to attack advanced modeling: embedding versus referencing, populate and the N+1 problem, deliberate denormalization, the aggregation framework applied to real Escena Viva reports — which will replace the ones we built in Module 3 by reading CSV files — and indexes taken seriously, with explain() to see whether your queries use them or are scanning the whole collection.

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