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

  1. What MongoDB is: documents, collections and ObjectId
  2. What Mongoose is and whether it is worth it
  3. Connection, startup and graceful shutdown
  4. The Event schema with embedded sessions
  5. Schema types and options
  6. Order, Ticket and a minimal User
  7. Schema validators versus zod validation
  8. _id versus business identifiers
  9. Methods, statics and virtuals
  10. Schema middleware and toJSON
  11. 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:

  1. 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 the SalesManager we already have.
  2. Remember which hooks fire. pre('save') does not run on updateOne, findOneAndUpdate or deleteMany, 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. this stops being the document and everything returns undefined.
  • Believing unique: true validates. It is an index; the error arrives as E11000, not as a ValidationError.
  • Calling mongoose.connect on 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.name instead.
  • Defining the same model twice. mongoose.model('Event', ...) in two files throws OverwriteModelError.
  • Tip: start with generous required and enum declarations — 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

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