Table of Contents

0. How To Use This Book 1

1. Getting Started 2

  1. What is Mongoose? 2
  2. Connecting to MongoDB 3
  3. Defining a Schema 4
  4. Storing and Querying Documents 6

2. Core Concepts 8

  1. Models, Schemas, Connections, Documents, Queries 8
  2. Documents: Casting, Change Tracking, and Validation 9
  3. Schemas and SchemaTypes 14
  4. Getters and Setters 15
  5. Virtuals 20
  6. Queries 26
  7. The Mongoose Global and Multiple Connections 24
  8. Summary 26

3. Queries 27

  1. Query Execution 27
  2. Query Operations 29
  3. Query Operators 34
  4. Update Operators 43
  5. Sort Order 48
  6. Limit, Skip, Project 52
  7. Query Casting and Validators 56
  8. Summary 61

4. Middleware 62

  1. pre() and post() 62
  2. Async Middleware Functions 64
  3. Document Middleware 65
  4. Model Middleware 67
  5. Aggregation Middleware 68
  6. Query Middleware 71
  7. Error Handling Middleware 75
  8. Summary 76

5. Populate 77

  1. The ref Property 78
  2. populate() Syntax 80
  3. Virtual Populate 84
  4. One-To-One, One-To-Many, Many-To-Many 87
  5. Deep Populate 92
  6. Manual Population 94
  7. Populating Across Databases 95
  8. Summary 96

6. Schema Design and Performance 98

  1. Principle of Least Cardinality 98
  2. Principle of Denormalization 102
  3. Principle of Data Locality 105
  4. Indexes 107
  5. Index Specificity and explain() 115
  6. Slow Trains and Aggregations 121
  7. Manual Sharding 124
  8. Summary 125

7. App Structure 127

  1. Exporting Schemas vs Models 127
  2. Directory Structures 130
  3. Custom Validators vs Middleware 135
  4. Pagination 139
  5. Storing Passwords and OAuth Tokens 141
  6. Integrating with Express 143
  7. Integrating with WebSockets 148
  8. Summary 153

0: How To Use This Book

Mongoose is an object-document mapping (ODM) framework for Node.js and MongoDB. It is the most popular database framework for Node.js in terms of npm downloads (over 800,000 per month) and usage on GitHub (over 939,000 repos depend on Mongoose).

I'm Valeri Karpov, the maintainer of Mongoose since April 2014. Mongoose was one of the first Node.js projects when it was written in 2010, and by 2014 the early maintainers had moved on. I had been using Mongoose since 2012, so I jumped on the opportunity to work on the project as fast as I could. I've been primarily responsible for Mongoose for most of its 10 years of existence.

This eBook contains the distilled experience of 8 years working with MongoDB and Mongoose, and 6 years working on Mongoose. The goal of this book is to take you from someone who is familiar with Node.js and MongoDB, to someone who can architect a Mongoose-based backend for a large scale business. Here are the guiding principles for this eBook:

Before we get started, here's some technical details.

Mongoose is a powerful tool for building backend applications. Mongoose provides change tracking and schema validation out of the box, letting you work with a schema-less database without worrying about malformed data. Mongoose also provides tools for customizing your database interactions, like middleware and plugins. Mongoose's community also supports a wide variety of plugins for everything from pagination to encryption to custom types.

Are you ready to become a Mongoose master? Let's get started!

1: Getting Started

1.1: What is Mongoose?

Mongoose is a object-document mapping (ODM) framework for Node.js and MongoDB. This definition is nuanced, so here's an overview of the core benefits of using Mongoose.

Core Benefits

Mongoose is built on top of the official MongoDB Node.js Driver, which this book will refer to as just "the driver". The driver is responsible for maintaining a connection to MongoDB and serializing commands into MongoDB's wire protocol. The driver is an rapidly growing project and it is entirely possible to build an application without Mongoose. The major benefits of using Mongoose over using the driver directly are:

In order to clarify what Mongoose is, let's take a look at how Mongoose differs from other modules you may have used.

ODM vs ORM

Mongoose is an ODM, not an ORM (object-relational mapping). Some common ORMs are ActiveRecord, Hibernate, and Sequelize. Here's the key difference between ORMs and ODMs: ORMs store data in relational databases, so data from one ORM object may end up in multiple rows in multiple tables. In other words, how your data looks in JavaScript may be completely different from how your ORM stores the data.

MongoDB allows storing arbitrary objects, so Mongoose doesn't need to slice your data into different collections. Like an ORM, Mongoose validates your data, provides a middleware framework, and transforms vanilla JavaScript operations into database operations. But unlike an ORM, Mongoose ensures your objects have the same structure in Node.js as they do when they're stored in MongoDB.

Framework vs Library

Mongoose is more a framework than a library, although it has elements of both. For the purposes of this book, a framework is a module that executes your code for you, whereas a library is a module that your code calls. Modules that are either a pure framework or a pure library are rare. But, for example, Lodash is a library, Mocha is a framework, and Mongoose is a bit of both.

A database driver, like the MongoDB Node.js driver, is a library. It exposes several functions that you can call, but it doesn't provide middleware or any other paradigm for structuring code that uses the library. On the other hand, Mongoose provides middleware, custom validators, custom types, and other paradigms for code organization.

The MongoDB driver doesn't prescribe any specific architecture. You can build your project using traditional MVC architecture, aspect-oriented, reactive extensions, or anything else. Mongoose, on the other hand, is designed to fit the "model" portion of MVC or MVVM.

Summary

Mongoose is an ODM for Node.js and MongoDB. It provides schema validation, change tracking, middleware, and plugins. Mongoose also makes it easy to build apps using MVC patterns. Next, let's take a look at some basic Mongoose patterns that almost all Mongoose apps share.

1.2 Connecting to MongoDB

To use Mongoose, you need to open at least one connection to a MongoDB server, replica set, or sharded cluster.

This book will assume you already have a MongoDB instance running. If you don't have MongoDB set up yet, the easiest way to get started is a cloud instance using MongoDB Atlas' free tier. If you prefer a local MongoDB instance, run-rs is an npm module that automatically installs and runs MongoDB for you.

Here's how you connect Mongoose to a MongoDB instance running on your local machine on the default port.

const mongoose = require('mongoose');

mongoose.connect('mongodb://localhost:27017/mydb');

The first parameter to mongoose.connect() is known as the connection string. The connection string defines which MongoDB server(s) you're connecting to and the name of the database you want to use. More sophisticated connection strings can also include authentication information and configuration options.

The mydb section of the connection string tells Mongoose which database to use. MongoDB stores data in the form of documents. A document is essentially a Node.js object, and analagous to a row in SQL databases. Every document belongs to a collection, and every collection belongs to a database. For the purposes of this book, you can think of a database as a set of collections.

Although connecting to MongoDB is an asynchronous operation, you don't have to wait for connecting to succeed before using Mongoose. You can, and generally should, await on mongoose.connect() to ensure Mongoose connects successfully.

await mongoose.connect('mongodb://localhost:27017/mydb');

However, many Mongoose apps do not wait for Mongoose to connect because it isn't strictly necessary. Don't be surprised if you see mongoose.connect() with no await or then().

1.3 Defining a Schema

To store and query MongoDB documents using Mongoose, you need to define a model. Mongoose models are the primary tool you'll use for creating and loading documents. To define a model, you first need to define a schema.

In general, Mongoose schemas are objects that configure models. In particular, schemas are responsible for defining what properties your documents can have and what types those properties must be.

For example, suppose you're creating a model to represent a product. A minimal product should have a string property name and a number property price as shown below.

const productSchema = new mongoose.Schema({
  // A product has two properties: `name` and `price`
  name: String,
  price: Number
});

// The `mongoose.model()` function has 2 required parameters:
// The 1st param is the model's name, a string
// The 2nd param is the schema
const Product = mongoose.model('Product', productSchema);

const product = new Product({
  name: 'iPhone',
  price: '800', // Note that this is a string, not a number
  notInSchema: 'foo'
});

product.name; // 'iPhone'
product.price; // 800, Mongoose converted this to a number
// undefined, Mongoose removes props that aren't in the schema
product.notInSchema;

The mongoose.model() function takes the model's name and schema as parameters, and returns a class. That class is configured to cast, validate, and track changes on the paths defined in the schema. Schemas have numerous features beyond just type checking. For example, you can make Mongoose lowercase the product's name as shown below.

const productSchema = new mongoose.Schema({
  // The below means the `name` property should be a string
  // with an option `lowercase` set to `true`.
  name: { type: String, lowercase: true },
  price: Number
});
const Product = mongoose.model('Product', productSchema);

const product = new Product({ name: 'iPhone', price: 800 });

product.name; // 'iphone', lowercased

Internally, when you instantiate a Mongoose model, Mongoose defines native JavaScript getters and setters for every path in your schema on every instance of that model. Here's an example of a simplified Product class implemented using ES6 classes that mimics how a Mongoose model works.

class Product {
  constructor(obj) {
    // `_doc` stores the raw data, bypassing getters and setters
    // Otherwise `doc.name = 'foo'` causes infinite recursion
    this._doc = {};
    Object.assign(this, { name: obj.name, price: obj.price });
  }

  get name() { return this._doc.name; }
  set name(v) { this._doc.name = v == null ? v : '' + v; }

  get price() { return this._doc.price; }
  set price(v) { this._doc.price = v == null ? v : +v; }
}

const p = new Product({ name: 'iPhone', price: '800', foo: 'bar' });
p.name; // 'iPhone'
p.price; // 800
p.foo; // undefined

1.4 Storing and Querying Documents

In Mongoose, a model is a class, and an instance of a model is called a document. This book also defined a "document" as an object stored in a MongoDB collection in section 1.2. These two definitions are equivalent for practical purposes, because a Mongoose document in Node.js maps one-to-one to a document stored on the MongoDB server.

There are two ways to create a Mongoose document: you can instantiate a model to create a new document, or you can execute a query to load an existing document from the MongoDB server.

To create a new document, you can use your model as a constructor. This creates a new document that has not been stored in MongoDB yet. Documents have a save() function that you use to persist changes to MongoDB. When you create a new document, the save() function sends an insertOne() operation to the MongoDB server.

// `doc` is just in Node.js, you haven't persisted it to MongoDB yet
const doc = new Product({ name: 'iPhone', price: 800 });

// Store `doc` in MongoDB
await doc.save();

That's how you create a new document. To load an existing document from MongoDB, you can use the Model.find() or Model.findOne() functions. There are several other query functions you can use that you'll learn about in Chapter 3.

// `Product.findOne()` returns a Mongoose _query_. Queries are
// thenable, which means you can `await` on them.
let product = await Product.findOne();
product.name; // "iPhone"
product.price; // 800

const products = await Product.find();
product = products[0];
product.name; // "iPhone"
product.price; // 800

Connecting to MongoDB, defining a schema, creating a model, creating documents, and loading documents are the basic patterns that you'll see in almost every Mongoose app. Now that you've seen the verbs describing what actions you can do with Mongoose, let's define the nouns that are responsible for these actions.