Node.jsBackendFull-Stack

MongoDB & Mongoose: CRUD Operations in Node.js

TT
TopicTrick Team
MongoDB & Mongoose: CRUD Operations in Node.js

MongoDB & Mongoose: CRUD Operations in Node.js

MongoDB is the database of choice for a large share of Node.js applications. Its document model maps naturally to JavaScript objects, and its horizontal scaling story handles internet-scale traffic. But MongoDB alone gives you no structure. Mongoose gives you that structure while keeping MongoDB's flexibility.

In this module you will connect to MongoDB, define schemas and models, run every type of CRUD operation, handle validation errors, and wire the data layer into the Express feature structure from Module 13.

This is Module 14 of the Node.js Full‑Stack Developer course.


Installing Mongoose

bash
npm install mongoose

Mongoose requires no separate MongoDB driver installation — it bundles the official driver internally.


Connecting to MongoDB

Create a dedicated config module for the database connection:

js
// config/db.js
import mongoose from 'mongoose';

export async function connectDB() {
  const uri = process.env.MONGODB_URI;

  if (!uri) {
    throw new Error('MONGODB_URI environment variable is not set');
  }

  mongoose.connection.on('connected', () => {
    console.log('MongoDB connected');
  });

  mongoose.connection.on('error', (err) => {
    console.error('MongoDB connection error:', err);
  });

  mongoose.connection.on('disconnected', () => {
    console.warn('MongoDB disconnected');
  });

  await mongoose.connect(uri, {
    serverSelectionTimeoutMS: 5000,  // fail fast if MongoDB is unreachable
  });
}

Call connectDB() before starting the HTTP server:

js
// server.js
import app from './app.js';
import { connectDB } from './config/db.js';

const PORT = process.env.PORT || 3000;

async function start() {
  await connectDB();
  app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
}

start().catch(err => {
  console.error('Failed to start:', err);
  process.exit(1);
});

For local development, your .env file:

text
MONGODB_URI=mongodb://localhost:27017/myapp

For production, use a connection string from MongoDB Atlas.


Defining a Schema

A Mongoose schema defines the shape of documents in a collection. Every field has a type and optional constraints:

js
// features/users/users.model.js
import mongoose from 'mongoose';
import bcrypt from 'bcrypt';

const userSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: [true, 'Name is required'],
      trim: true,
      minlength: [2, 'Name must be at least 2 characters'],
      maxlength: [100, 'Name must be at most 100 characters'],
    },
    email: {
      type: String,
      required: [true, 'Email is required'],
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, 'Invalid email format'],
    },
    password: {
      type: String,
      required: [true, 'Password is required'],
      minlength: 8,
      select: false,  // excluded from queries by default
    },
    role: {
      type: String,
      enum: ['user', 'admin'],
      default: 'user',
    },
    isActive: {
      type: Boolean,
      default: true,
    },
    lastLogin: {
      type: Date,
    },
  },
  {
    timestamps: true,   // adds createdAt and updatedAt automatically
    toJSON: {
      virtuals: true,
      transform(doc, ret) {
        delete ret.password;  // never serialize password
        delete ret.__v;
        return ret;
      },
    },
  }
);

// ── Virtuals ─────────────────────────────────────────────────────────────────
userSchema.virtual('isAdmin').get(function () {
  return this.role === 'admin';
});

// ── Pre-save middleware ───────────────────────────────────────────────────────
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

// ── Instance methods ──────────────────────────────────────────────────────────
userSchema.methods.comparePassword = async function (candidate) {
  return bcrypt.compare(candidate, this.password);
};

// ── Static methods ────────────────────────────────────────────────────────────
userSchema.statics.findByEmail = function (email) {
  return this.findOne({ email: email.toLowerCase() }).select('+password');
};

export const User = mongoose.model('User', userSchema);

Schema Types

TypeExample
StringName, email, role
NumberPrice, age, quantity
BooleanisActive, isVerified
DatecreatedAt, expiresAt
ObjectIdForeign keys (refs)
ArrayTags, permissions
MixedArbitrary objects (avoid)
MapKey-value with dynamic keys
BufferBinary data

CRUD Operations

Create

js
// Method 1: Model.create() — single document
const user = await User.create({
  name: 'Alice',
  email: 'alice@example.com',
  password: 'secret123',
});

// Method 2: new Model() + save() — gives you pre-save hook control
const user = new User({ name: 'Alice', email: 'alice@example.com', password: 'secret123' });
await user.save();

// Method 3: insertMany() — efficient bulk insert
const users = await User.insertMany([
  { name: 'Bob', email: 'bob@example.com', password: 'pw1' },
  { name: 'Carol', email: 'carol@example.com', password: 'pw2' },
]);

Read

js
// Find all
const users = await User.find();

// Find with filter
const activeUsers = await User.find({ isActive: true });

// Find one
const user = await User.findOne({ email: 'alice@example.com' });

// Find by primary key
const user = await User.findById('64abc123def456...');

// Projection — select specific fields
const users = await User.find({}, { name: 1, email: 1 }); // include
const users = await User.find({}, { password: 0 });       // exclude

// Query builder (chainable)
const users = await User
  .find({ isActive: true })
  .select('name email role')
  .sort({ createdAt: -1 })
  .skip(20)
  .limit(10)
  .lean();                // plain objects, faster for reads

// Count
const total = await User.countDocuments({ isActive: true });

Query Operators

js
// Comparison
await User.find({ age: { $gt: 18, $lt: 65 } });
await User.find({ role: { $in: ['admin', 'moderator'] } });
await User.find({ role: { $nin: ['banned'] } });
await User.find({ deletedAt: { $exists: false } });

// Logical
await User.find({ $or: [{ role: 'admin' }, { isActive: false }] });
await User.find({ $and: [{ isActive: true }, { role: 'user' }] });

// String
await User.find({ name: /^alice/i });              // regex
await User.find({ name: { $regex: 'ali', $options: 'i' } });

// Array
await User.find({ tags: 'nodejs' });              // contains element
await User.find({ tags: { $all: ['node', 'express'] } }); // contains all
await User.find({ tags: { $size: 3 } });          // array length

Update

js
// findByIdAndUpdate — returns updated document
const user = await User.findByIdAndUpdate(
  id,
  { $set: { name: 'Alice Updated', isActive: false } },
  { new: true, runValidators: true }  // new: return updated doc; runValidators: run schema validators
);

// findOneAndUpdate — find by any field
const user = await User.findOneAndUpdate(
  { email: 'alice@example.com' },
  { $set: { lastLogin: new Date() } },
  { new: true }
);

// updateMany — update multiple documents
const result = await User.updateMany(
  { isActive: false, updatedAt: { $lt: new Date('2025-01-01') } },
  { $set: { archived: true } }
);
console.log(result.modifiedCount); // how many were updated

// Atomic operators
await User.findByIdAndUpdate(id, {
  $set: { name: 'New Name' },            // set fields
  $unset: { tempToken: '' },             // remove field
  $inc: { loginCount: 1 },              // increment
  $push: { tags: 'nodejs' },             // append to array
  $pull: { tags: 'python' },            // remove from array
  $addToSet: { permissions: 'write' },   // add to array if not present
});

Delete

js
// findByIdAndDelete — delete and return the deleted document
const deleted = await User.findByIdAndDelete(id);
if (!deleted) throw new AppError('User not found', 404);

// deleteMany — delete multiple
const result = await User.deleteMany({ isActive: false, archived: true });
console.log(result.deletedCount);

// Soft delete (recommended for most production apps)
const user = await User.findByIdAndUpdate(
  id,
  { deletedAt: new Date() },
  { new: true }
);
// Then filter all queries: User.find({ deletedAt: null })

Validation Errors

When a Mongoose validation fails, it throws a ValidationError with a errors map. Catch it in the global error handler:

js
// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const errors = Object.values(err.errors).map(e => ({
      field: e.path,
      message: e.message,
    }));
    return res.status(422).json({ status: 'error', errors });
  }

  // MongoDB duplicate key (unique constraint violation)
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    return res.status(409).json({
      status: 'error',
      message: `${field} is already taken`,
    });
  }

  // Mongoose CastError (invalid ObjectId)
  if (err.name === 'CastError') {
    return res.status(400).json({
      status: 'error',
      message: `Invalid ${err.path}: ${err.value}`,
    });
  }

  const status = err.statusCode || 500;
  res.status(status).json({ status: 'error', message: err.message });
}

ObjectId Validation

Always validate req.params.id before passing it to Mongoose:

js
// middleware/validateObjectId.js
import mongoose from 'mongoose';
import { AppError } from '../lib/errors.js';

export function validateObjectId(req, res, next) {
  if (!mongoose.isValidObjectId(req.params.id)) {
    return next(new AppError(`Invalid id: ${req.params.id}`, 400));
  }
  next();
}
js
// users.router.js
router.get('/:id', validateObjectId, requireAuth, controller.getById);
router.put('/:id', validateObjectId, requireAuth, validate(updateUserSchema), controller.update);
router.delete('/:id', validateObjectId, requireAuth, requireRole('admin'), controller.remove);

Pagination

js
// features/users/users.service.js
export async function listUsers({ page = 1, limit = 20, sort = '-createdAt' } = {}) {
  const pageNum = Math.max(1, parseInt(page));
  const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
  const skip = (pageNum - 1) * limitNum;

  const [users, total] = await Promise.all([
    User.find({ deletedAt: null })
      .select('-password -__v')
      .sort(sort)
      .skip(skip)
      .limit(limitNum)
      .lean(),
    User.countDocuments({ deletedAt: null }),
  ]);

  return {
    data: users,
    meta: {
      total,
      page: pageNum,
      limit: limitNum,
      totalPages: Math.ceil(total / limitNum),
      hasNextPage: pageNum < Math.ceil(total / limitNum),
      hasPrevPage: pageNum > 1,
    },
  };
}

Populating References

When one document references another by ObjectId, .populate() fetches the referenced document:

js
// features/posts/posts.model.js
const postSchema = new mongoose.Schema({
  title: { type: String, required: true },
  body: { type: String, required: true },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',         // reference the 'User' model
    required: true,
  },
  tags: [String],
}, { timestamps: true });

export const Post = mongoose.model('Post', postSchema);
js
// Populate the author field
const posts = await Post
  .find()
  .populate('author', 'name email')   // only fetch name and email
  .lean();

// posts[0].author is now { _id, name, email } instead of an ObjectId

// Nested populate
const posts = await Post
  .find()
  .populate({
    path: 'author',
    select: 'name email',
    populate: {
      path: 'company',
      select: 'name',
    },
  })
  .lean();

Use populate judiciously — each populated field is an additional database query. For performance-critical list endpoints, consider storing denormalized data or using MongoDB's $lookup aggregation instead.


Aggregation Pipeline

For complex read operations — grouping, joining, computing stats — use MongoDB's aggregation pipeline:

js
// Get user count by role
const stats = await User.aggregate([
  { $match: { isActive: true } },
  { $group: { _id: '$role', count: { $sum: 1 } } },
  { $sort: { count: -1 } },
]);
// [{ _id: 'user', count: 124 }, { _id: 'admin', count: 3 }]

// Revenue by month
const revenue = await Order.aggregate([
  { $match: { status: 'completed', createdAt: { $gte: startOfYear } } },
  {
    $group: {
      _id: { $month: '$createdAt' },
      total: { $sum: '$amount' },
      count: { $sum: 1 },
    },
  },
  { $sort: { '_id': 1 } },
]);

Indexes

Without indexes, MongoDB scans every document for every query. Add indexes for any field you filter or sort on frequently:

js
const userSchema = new mongoose.Schema({
  email: { type: String, unique: true },  // unique: true creates an index automatically
  role: String,
  isActive: Boolean,
  createdAt: Date,
});

// Compound index for common query pattern
userSchema.index({ isActive: 1, createdAt: -1 });

// Text index for full-text search
userSchema.index({ name: 'text', email: 'text' });

Check which indexes exist:

bash
# In MongoDB shell
db.users.getIndexes()

Environment Variables for MongoDB

text
# .env
MONGODB_URI=mongodb+srv://username:password@cluster0.abc.mongodb.net/myapp?retryWrites=true&w=majority

For local development with Docker:

yaml
# docker-compose.yml
services:
  mongo:
    image: mongo:7
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db

volumes:
  mongo_data:
text
MONGODB_URI=mongodb://localhost:27017/myapp

Node.js Full‑Stack Course — Module 14 of 32

You can now connect an Express API to MongoDB and run any CRUD operation with Mongoose. Continue to Module 15 to learn PostgreSQL with Prisma ORM.


    Summary

    MongoDB and Mongoose together form a productive data layer for Node.js:

    • Connect with mongoose.connect(uri) before starting the HTTP server — never inside route handlers
    • Schema types and validators enforce data integrity at the application level
    • { timestamps: true } adds createdAt and updatedAt automatically
    • select: false hides sensitive fields (like password) from all queries by default
    • Use .lean() on read-only list queries for a significant performance improvement
    • Handle ValidationError, code 11000 (duplicate), and CastError (bad ObjectId) in the global error handler
    • Always check mongoose.isValidObjectId(id) before querying to avoid uncaught CastErrors
    • Use .populate() to join documents; use aggregation for complex reporting queries

    Continue to Module 15: PostgreSQL & Prisma ORM →