Back to blog

Express.js Làm Việc Với MongoDB và Mongoose

expressnodejsmongodbmongoosebackend
Express.js Làm Việc Với MongoDB và Mongoose

Trong bài trước, bạn đã thành thạo Express Router, request validation và file uploads. Bạn xây dựng route structures gọn gàng với validators và controllers — nhưng mọi thứ sống trong memory. Restart server? Data bay hết.

Đã đến lúc fix điều đó. Trong bài này, chúng ta sẽ kết nối Express app với MongoDB dùng Mongoose — ODM (Object Data Modeling) library phổ biến nhất cho Node.js. Cuối bài, bạn sẽ có blog API persistent hoàn chỉnh với users, posts và comments.

Bạn Sẽ Học Được Gì

Kết Quả Học Tập:
✅ Cài đặt MongoDB locally và với MongoDB Atlas (cloud)
✅ Kết nối Express với MongoDB với error handling đúng cách
✅ Định nghĩa Mongoose schemas với validation và defaults
✅ Thực hiện full CRUD operations (Create, Read, Update, Delete)
✅ Dùng virtuals, instance methods và static methods
✅ Mô hình hóa relationships với references và population
✅ Implement pagination, filtering và sorting
✅ Xây dựng blog API hoàn chỉnh với users, posts và comments

Thời gian: 5–7 ngày, 1–2 giờ mỗi ngày
Yêu cầu: Express.js Routing and Request Handling


Phần 1: Cài Đặt MongoDB

MongoDB Là Gì?

MongoDB là document database — thay vì rows và columns (như PostgreSQL hay MySQL), nó lưu trữ data dạng JSON-like documents linh hoạt:

{
  "_id": "507f1f77bcf86cd799439011",
  "name": "Chanh Le",
  "email": "[email protected]",
  "posts": [
    { "title": "Getting Started with Express", "likes": 42 }
  ]
}

Khác biệt chính so với SQL databases:

FeatureSQL (PostgreSQL)NoSQL (MongoDB)
Data modelTables với rowsCollections với documents
SchemaCố định, định nghĩa trướcLinh hoạt (schema-less mặc định)
RelationshipsJOINsEmbedding hoặc references
Query languageSQLMongoDB Query Language
ScalingVertical (scale up)Horizontal (scale out)
Phù hợp choRelationships phức tạp, transactionsSchemas linh hoạt, iteration nhanh

Cài Đặt MongoDB Locally

macOS:

brew tap mongodb/brew
brew install mongodb-community
brew services start mongodb-community

Linux (Ubuntu/Debian):

# Import MongoDB public GPG key
curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmor
 
# Add repository
echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list
 
sudo apt-get update
sudo apt-get install -y mongodb-org
sudo systemctl start mongod

Docker (khuyến nghị cho development):

docker run -d --name mongodb \
  -p 27017:27017 \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=password \
  -v mongodb_data:/data/db \
  mongo:7

Kiểm tra kết nối:

mongosh
# Hoặc với Docker:
docker exec -it mongodb mongosh -u admin -p password

MongoDB Atlas (Cloud)

Cho production hoặc nếu không muốn cài locally:

  1. Vào mongodb.com/atlas và tạo tài khoản miễn phí
  2. Tạo free cluster (M0 tier — miễn phí mãi mãi)
  3. Setup database user và whitelist IP
  4. Lấy connection string: mongodb+srv://user:[email protected]/mydb

Setup Project

mkdir express-blog-api && cd express-blog-api
npm init -y
npm install express mongoose dotenv
npm install -D nodemon

Tạo file .env:

PORT=3000
MONGODB_URI=mongodb://localhost:27017/blog-api
# Cho Atlas: mongodb+srv://user:[email protected]/blog-api

Kết Nối MongoDB

Tạo src/config/database.js:

const mongoose = require('mongoose');
 
const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGODB_URI);
    console.log(`MongoDB Connected: ${conn.connection.host}`);
  } catch (error) {
    console.error(`MongoDB connection error: ${error.message}`);
    process.exit(1);
  }
};
 
// Xử lý connection events
mongoose.connection.on('disconnected', () => {
  console.log('MongoDB disconnected');
});
 
mongoose.connection.on('error', (err) => {
  console.error(`MongoDB error: ${err}`);
});
 
// Graceful shutdown
process.on('SIGINT', async () => {
  await mongoose.connection.close();
  console.log('MongoDB connection closed (app termination)');
  process.exit(0);
});
 
module.exports = connectDB;

Tạo src/app.js:

require('dotenv').config();
const express = require('express');
const connectDB = require('./config/database');
 
const app = express();
 
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
 
// Kết nối MongoDB
connectDB();
 
// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', db: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected' });
});
 
// Routes (sẽ thêm sau)
// app.use('/api/users', require('./routes/users'));
// app.use('/api/posts', require('./routes/posts'));
 
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Cập nhật package.json:

{
  "scripts": {
    "start": "node src/app.js",
    "dev": "nodemon src/app.js"
  }
}

Phần 2: Mongoose Schemas và Models

Mongoose Là Gì?

Mongoose là ODM (Object Data Modeling) library cung cấp giải pháp dựa trên schema cho MongoDB. Trong khi MongoDB là schema-less, Mongoose thêm cấu trúc:

Định Nghĩa Schema

Schema định nghĩa cấu trúc, types và rules cho documents.

Tạo src/models/User.js:

const mongoose = require('mongoose');
 
const userSchema = new mongoose.Schema(
  {
    username: {
      type: String,
      required: [true, 'Username is required'],
      unique: true,
      trim: true,
      minlength: [3, 'Username must be at least 3 characters'],
      maxlength: [30, 'Username cannot exceed 30 characters'],
    },
    email: {
      type: String,
      required: [true, 'Email is required'],
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email'],
    },
    password: {
      type: String,
      required: [true, 'Password is required'],
      minlength: [8, 'Password must be at least 8 characters'],
      select: false, // Không include trong queries mặc định
    },
    bio: {
      type: String,
      maxlength: [500, 'Bio cannot exceed 500 characters'],
      default: '',
    },
    avatar: {
      type: String,
      default: 'default-avatar.png',
    },
    role: {
      type: String,
      enum: ['user', 'admin'],
      default: 'user',
    },
  },
  {
    timestamps: true, // Tự động thêm createdAt và updatedAt
  }
);
 
module.exports = mongoose.model('User', userSchema);

Schema Types

Mongoose hỗ trợ các types này:

TypeJavaScriptVí dụ
StringString"hello"
NumberNumber42, 3.14
BooleanBooleantrue, false
DateDatenew Date()
BufferBufferBinary data
ObjectIdObjectIdReferences đến documents khác
ArrayArray["a", "b"]
MapMapKey-value pairs
Schema.Types.MixedObjectBất kỳ type nào

Validation

Mongoose cung cấp built-in và custom validators:

const postSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, 'Title is required'],
    minlength: [5, 'Title must be at least 5 characters'],
    maxlength: [200, 'Title cannot exceed 200 characters'],
  },
  slug: {
    type: String,
    unique: true,
    lowercase: true,
  },
  content: {
    type: String,
    required: [true, 'Content is required'],
    minlength: [50, 'Content must be at least 50 characters'],
  },
  status: {
    type: String,
    enum: {
      values: ['draft', 'published', 'archived'],
      message: '{VALUE} is not a valid status',
    },
    default: 'draft',
  },
  tags: {
    type: [String],
    validate: {
      validator: function (v) {
        return v.length <= 10;
      },
      message: 'A post can have at most 10 tags',
    },
  },
  views: {
    type: Number,
    default: 0,
    min: [0, 'Views cannot be negative'],
  },
  // Custom validator
  publishedAt: {
    type: Date,
    validate: {
      validator: function (v) {
        return !v || v <= new Date();
      },
      message: 'Published date cannot be in the future',
    },
  },
});

Phần 3: CRUD Operations

Tạo Documents

const User = require('../models/User');
 
// Cách 1: create()
const user = await User.create({
  username: 'johndoe',
  email: '[email protected]',
  password: 'securepassword123',
  bio: 'Full-stack developer',
});
 
// Cách 2: new + save()
const user = new User({
  username: 'janedoe',
  email: '[email protected]',
  password: 'securepassword456',
});
await user.save();
 
// Cách 3: insertMany (bulk)
const users = await User.insertMany([
  { username: 'user1', email: '[email protected]', password: 'pass12345678' },
  { username: 'user2', email: '[email protected]', password: 'pass12345678' },
]);

Đọc Documents

// Tìm tất cả
const users = await User.find();
 
// Tìm với conditions
const admins = await User.find({ role: 'admin' });
 
// Tìm một
const user = await User.findOne({ email: '[email protected]' });
 
// Tìm theo ID
const user = await User.findById('507f1f77bcf86cd799439011');
 
// Chọn fields cụ thể
const user = await User.findById(id).select('username email bio -_id');
 
// Query operators
const recentUsers = await User.find({
  createdAt: { $gte: new Date('2024-01-01') },
  role: { $in: ['user', 'admin'] },
});
 
// Sắp xếp
const users = await User.find().sort({ createdAt: -1 }); // Mới nhất trước
const users = await User.find().sort('username');          // Theo alphabet
 
// Limiting và skipping (pagination)
const page = 1;
const limit = 10;
const users = await User.find()
  .sort({ createdAt: -1 })
  .skip((page - 1) * limit)
  .limit(limit);
 
// Đếm documents
const totalUsers = await User.countDocuments({ role: 'user' });

Cập Nhật Documents

// findByIdAndUpdate (trả về document)
const user = await User.findByIdAndUpdate(
  id,
  { bio: 'Updated bio', role: 'admin' },
  {
    new: true,           // Trả về document đã cập nhật (không phải cái cũ)
    runValidators: true, // Chạy schema validators khi update
  }
);
 
// findOneAndUpdate
const user = await User.findOneAndUpdate(
  { email: '[email protected]' },
  { $set: { bio: 'New bio' } },
  { new: true, runValidators: true }
);
 
// updateOne (không trả về document)
await User.updateOne(
  { _id: id },
  { $set: { role: 'admin' } }
);
 
// updateMany (bulk update)
await User.updateMany(
  { role: 'user' },
  { $set: { verified: true } }
);
 
// Atomic operators
await User.findByIdAndUpdate(id, {
  $inc: { loginCount: 1 },    // Tăng
  $push: { tags: 'new-tag' }, // Thêm vào array
  $pull: { tags: 'old-tag' }, // Xóa khỏi array
  $addToSet: { followers: userId }, // Thêm unique vào array
});

Xóa Documents

// findByIdAndDelete
const user = await User.findByIdAndDelete(id);
if (!user) {
  throw new Error('User not found');
}
 
// findOneAndDelete
const user = await User.findOneAndDelete({ email: '[email protected]' });
 
// deleteOne
await User.deleteOne({ _id: id });
 
// deleteMany
const result = await User.deleteMany({ role: 'inactive' });
console.log(`Deleted ${result.deletedCount} users`);

Phần 4: Schema Features

Virtuals

Virtuals là properties không lưu trong MongoDB nhưng được tính từ fields có sẵn:

const userSchema = new mongoose.Schema({
  firstName: String,
  lastName: String,
});
 
// Virtual property: fullName
userSchema.virtual('fullName').get(function () {
  return `${this.firstName} ${this.lastName}`;
});
 
// Include virtuals trong JSON output
userSchema.set('toJSON', { virtuals: true });
userSchema.set('toObject', { virtuals: true });
 
const user = new User({ firstName: 'Chanh', lastName: 'Le' });
console.log(user.fullName); // "Chanh Le"

Instance Methods

Methods có sẵn trên document instances:

const userSchema = new mongoose.Schema({
  email: String,
  password: String,
});
 
// Instance method: so sánh passwords
userSchema.methods.comparePassword = async function (candidatePassword) {
  const bcrypt = require('bcrypt');
  return bcrypt.compare(candidatePassword, this.password);
};
 
// Instance method: tạo profile summary
userSchema.methods.getPublicProfile = function () {
  return {
    id: this._id,
    username: this.username,
    email: this.email,
    bio: this.bio,
    avatar: this.avatar,
    createdAt: this.createdAt,
  };
};
 
// Sử dụng
const user = await User.findById(id).select('+password');
const isMatch = await user.comparePassword('mypassword');
const profile = user.getPublicProfile();

Static Methods

Methods có sẵn trên model:

// Static method: tìm theo email
userSchema.statics.findByEmail = function (email) {
  return this.findOne({ email: email.toLowerCase() });
};
 
// Static method: lấy active users với pagination
userSchema.statics.getActiveUsers = function (page = 1, limit = 10) {
  return this.find({ role: { $ne: 'inactive' } })
    .sort({ createdAt: -1 })
    .skip((page - 1) * limit)
    .limit(limit);
};
 
// Sử dụng
const user = await User.findByEmail('[email protected]');
const activeUsers = await User.getActiveUsers(2, 20);

Middleware (Hooks)

Chạy functions trước hoặc sau certain operations:

const bcrypt = require('bcrypt');
 
// Pre-save: hash password trước khi lưu
userSchema.pre('save', async function (next) {
  // Chỉ hash nếu password bị modified
  if (!this.isModified('password')) return next();
 
  const salt = await bcrypt.genSalt(12);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});
 
// Pre-save: tạo slug từ title
postSchema.pre('save', function (next) {
  if (this.isModified('title')) {
    this.slug = this.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '');
  }
  next();
});
 
// Post-save: log creation
userSchema.post('save', function (doc) {
  console.log(`New user created: ${doc.email}`);
});
 
// Pre-find: loại trừ inactive mặc định
userSchema.pre(/^find/, function (next) {
  this.where({ active: { $ne: false } });
  next();
});

Indexes

Indexes tăng tốc queries — cần thiết cho production:

const postSchema = new mongoose.Schema({
  title: String,
  slug: { type: String, unique: true },        // Unique index
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  tags: [String],
  status: String,
  createdAt: Date,
});
 
// Single field index
postSchema.index({ createdAt: -1 });
 
// Compound index (cho query theo author + status cùng lúc)
postSchema.index({ author: 1, status: 1 });
 
// Text index (cho full-text search)
postSchema.index({ title: 'text', content: 'text' });
 
// Sử dụng text search
const results = await Post.find({ $text: { $search: 'express mongodb' } });

Phần 5: Relationships và Population

MongoDB không có JOINs, nhưng Mongoose cung cấp population — cách tự động thay thế references bằng documents thực tế.

Referenced Relationships

Tạo src/models/Post.js:

const mongoose = require('mongoose');
 
const postSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: [true, 'Title is required'],
      trim: true,
      maxlength: [200, 'Title cannot exceed 200 characters'],
    },
    slug: {
      type: String,
      unique: true,
      lowercase: true,
    },
    content: {
      type: String,
      required: [true, 'Content is required'],
    },
    excerpt: {
      type: String,
      maxlength: [500, 'Excerpt cannot exceed 500 characters'],
    },
    author: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'User',
      required: [true, 'Author is required'],
    },
    tags: {
      type: [String],
      default: [],
    },
    status: {
      type: String,
      enum: ['draft', 'published', 'archived'],
      default: 'draft',
    },
    views: {
      type: Number,
      default: 0,
    },
    publishedAt: Date,
  },
  {
    timestamps: true,
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
  }
);
 
// Virtual: comments cho post này
postSchema.virtual('comments', {
  ref: 'Comment',
  localField: '_id',
  foreignField: 'post',
});
 
// Pre-save: tạo slug
postSchema.pre('save', function (next) {
  if (this.isModified('title')) {
    this.slug = this.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '');
  }
  if (this.isModified('status') && this.status === 'published' && !this.publishedAt) {
    this.publishedAt = new Date();
  }
  next();
});
 
// Indexes
postSchema.index({ slug: 1 });
postSchema.index({ author: 1, status: 1 });
postSchema.index({ tags: 1 });
postSchema.index({ createdAt: -1 });
 
module.exports = mongoose.model('Post', postSchema);

Tạo src/models/Comment.js:

const mongoose = require('mongoose');
 
const commentSchema = new mongoose.Schema(
  {
    content: {
      type: String,
      required: [true, 'Comment content is required'],
      maxlength: [2000, 'Comment cannot exceed 2000 characters'],
    },
    post: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Post',
      required: [true, 'Post reference is required'],
    },
    author: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'User',
      required: [true, 'Author is required'],
    },
  },
  {
    timestamps: true,
  }
);
 
// Index cho queries hiệu quả
commentSchema.index({ post: 1, createdAt: -1 });
 
module.exports = mongoose.model('Comment', commentSchema);

Population (Thay Thế References Bằng Documents)

// Population cơ bản
const post = await Post.findById(id).populate('author');
// post.author giờ là full User document, không chỉ ObjectId
 
// Chọn fields cụ thể từ populated document
const post = await Post.findById(id).populate('author', 'username email avatar');
 
// Nhiều populations
const post = await Post.findById(id)
  .populate('author', 'username email avatar')
  .populate({
    path: 'comments',
    populate: { path: 'author', select: 'username avatar' }, // Nested population
    options: { sort: { createdAt: -1 }, limit: 10 },
  });
 
// Population với conditions
const posts = await Post.find({ status: 'published' })
  .populate({
    path: 'author',
    match: { role: 'admin' }, // Chỉ populate nếu author là admin
    select: 'username email',
  })
  .sort({ publishedAt: -1 });

Embedded Documents vs References

Khi nào embed vs reference?

Embed (subdocument)Reference (ObjectId + populate)
Data luôn truy cập cùng nhauData truy cập độc lập
One-to-few relationship (< 100)One-to-many hoặc many-to-many
Data ít thay đổiData thay đổi thường xuyên
Ví dụ: address trong userVí dụ: comments trên post

Ví dụ embedded:

const userSchema = new mongoose.Schema({
  username: String,
  // Embedded: address luôn được fetch cùng user
  address: {
    street: String,
    city: String,
    country: String,
    zipCode: String,
  },
  // Embedded array: social links
  socialLinks: [
    {
      platform: { type: String, enum: ['twitter', 'github', 'linkedin'] },
      url: String,
    },
  ],
});

Phần 6: Xây Dựng Blog API

Hãy kết hợp tất cả thành API hoàn chỉnh.

Cấu Trúc Project

src/
├── app.js
├── config/
│   └── database.js
├── models/
│   ├── User.js
│   ├── Post.js
│   └── Comment.js
├── routes/
│   ├── users.js
│   ├── posts.js
│   └── comments.js
├── controllers/
│   ├── userController.js
│   ├── postController.js
│   └── commentController.js
└── middleware/
    └── errorHandler.js

Error Handler Middleware

Tạo src/middleware/errorHandler.js:

const errorHandler = (err, req, res, next) => {
  console.error(err.stack);
 
  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const errors = Object.values(err.errors).map((e) => e.message);
    return res.status(400).json({ error: 'Validation failed', details: errors });
  }
 
  // Mongoose duplicate key error
  if (err.code === 11000) {
    const field = Object.keys(err.keyPattern)[0];
    return res.status(409).json({ error: `${field} already exists` });
  }
 
  // Mongoose cast error (invalid ObjectId)
  if (err.name === 'CastError') {
    return res.status(400).json({ error: `Invalid ${err.path}: ${err.value}` });
  }
 
  res.status(err.statusCode || 500).json({
    error: err.message || 'Internal server error',
  });
};
 
module.exports = errorHandler;

User Routes và Controller

Tạo src/controllers/userController.js:

const User = require('../models/User');
 
exports.createUser = async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json({ data: user.getPublicProfile() });
  } catch (error) {
    next(error);
  }
};
 
exports.getUsers = async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
 
    const [users, total] = await Promise.all([
      User.find()
        .select('-password')
        .sort({ createdAt: -1 })
        .skip((page - 1) * limit)
        .limit(limit),
      User.countDocuments(),
    ]);
 
    res.json({
      data: users,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit),
      },
    });
  } catch (error) {
    next(error);
  }
};
 
exports.getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id).select('-password');
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json({ data: user });
  } catch (error) {
    next(error);
  }
};
 
exports.updateUser = async (req, res, next) => {
  try {
    // Ngăn cập nhật password và role qua endpoint này
    const { password, role, ...updateData } = req.body;
 
    const user = await User.findByIdAndUpdate(req.params.id, updateData, {
      new: true,
      runValidators: true,
    }).select('-password');
 
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json({ data: user });
  } catch (error) {
    next(error);
  }
};
 
exports.deleteUser = async (req, res, next) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

Tạo src/routes/users.js:

const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
 
router.route('/')
  .get(userController.getUsers)
  .post(userController.createUser);
 
router.route('/:id')
  .get(userController.getUser)
  .patch(userController.updateUser)
  .delete(userController.deleteUser);
 
module.exports = router;

Post Routes và Controller

Tạo src/controllers/postController.js:

const Post = require('../models/Post');
 
exports.createPost = async (req, res, next) => {
  try {
    const post = await Post.create(req.body);
    await post.populate('author', 'username email avatar');
    res.status(201).json({ data: post });
  } catch (error) {
    next(error);
  }
};
 
exports.getPosts = async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
 
    // Build filter
    const filter = {};
    if (req.query.status) filter.status = req.query.status;
    if (req.query.author) filter.author = req.query.author;
    if (req.query.tag) filter.tags = req.query.tag;
    if (req.query.search) {
      filter.$or = [
        { title: { $regex: req.query.search, $options: 'i' } },
        { content: { $regex: req.query.search, $options: 'i' } },
      ];
    }
 
    // Build sort
    let sort = { createdAt: -1 };
    if (req.query.sort === 'views') sort = { views: -1 };
    if (req.query.sort === 'oldest') sort = { createdAt: 1 };
    if (req.query.sort === 'title') sort = { title: 1 };
 
    const [posts, total] = await Promise.all([
      Post.find(filter)
        .populate('author', 'username email avatar')
        .sort(sort)
        .skip((page - 1) * limit)
        .limit(limit),
      Post.countDocuments(filter),
    ]);
 
    res.json({
      data: posts,
      pagination: { page, limit, total, pages: Math.ceil(total / limit) },
    });
  } catch (error) {
    next(error);
  }
};
 
exports.getPost = async (req, res, next) => {
  try {
    const post = await Post.findOne({ slug: req.params.slug })
      .populate('author', 'username email avatar')
      .populate({
        path: 'comments',
        populate: { path: 'author', select: 'username avatar' },
        options: { sort: { createdAt: -1 } },
      });
 
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
 
    // Tăng views
    post.views += 1;
    await post.save({ validateBeforeSave: false });
 
    res.json({ data: post });
  } catch (error) {
    next(error);
  }
};
 
exports.updatePost = async (req, res, next) => {
  try {
    const post = await Post.findByIdAndUpdate(req.params.id, req.body, {
      new: true,
      runValidators: true,
    }).populate('author', 'username email avatar');
 
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
    res.json({ data: post });
  } catch (error) {
    next(error);
  }
};
 
exports.deletePost = async (req, res, next) => {
  try {
    const post = await Post.findByIdAndDelete(req.params.id);
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
 
    // Xóa luôn tất cả comments của post này
    const Comment = require('../models/Comment');
    await Comment.deleteMany({ post: req.params.id });
 
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

Tạo src/routes/posts.js:

const express = require('express');
const router = express.Router();
const postController = require('../controllers/postController');
 
router.route('/')
  .get(postController.getPosts)
  .post(postController.createPost);
 
router.get('/:slug', postController.getPost);
 
router.route('/:id')
  .patch(postController.updatePost)
  .delete(postController.deletePost);
 
module.exports = router;

Comment Routes và Controller

Tạo src/controllers/commentController.js:

const Comment = require('../models/Comment');
 
exports.createComment = async (req, res, next) => {
  try {
    const comment = await Comment.create({
      ...req.body,
      post: req.params.postId,
    });
    await comment.populate('author', 'username avatar');
    res.status(201).json({ data: comment });
  } catch (error) {
    next(error);
  }
};
 
exports.getComments = async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 20;
 
    const filter = { post: req.params.postId };
 
    const [comments, total] = await Promise.all([
      Comment.find(filter)
        .populate('author', 'username avatar')
        .sort({ createdAt: -1 })
        .skip((page - 1) * limit)
        .limit(limit),
      Comment.countDocuments(filter),
    ]);
 
    res.json({
      data: comments,
      pagination: { page, limit, total, pages: Math.ceil(total / limit) },
    });
  } catch (error) {
    next(error);
  }
};
 
exports.deleteComment = async (req, res, next) => {
  try {
    const comment = await Comment.findByIdAndDelete(req.params.id);
    if (!comment) {
      return res.status(404).json({ error: 'Comment not found' });
    }
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

Tạo src/routes/comments.js:

const express = require('express');
const router = express.Router({ mergeParams: true }); // Truy cập postId từ parent
const commentController = require('../controllers/commentController');
 
router.route('/')
  .get(commentController.getComments)
  .post(commentController.createComment);
 
router.delete('/:id', commentController.deleteComment);
 
module.exports = router;

Kết Nối Tất Cả Trong app.js

Cập nhật src/app.js:

require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const connectDB = require('./config/database');
const errorHandler = require('./middleware/errorHandler');
 
const app = express();
 
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
 
// Kết nối MongoDB
connectDB();
 
// Health check
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    db: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected',
  });
});
 
// Routes
app.use('/api/users', require('./routes/users'));
app.use('/api/posts', require('./routes/posts'));
app.use('/api/posts/:postId/comments', require('./routes/comments'));
 
// Error handler (phải đặt cuối cùng)
app.use(errorHandler);
 
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Test API

# Start server
npm run dev
 
# Tạo user
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"username": "chanh", "email": "[email protected]", "password": "password123"}'
 
# Tạo post (dùng user _id từ trên)
curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Getting Started with Express",
    "content": "Express.js is a minimal and flexible Node.js web framework...",
    "author": "USER_ID_HERE",
    "tags": ["express", "nodejs"],
    "status": "published"
  }'
 
# Lấy tất cả published posts
curl "http://localhost:3000/api/posts?status=published&page=1&limit=5"
 
# Lấy một post theo slug
curl http://localhost:3000/api/posts/getting-started-with-express
 
# Thêm comment
curl -X POST http://localhost:3000/api/posts/POST_ID_HERE/comments \
  -H "Content-Type: application/json" \
  -d '{"content": "Great article!", "author": "USER_ID_HERE"}'
 
# Tìm kiếm posts
curl "http://localhost:3000/api/posts?search=express"
 
# Cập nhật post
curl -X PATCH http://localhost:3000/api/posts/POST_ID_HERE \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated Title"}'
 
# Xóa post (cũng xóa comments của nó)
curl -X DELETE http://localhost:3000/api/posts/POST_ID_HERE

Bài Tập

Bài Tập 1: Thêm Likes Cho Posts

Thêm feature likes vào Post model:

  • Track users nào đã like post (array of user IDs)
  • Thêm POST /api/posts/:id/like và DELETE /api/posts/:id/like endpoints
  • Ngăn duplicate likes (dùng $addToSet)
  • Thêm virtual cho likeCount

Bài Tập 2: User Posts Aggregation

Tạo endpoint GET /api/users/:id/stats trả về:

  • Tổng số posts
  • Tổng views trên tất cả posts
  • Số published vs draft posts
  • Post phổ biến nhất (theo views)

Dùng Mongoose aggregate() pipeline.

Implement search trên posts:

  • Tạo text index trên title và content
  • Thêm search scoring để sort theo relevance
  • Trả về highlighted snippets (200 ký tự đầu của matching content)
  • Hỗ trợ filtering theo tags cùng lúc với search

Tiếp Theo

Trong bài tiếp, chúng ta sẽ làm việc với PostgreSQL và Prisma — cách tiếp cận hoàn toàn khác cho database integration:

  • Setup PostgreSQL với Express
  • Prisma ORM: schema definition và migrations
  • Type-safe queries với Prisma Client
  • So sánh Mongoose (MongoDB) vs Prisma (PostgreSQL)
  • Khi nào chọn SQL vs NoSQL

Mọi thứ bạn học ở đây — schemas, validation, relationships — đều áp dụng cho SQL, chỉ khác syntax.


Tóm Tắt và Điểm Chính

✅ MongoDB lưu trữ flexible JSON-like documents — tuyệt vời cho development nhanh và schemas thay đổi
✅ Mongoose thêm structure cho MongoDB với schemas, validation và middleware hooks
✅ Luôn dùng timestamps: true trong schemas cho createdAt và updatedAt tự động
✅ Dùng select: false cho sensitive fields (như passwords) để loại khỏi queries mặc định
✅ Pre-save middleware hoàn hảo cho hashing passwords và tạo slugs
✅ Population thay thế ObjectId references bằng full documents — dùng cho relationships
✅ Embed data luôn truy cập cùng nhau; reference data truy cập độc lập
✅ Luôn thêm indexes cho fields bạn thường query, sort hoặc filter
✅ Dùng runValidators: true trong update operations — Mongoose bỏ qua validation khi updates mặc định
✅ Handle Mongoose-specific errors (ValidationError, CastError, duplicate key) trong error middleware


Series: Express.js Learning Roadmap
Trước đó: Routing and Request Handling
Tiếp theo: Working with PostgreSQL and Prisma (Sắp ra mắt)


Có câu hỏi về MongoDB hoặc Mongoose? Hãy liên hệ hoặc để lại comment!

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.