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
- Creating documents
- Duplicate key errors and the Module 6 hierarchy
- Reading: lazy
Queryobjects, projections andlean() - Sorting, limiting and paginating
- Essential query operators
- Updating:
runValidators,$set,$inc - Deleting and soft deletes
- The repository: retiring
catalog-data.js POST /ordersagainst the database- 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 nullNone 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: truereturns 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: trueruns the schema validators. Without it you skip validation entirely: you could setstatus: 'invented'even though there is anenum, orcapacity: -5even though there is amin: 1. It is Mongoose's biggest and quietest hole. Turn it on globally withmongoose.set('runValidators', true). And watch out for a real limitation: when validating an update,thisis the query, not the document, so a validator comparingsoldwithcapacityhas no access tocapacityunless 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. Yourenums and yourmins 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 unexecutedQuerydoes 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
skipon 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 onmongoose.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
- 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
