Vấn đề thực tế: Khi chiếc “Monolith” trở thành mớ bòng bong
Anh em làm Node.js chắc không lạ gì cảnh này: Dự án lúc đầu chỉ có vài tính năng CRUD, chạy mượt vô cùng. Nhưng sau khoảng một năm, khi team tăng từ 2 lên 10 người và lượng tính năng phình to, codebase bắt đầu biến thành một “Big Ball of Mud”.
Tôi từng trực tiếp xử lý một dự án khoảng 50.000 dòng code với hơn 60 API endpoints. Ban đầu nó chỉ là app quản lý bán hàng đơn giản. Thế nhưng, chỉ sau vài tháng, logic của module Order đã đan xen chằng chịt với Inventory và Promotion. Một lỗi nhỏ khi sửa logic tính tiền có thể làm sập luôn tính năng gửi mail hoặc trừ kho. Team dev bắt đầu sợ chạm vào code của nhau. Những buổi merge code kéo dài 2-3 tiếng vì xung đột file diễn ra như cơm bữa.
Lúc bế tắc, phản xạ tự nhiên của anh em thường là: “Hay tách quách ra Microservices cho xong?”. Nhưng khoan hãy vội. Microservices không phải là chiếc đũa thần. Nếu chưa biết cách quy hoạch code, việc tách nhỏ chỉ khiến bạn chuyển từ một “đống rác tập trung” sang một “đống rác phân tán”. Bạn sẽ phải đối mặt thêm với gánh nặng hạ tầng, độ trễ mạng và bài toán dữ liệu phân tán cực kỳ nhức óc.
Tại sao code của chúng ta lại trở nên khó kiểm soát?
Sau khi ngồi lại mổ xẻ vấn đề, tôi nhận ra lỗi không nằm ở Monolith. Vấn đề thực sự là chúng ta đang thiếu các ranh giới (boundaries) rõ ràng giữa các tính năng.
- Liên kết quá chặt (Tight Coupling): Module A gọi trực tiếp hàm nội bộ của Module B, thậm chí “chọc” thẳng vào database table của module khác để lấy dữ liệu.
- Dùng chung Database vô tội vạ: Mọi module cùng query chung vào một bộ bảng. Chỉ cần bạn đổi tên một cột ở bảng User, module Báo cáo có thể lăn đùng ra chết ngay lập tức.
- Cấu trúc folder lỗi thời: Việc chia folder theo kiểu
controllers/,services/,models/khiến logic bị xé lẻ. Để tìm hiểu một tính năng, bạn phải nhảy qua lại giữa 4-5 folder khác nhau.
Ba hướng đi phổ biến khi hệ thống phình to
Thông thường, các team sẽ đứng trước ba lựa chọn chính:
- Cố gắng chịu đựng: Tiếp tục viết Unit Test đè lên. Tuy nhiên, với cấu trúc rối rắm, việc viết test trở thành cực hình vì phải mock quá nhiều thành phần liên quan.
- Nhảy thẳng lên Microservices: Chia mỗi module thành một repo và server riêng. Cách này tốn kém tài nguyên và đòi hỏi kỹ năng DevOps cực tốt để vận hành CI/CD, Kubernetes hay Service Mesh.
- Modular Monolith: Đây là giải pháp trung dung nhưng hiệu quả. Hệ thống vẫn chạy chung một tiến trình Node.js và một database, nhưng code được tách biệt hoàn toàn về mặt logic.
Cách triển khai Modular Monolith chuẩn trong Node.js
Trong kiến trúc này, mỗi module đóng vai trò như một đơn vị độc lập. Module A không cần biết Module B thực thi thế nào, nó chỉ tương tác qua một “cổng chào” được quy định sẵn.
1. Tổ chức thư mục theo Domain (Nghiệp vụ)
Thay vì chia theo tầng kỹ thuật, hãy gom nhóm theo chức năng. Mỗi folder module sẽ chứa trọn vẹn từ Route, Controller đến Repository của riêng nó.
src/
modules/
users/
index.js # Cổng giao tiếp duy nhất (Public API)
users.controller.js
users.service.js
users.model.js
orders/
index.js
orders.service.js
catalog/
...
shared/ # Tiện ích dùng chung (Logger, DB client)
app.js # Điểm khởi tạo server
2. Thiết lập ranh giới bằng “Public API”
Quy tắc vàng ở đây là: Chỉ truy cập module thông qua file index.js. Mọi file khác bên trong phải được coi là nội bộ (private).
Giả sử module orders cần thông tin khách hàng, nó tuyệt đối không được require('../users/users.service') trực tiếp. Thay vào đó, module users sẽ công khai các hàm cần thiết tại file index.
// src/modules/users/index.js
const userService = require('./users.service');
module.exports = {
getUserInfo: async (userId) => {
return await userService.getById(userId);
}
};
3. Giao tiếp giữa các module bằng Sự kiện (Events)
Để giảm thiểu phụ thuộc, hãy tận dụng EventEmitter của Node.js. Khi một đơn hàng được thanh toán thành công, module orders chỉ việc phát đi một tín hiệu.
// src/modules/orders/orders.service.js
const eventBus = require('../../../shared/event-bus');
async function completeOrder(orderId) {
const order = await db.orders.update(orderId, { status: 'paid' });
// Bắn event ra ngoài, không quan tâm ai sẽ xử lý
eventBus.emit('ORDER_PAID', { orderId: order.id, customerId: order.customerId });
}
Lúc này, module inventory sẽ tự lắng nghe để trừ kho, còn module email sẽ tự gửi hóa đơn. Module orders giờ đây hoàn toàn “điếc” về sự tồn tại của các module khác.
4. Quản lý Database: Ranh giới ảo
Dù dùng chung một database PostgreSQL hay MongoDB, hãy quy định mỗi bảng chỉ thuộc quyền sở hữu của một module duy nhất. Nếu Module A muốn lấy dữ liệu của Module B, nó phải gọi qua Service của Module B thay vì dùng lệnh JOIN trực tiếp trong SQL.
Kinh nghiệm xương máu khi refactor dự án 50K dòng code là bạn phải phủ kín Unit Test cho các hàm truy vấn trước khi tách bảng. Nếu không, việc tìm kiếm các câu query “đi đêm” ẩn sâu trong code sẽ khiến bạn kiệt sức.
Tại sao bạn nên áp dụng mô hình này ngay hôm nay?
- Kiểm thử dễ dàng: Bạn có thể viết test cho từng module mà không sợ ảnh hưởng bởi logic của các phần khác.
- Làm việc nhóm hiệu quả: Team A phụ trách Users, Team B lo Orders. Hai nhóm rất ít khi đụng chạm vào code của nhau, giúp tốc độ release nhanh hơn rõ rệt.
- Lộ trình lên Microservices rõ ràng: Khi module
ordersquá tải, bạn chỉ cần nhấc nguyên foldermodules/orderssang một repo mới. 90% logic đã được đóng gói sẵn, bạn chỉ việc thay đổi cách gọi hàm thành gọi qua HTTP hoặc gRPC.
Lời kết
Đừng chạy theo những từ khóa thời thượng như Microservices nếu hệ thống của bạn chưa thực sự cần đến sự phân tán phức tạp. Modular Monolith là lựa chọn khôn ngoan để giữ cho dự án ngăn nắp mà vẫn duy trì được tốc độ phát triển nhanh.
Hãy bắt đầu bằng việc gom nhóm các file theo chức năng và thiết lập ranh giới giao tiếp nghiêm túc. Chúc anh em xây dựng được những hệ thống Node.js vừa tinh gọn, vừa dễ mở rộng!

