In the previous lesson we settled Escena Viva's data model and left the src/repositories/ boundary ready. Now we go down into the code: we will connect the application to MongoDB, write the connection module that plugs into the graceful shutdown in src/server.js, and translate the diagram from lesson 07-01 into Mongoose schemas and models in src/models/.
This lesson does not write queries yet — that is 07-03. It writes the structure: what shape the documents have, what rules they enforce, what derived fields they expose and what indexes hold them up.
Contents
- What MongoDB is: documents, collections and
ObjectId - What Mongoose is and whether it is worth it
- Connection, startup and graceful shutdown
- The
Eventschema with embedded sessions - Schema types and options
Order,Ticketand a minimalUser- Schema validators versus zod validation
_idversus business identifiers- Methods, statics and virtuals
- Schema middleware and
toJSON - Declared indexes
What MongoDB is: documents, collections and ObjectId
MongoDB stores documents in collections. A document is a structure much like a JavaScript object, with nested fields and arrays. A collection is the loose equivalent of a table: a container that does not demand that all its documents have the same shape.
The internal format is BSON (Binary JSON): a binary encoding that adds types JSON does not have and that we care about.
| BSON type | JS equivalent | Why it matters in Escena Viva |
|---|---|---|
ObjectId |
A 12-byte object | The default identifier of every document |
Date |
Date |
dateTime is stored as a real date, not as text |
Int32 / Int64 |
number |
capacity, sold and priceCents are genuine integers |
Decimal128 / Binary |
— / Buffer |
Exact decimals (we do not use them: we work in cents) and the buffers from M3 |
Every document has an _id that is unique within its collection. If you do not supply one, MongoDB generates an ObjectId: 12 bytes combining a timestamp, a machine/process identifier and a counter. Two practical consequences: they are unique without central coordination, and they are roughly sortable by creation date, because the first 4 bytes are the instant in seconds.
In text it looks like 507f1f77bcf86cd799439011: 24 hexadecimal characters. Watch out for this, because passing a string that does not have that shape to a query by _id produces a CastError, and that error has to be translated into a 400 rather than being allowed to turn into a 500.
What Mongoose is and whether it is worth it
Mongoose is an ODM: it sits on top of the official mongodb driver and adds a schema (the expected shape of your documents), validation before writing, automatic typing and casting, and model functionality: pre/post hooks, methods, virtuals and populate.
Is it worth it compared with the native driver? The honest answer: it depends on how much discipline you can guarantee unaided. With the native driver you write exactly the query that runs and there are no performance surprises, but every required field, every default value and every type conversion is your own code, repeated at every write site. Mongoose centralizes that and makes a malformed document hard to create by accident. The cost is a layer of indirection: you have to know that find() returns a lazy Query, that lean() changes what you get back and that a pre('save') does not fire on an updateOne. For a domain with invariants like ours the scales tip towards Mongoose; for an ingestion script handling a million documents, I would drop down to the native driver without a second thought.
You install it with npm install mongoose; it bundles the mongodb driver as a dependency, so there is no need to install that separately.
Connection, startup and graceful shutdown
The URL comes in through the only place authorized to read process.env: src/config/index.js.
// src/config/index.js (new fragment)
const mongodbUrl = process.env.MONGODB_URL ?? 'mongodb://localhost:27017/escena_viva';
if (!/^mongodb(\+srv)?:\/\//.test(mongodbUrl)) {
throw new Error('MONGODB_URL must start with mongodb:// or mongodb+srv://');
}
const configuration = Object.freeze({
mongodbUrl, // ...alongside the fields from module 6
mongodbTimeout: Number(process.env.MONGODB_TIMEOUT ?? 5000),
});Validating at startup is deliberate: if the URL is wrong we want to find out when the process launches, not on a client's first request.
// src/db/connection.js
'use strict';
const mongoose = require('mongoose');
const { configuration } = require('../config/index.js');
// Mongoose 7+ does not allow querying fields not declared in the schema.
mongoose.set('strictQuery', true);
let activeConnection = null;
/** Opens the connection. It is idempotent: if one already exists, it is reused. */
async function connect({ url = configuration.mongodbUrl } = {}) {
if (activeConnection) return activeConnection;
registerEvents();
await mongoose.connect(url, {
// If no server is found within this time, reject instead of waiting forever.
serverSelectionTimeoutMS: configuration.mongodbTimeout,
// Pool of reused sockets: simultaneous operations against the engine.
maxPoolSize: 10,
minPoolSize: 1,
});
activeConnection = mongoose.connection;
return activeConnection;
}
async function disconnect() {
if (!activeConnection) return;
await mongoose.disconnect();
activeConnection = null;
}
function registerEvents() {
const { connection } = mongoose;
// We never log the full URL: it may carry credentials.
connection.on('connected', () => console.log(`[db] connected to '${connection.name}'`));
connection.on('error', (error) => console.error('[db] error:', error.message));
connection.on('disconnected', () => console.warn('[db] disconnected; will retry'));
}
module.exports = { connect, disconnect };Three details that are not decoration. serverSelectionTimeoutMS: without it, a wrong URL leaves startup hanging with no explanation. The pool: Mongoose does not open one connection per query, it keeps a set of sockets; if maxPoolSize were 1, you would serialize the entire application. The events: after a disconnected the driver retries automatically and queues the pending operations; you do not need to write reconnection logic, but you do need to log the event so you are not blind during an outage.
createApplication() remains a pure factory that never touches the network. The connection belongs to the process, and the process is governed by src/server.js:
// src/server.js (adapted fragment)
async function startServer() {
await connect(); // 1. Database first: serving traffic without data makes no sense.
const server = createApplication().listen(configuration.port);
async function shutdown(signal) {
console.log(`[http] received ${signal}, shutting down gracefully`);
// 2. We stop accepting requests and, only once the in-flight ones have
// finished, 3. we close the database connection.
server.close(async () => {
await disconnect();
process.exit(0);
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
return server;
}The order matters and it is symmetric. On startup: database first, HTTP second, so no request ever reaches an application without data. On shutdown: HTTP first, database second, because closing the connection with requests in flight would produce errors in purchases already under way. The connection is a process-level resource with the same life cycle as the process itself.
The Event schema with embedded sessions
// src/models/event.js
'use strict';
const mongoose = require('mongoose');
const { Schema } = mongoose;
const EVENT_STATUSES = ['draft', 'published', 'finished'];
const sessionSchema = new Schema(
{
// Business identifier, in the ses-NNN-M format zod already validates.
sessionId: { type: String, required: true, match: /^ses-\d{3}-\d+$/ },
dateTime: { type: Date, required: true },
capacity: { type: Number, required: true, min: 1 },
sold: { type: Number, required: true, default: 0, min: 0 },
priceCents: { type: Number, required: true, min: 0 },
},
// Subdocuments do not need an _id: sessionId identifies them.
{ _id: false },
);
// Custom validator: the central invariant of the domain.
sessionSchema.path('sold').validate(function checkCapacity(value) {
return value <= this.capacity;
}, 'Tickets sold cannot exceed the capacity');
const eventSchema = new Schema(
{
eventId: { type: String, required: true, unique: true, match: /^evt-\d{3}$/ },
title: { type: String, required: true, trim: true, maxlength: 160 },
venue: { type: String, required: true, trim: true, index: true },
organizerId: { type: String, required: true, index: true },
category: { type: String, required: true, trim: true },
status: { type: String, required: true, enum: EVENT_STATUSES, default: 'draft' },
durationMinutes: { type: Number, required: true, min: 1, max: 600 },
sessions: { type: [sessionSchema], default: [] },
},
{ timestamps: true },
);
const Event = mongoose.model('Event', eventSchema);
module.exports = { Event, EVENT_STATUSES };The schema reproduces field by field what we already knew from the domain: evt-001 "Concierto de Otono" at Teatro Almendra, with org-almendra and sessions ses-001-1 and ses-001-2. Nothing new is invented; what already existed is persisted. And { timestamps: true } adds automatic createdAt and updatedAt: the beginning of the history we were missing in the JSON file.
Schema types and options
| Option | What it does | Example |
|---|---|---|
type |
String, Number, Date, Boolean, ObjectId, arrays, subdocuments |
dateTime: Date |
required |
Rejects the save if it is missing | capacity |
default |
Value when none is given; accepts a function | sold: 0 |
enum |
Closed list of values | status |
min / max |
Numeric or date range | priceCents: { min: 0 } |
minlength / maxlength |
String length | title up to 160 |
match |
Regular expression | code: EV-\d{4}-\d{6} |
trim / lowercase |
Normalizes before saving | email |
unique / index |
They do not validate: they declare indexes (unique or plain) | eventId, venue |
immutable |
Prevents modification after creation | code |
timestamps |
A schema option: createdAt/updatedAt |
Every model |
The unique row deserves emphasis: unique: true validates nothing in Mongoose. It asks MongoDB to create a unique index, and the one that rejects the duplicate is the engine, with an E11000 error at write time, not a ValidationError. In the next lesson we will translate it into a 409.
Order, Ticket and a minimal User
// src/models/order.js
const ORDER_STATUSES = ['pending', 'paid', 'issued', 'cancelled'];
const orderSchema = new Schema(
{
// Reference to another collection: it stores the _id, not the whole user.
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
eventId: { type: String, required: true },
sessionId: { type: String, required: true, index: true },
quantity: { type: Number, required: true, min: 1, max: 6 },
totalCents: { type: Number, required: true, min: 0 },
status: { type: String, required: true, enum: ORDER_STATUSES, default: 'pending' },
channel: { type: String, required: true, enum: ['web', 'box-office', 'phone'] },
},
{ timestamps: true },
);
const Order = mongoose.model('Order', orderSchema);
module.exports = { Order, ORDER_STATUSES };// src/models/ticket.js
const TICKET_STATUSES = ['valid', 'used', 'cancelled'];
const ticketSchema = new Schema(
{
// The printed code: EV-<year>-<6 digits>. Unique and immutable.
code: { type: String, required: true, unique: true, immutable: true, match: /^EV-\d{4}-\d{6}$/ },
orderId: { type: Schema.Types.ObjectId, ref: 'Order', required: true, index: true },
sessionId: { type: String, required: true, index: true },
status: { type: String, required: true, enum: TICKET_STATUSES, default: 'valid' },
// Moment of the scan at the door; null while it has not been used.
usedAt: { type: Date, default: null },
},
{ timestamps: true },
);
const Ticket = mongoose.model('Ticket', ticketSchema);
module.exports = { Ticket, TICKET_STATUSES };
// --- src/models/user.js ---
const ROLES = ['attendee', 'organizer', 'administrator'];
const userSchema = new Schema(
{
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
name: { type: String, required: true, trim: true },
role: { type: String, required: true, enum: ROLES, default: 'attendee' },
// There is NO password here. Authentication is the whole of module 8:
// hashing, sessions, JWT and role-based access control.
},
{ timestamps: true },
);
const User = mongoose.model('User', userSchema);
module.exports = { User, ROLES };That comment in User is not an oversight: it is a decision. The user exists so orders can reference it and so roles are already modeled, but we are not implementing authentication. Storing passwords badly is worse than not storing them at all.
Schema validators versus zod validation
In Module 6 we put zod at the edge, with createOrderSchema and the validate middleware that leaves the result in req.validatedData. Now Mongoose schema validation shows up. Isn't that duplicated work? No: they are different layers, with different clients.
| zod (HTTP edge) | Mongoose (schema) | |
|---|---|---|
| What it validates | The shape of the request body | The shape of the document before writing it |
| What triggers it | Every HTTP request | Every save() or create(), wherever it comes from |
| Protects you from | Clients that send garbage | Your own bugs, scripts, migrations, seeds |
| Error it produces | ValidationError → 400/422 |
Mongoose's ValidationError |
The rule in one sentence: zod validates what comes in through the door; Mongoose validates what goes out to disk. A nightly import script does not go through Express and therefore does not go through zod; if the schema did not validate, it could leave sold: 9999 on a session with a capacity of 300. And zod can reject things Mongoose does not care about (an extra field, an unknown channel) before spending a round trip to the database. Above both of them sits the domain: Session.sell() keeps its invariant in memory. Three layers, three moments, none of them redundant.
_id versus business identifiers
Our documents have two identifiers. The _id is technical: unique, generated without coordination, efficient as an index key and the target of the references populate uses. The business identifier (evt-001, ses-001-1, EV-2026-000431) is semantic: it appears in URLs, emails, printed tickets, the CSV files of Module 3 and the zod schemas of Module 6; and it is stable across migrations, because if tomorrow we move the data to PostgreSQL the _ids disappear but evt-001 is still evt-001. Using _id in public URLs ties you to the engine and leaks internal information; using only the business one as _id is defensible, but you lose the implicit chronological ordering. Keeping both, with a unique index on the business one, is what systems expecting to live for years normally do.
Methods, statics and virtuals
Virtuals reproduce the rich domain getters from Module 2: computed fields that are not stored.
// src/models/event.js (extension)
sessionSchema.virtual('available').get(function () {
return this.capacity - this.sold;
});
sessionSchema.virtual('occupancy').get(function () {
return this.capacity === 0 ? 0 : Number((this.sold / this.capacity).toFixed(4));
});
sessionSchema.virtual('soldOut').get(function () {
return this.sold >= this.capacity;
});
eventSchema.virtual('totalCapacity').get(function () {
return this.sessions.reduce((total, session) => total + session.capacity, 0);
});
// Instance method: operates on ONE document.
eventSchema.methods.findSession = function (sessionId) {
return this.sessions.find((session) => session.sessionId === sessionId) ?? null;
};
// Static method: operates on the model (the whole collection).
eventSchema.statics.findByVenue = function (venue) {
return this.find({ venue, status: 'published' }).sort({ title: 1 });
};Two warnings. Virtuals do not exist in the database: you cannot filter or sort by occupancy, because MongoDB does not know it exists; for that you need an aggregation (lesson 07-04) or a denormalized stored field. And lean() removes them: if you ask for plain objects for performance, you lose virtuals and methods.
On function versus arrow: in methods, virtuals and hooks you must use function, because Mongoose binds this to the document. An arrow captures the module's lexical this and will leave you with undefined. It is the number-one mistake of newcomers.
Schema middleware and toJSON
// src/models/order.js: runs before saving; throwing aborts the write.
orderSchema.pre('save', function (next) {
if (this.isNew && this.totalCents === 0 && this.quantity > 0) {
return next(new Error('An order with tickets cannot have a total of zero'));
}
next();
});Responsible use of hooks means three things:
- No external side effects. Sending an email or calling an API inside a
pre('save')couples persistence to a remote service: a network failure would stop the save. That belongs outside, or in theSalesManagerwe already have. - Remember which hooks fire.
pre('save')does not run onupdateOne,findOneAndUpdateordeleteMany, because those operations happen on the server without materializing the document. If the logic is non-negotiable, put it in the schema or in the repository. And keep them fast: a slow hook is paid for on every write.
// Output transformation, applicable to all four schemas.
const configureOutput = (schema) => schema.set('toJSON', {
virtuals: true, // includes available, occupancy, soldOut...
versionKey: false, // removes __v
transform(document, plain) {
plain.id = document._id.toString();
delete plain._id;
return plain;
},
});__v is Mongoose's internal version counter: it adds nothing for an HTTP client and only raises questions. And exposing a raw _id couples your public contract to the engine. This transformation is the boundary between "how I store" and "what I publish", the same separation the repositories were defending.
Declared indexes
An index speeds up reads and makes writes more expensive. They are declared per expected query, not on a whim:
// Catalog for one venue, sorted: equality filters first, then the
// sort field.
eventSchema.index({ status: 1, venue: 1, title: 1 });
// Date range. Because it is a field inside an array of subdocuments, MongoDB
// creates a multikey index automatically.
eventSchema.index({ 'sessions.dateTime': 1 });
// Locate the event that contains a session. Unique: a sessionId never repeats.
eventSchema.index({ 'sessions.sessionId': 1 }, { unique: true });| Index | Query it speeds up |
|---|---|
eventId (via unique) |
GET /events/evt-001 |
{ status, venue, title } |
Teatro Almendra's catalog sorted by title |
sessions.dateTime |
"What is on between March 1st and 15th" |
sessions.sessionId (unique) |
Finding ses-001-2 in order to sell |
Ticket.code (unique) / Order.userId |
Scanning at the door / "my orders" |
In development Mongoose creates these indexes at startup (autoIndex). In production it must be turned off (mongoose.set('autoIndex', false)) and they must be created in the corresponding migration: building an index over millions of documents during startup can block the application for several minutes. We come back to this in lesson 07-06.
Common Mistakes and Tips
- Arrow functions in methods, virtuals or hooks.
thisstops being the document and everything returnsundefined. - Believing
unique: truevalidates. It is an index; the error arrives asE11000, not as aValidationError. - Calling
mongoose.connecton every request. The connection belongs to the process and it is pooled; connecting per request exhausts the system's sockets. - Logging the connection URL. If it carries credentials, you have just leaked them. Log
connection.nameinstead. - Defining the same model twice.
mongoose.model('Event', ...)in two files throwsOverwriteModelError. - Tip: start with generous
requiredandenumdeclarations — relaxing a schema is cheaper than cleaning up incoherent data — and always keep money as an integer number of cents.
Exercises
Exercise 1: venue model
Write src/models/venue.js with venueId (unique, in the venue-xxx format), name, city, maxCapacity (integer ≥ 1) and active (boolean, true by default). Add a label virtual returning "<name> (<city>)", a findActive() static, timestamps and the toJSON transformation.
Exercise 2: cross-field validator
Add a validator to Ticket that prevents setting usedAt to a date when the status is cancelled. Explain why it would not fire with findByIdAndUpdate without options.
Exercise 3: choosing indexes
Decide the index and the order of its fields for: (a) a user's paid orders sorted by date descending; (b) the valid tickets of a session; (c) an organizer's events in published status.
Solutions
Exercise 1.
// src/models/venue.js
const venueSchema = new Schema(
{
venueId: { type: String, required: true, unique: true, match: /^venue-[a-z]+$/ },
name: { type: String, required: true, trim: true },
city: { type: String, required: true, trim: true, index: true },
maxCapacity: { type: Number, required: true, min: 1 },
active: { type: Boolean, required: true, default: true },
},
{ timestamps: true },
);
venueSchema.virtual('label').get(function () {
return `${this.name} (${this.city})`;
});
venueSchema.statics.findActive = function () {
return this.find({ active: true }).sort({ name: 1 });
};
venueSchema.set('toJSON', { virtuals: true, versionKey: false });
module.exports = { Venue: mongoose.model('Venue', venueSchema) };Exercise 2.
ticketSchema.path('usedAt').validate(function checkUsage(value) {
// A cancelled ticket can never carry a usage timestamp.
return !(this.status === 'cancelled' && value !== null);
}, 'A cancelled ticket cannot record a usage date');It does not fire because updates run on the server without building the document: Mongoose only validates if you pass { runValidators: true }, and even then this is the query, not the document, so a validator depending on other fields may have no access to them.
Exercise 3. (a) { userId: 1, status: 1, createdAt: -1 }. (b) { sessionId: 1, status: 1 }. (c) { organizerId: 1, status: 1 }. The rule is called ESR: equality-compared fields first (Equality), then the sort field (Sort) and finally the range ones (Range).
Conclusion
Escena Viva now has real persistence. You know what MongoDB stores and how, what Mongoose adds on top and at what price, and how to open the connection from validated configuration and wire it into startup and graceful shutdown. You have translated the previous lesson's diagram into four models — Event with its embedded sessions, Order, Ticket and a passwordless User waiting for Module 8 — with their types, validators, virtuals, middleware, a clean toJSON and reasoned indexes. And you have seen why three validation layers coexist without getting in each other's way: zod at the edge, the schema before the disk and the domain in memory.
In the next lesson we start moving data: the four CRUD operations with their traps — lazy Query objects, lean(), pagination, runValidators, $inc, soft deletes — the translation of Mongoose errors into our Module 6 hierarchy, and the moment we have been waiting for: retiring src/catalog-data.js and replacing it with src/repositories/events.js without the controllers noticing.
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
