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:
| Feature | SQL (PostgreSQL) | NoSQL (MongoDB) |
|---|---|---|
| Data model | Tables với rows | Collections với documents |
| Schema | Cố định, định nghĩa trước | Linh hoạt (schema-less mặc định) |
| Relationships | JOINs | Embedding hoặc references |
| Query language | SQL | MongoDB Query Language |
| Scaling | Vertical (scale up) | Horizontal (scale out) |
| Phù hợp cho | Relationships phức tạp, transactions | Schemas linh hoạt, iteration nhanh |
Cài Đặt MongoDB Locally
macOS:
brew tap mongodb/brew
brew install mongodb-community
brew services start mongodb-communityLinux (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 mongodDocker (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:7Kiểm tra kết nối:
mongosh
# Hoặc với Docker:
docker exec -it mongodb mongosh -u admin -p passwordMongoDB Atlas (Cloud)
Cho production hoặc nếu không muốn cài locally:
- Vào mongodb.com/atlas và tạo tài khoản miễn phí
- Tạo free cluster (M0 tier — miễn phí mãi mãi)
- Setup database user và whitelist IP
- 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 nodemonTạo file .env:
PORT=3000
MONGODB_URI=mongodb://localhost:27017/blog-api
# Cho Atlas: mongodb+srv://user:[email protected]/blog-apiKế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:
| Type | JavaScript | Ví dụ |
|---|---|---|
String | String | "hello" |
Number | Number | 42, 3.14 |
Boolean | Boolean | true, false |
Date | Date | new Date() |
Buffer | Buffer | Binary data |
ObjectId | ObjectId | References đến documents khác |
Array | Array | ["a", "b"] |
Map | Map | Key-value pairs |
Schema.Types.Mixed | Object | Bấ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 nhau | Data truy cập độc lập |
| One-to-few relationship (< 100) | One-to-many hoặc many-to-many |
| Data ít thay đổi | Data thay đổi thường xuyên |
| Ví dụ: address trong user | Ví 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.jsError 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_HEREBà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/likevàDELETE /api/posts/:id/likeendpoints - 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.
Bài Tập 3: Full-Text Search
Implement search trên posts:
- Tạo text index trên
titlevà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.