Tách tầng: controller mỏng, business logic ở service, truy cập dữ liệu ở repository
Một ứng dụng backend điển hình có ba mối quan tâm khác nhau: nhận/trả HTTP (presentation), logic nghiệp vụ (service/domain), và truy cập dữ liệu (repository). Tách chúng thành các tầng nghĩa là mỗi tầng chỉ biết việc của mình và phụ thuộc một chiều xuống dưới. Lý do thực tế: khi logic nghiệp vụ trộn vào controller, nó dính chặt vào HTTP — không tái sử dụng được cho một job nền hay một CLI, không test được nếu không giả lập request/response, và một controller phình to thành nơi mọi thứ đổ về. "Fat controller" là phản mẫu phổ biến nhất ở backend.
Cơ chế hoạt động
Mỗi tầng một trách nhiệm, phụ thuộc một chiều: controller → service → repository.
// Controller: chỉ dịch HTTP <-> lời gọi nghiệp vụ. Mỏng.
async function placeOrderHandler(req, res, next) {
try {
const order = await orderService.placeOrder(req.user.id, req.body) // ủy quyền
res.status(201).json(toDto(order))
} catch (err) { next(err) } // để error middleware xử lý
}
// Service: logic nghiệp vụ thuần, không biết gì về HTTP
class OrderService {
async placeOrder(userId, input) {
this.validate(input)
const total = this.pricing.compute(input)
return this.repo.save({ userId, ...input, total }) // gọi xuống repository
}
}
// Repository: chỉ biết lưu/đọc dữ liệu, không chứa logic nghiệp vụ
class OrderRepository {
save(order) { return this.db.insert('orders', order) }
}
Controller không tính tiền, không chạm DB; service không đọc req/res; repository không biết quy tắc nghiệp vụ. Phụ thuộc đi xuống, không vòng lên.
Vấn đề gặp trong production
Failure mode: fat controller. Khi controller làm hết — validate, tính toán, truy vấn DB, định dạng response — logic nghiệp vụ bị nhốt trong tầng HTTP:
async function placeOrder(req, res) {
if (!req.body.items?.length) return res.status(400).json({ error: '...' })
let total = 0
for (const i of req.body.items) total += i.price * i.qty // nghiệp vụ kẹt trong controller
await db.query('insert into orders ...') // truy cập DB thẳng
// ...không tái dùng được, không test được nếu không giả lập req/res
}
Khi cần đặt đơn từ một cron job hay một message queue (không có HTTP), không gọi lại được — phải copy logic, và hai bản drift dần. Tách service ra cho phép mọi điểm vào (HTTP, job, CLI) dùng chung một logic.
Failure mode: logic rò vào sai tầng. Hai biến thể hay gặp: business logic chui xuống repository (repo chứa if user.isPremium), và truy cập DB chui lên controller. Cả hai phá tính một-chiều và làm tầng mất ý nghĩa. Quy tắc đặt code: quyết định nghiệp vụ ở service; câu lệnh đọc/ghi dữ liệu ở repository; chuyển đổi HTTP ở controller.
Failure mode: over-layering / abstraction thừa. Phản ứng quá đà là thêm tầng cho mọi thứ — một interface + một mapper + một DTO cho cả CRUD tầm thường nhất, khiến thêm một field phải sửa sáu file. Số tầng nên tương xứng độ phức tạp: CRUD đơn giản có thể controller gọi thẳng repository; tầng service đáng có khi có logic nghiệp vụ thật để chứa. Tách tầng để cô lập mối quan tâm có thật, không phải để đủ "kiến trúc đẹp".
Cách debug và monitor
Dấu hiệu fat controller: file handler dài, import cả driver DB lẫn thư viện nghiệp vụ, và test handler phải mock req/res cùng database. Dấu hiệu rò tầng: tìm câu lệnh SQL/ORM trong controller, hoặc if quyết định nghiệp vụ trong repository. Một kiểm tra nhanh: "logic này có gọi lại được từ một context không-HTTP không?" — nếu không, nó đang kẹt trong controller. Khi cùng một quy tắc nghiệp vụ xuất hiện ở cả HTTP handler lẫn job handler, đó là bằng chứng logic chưa được tách ra tầng dùng chung. Ngược lại, nếu thêm một field tầm thường phải đụng nhiều tầng/mapper, có thể đã over-layer.
Tradeoff
Tách tầng cho tái sử dụng (mọi điểm vào dùng chung logic), test được (service thuần test không cần HTTP/DB thật), và thay đổi khoanh vùng (đổi DB chỉ đụng repository, đổi API chỉ đụng controller). Cái giá là nhiều tầng, nhiều file, và việc đi qua các tầng cho một thao tác đơn giản. Quy tắc thực tế: giữ controller mỏng (chỉ dịch HTTP), đặt logic nghiệp vụ ở service, truy cập dữ liệu ở repository, phụ thuộc một chiều; nhưng cho số tầng tương xứng độ phức tạp — đừng dựng đủ ba tầng + mapper cho CRUD không có logic. Tầng tồn tại để cô lập mối quan tâm thật, không phải để trang trí.
Câu hỏi phỏng vấn
Controller nên chứa gì, và vì sao không nên để business logic trong controller?
Controller nên mỏng: chỉ dịch giữa HTTP và lời gọi nghiệp vụ — đọc input từ request, gọi service, ánh xạ kết quả/lỗi thành response (thường đẩy lỗi sang error middleware). Không nên để business logic trong controller vì nó làm logic dính chặt vào tầng HTTP: không tái sử dụng được từ context khác (cron job, message queue, CLI) nên buộc copy và hai bản drift; không test được nếu không giả lập req/res; và controller phình thành nơi mọi thứ đổ về. Tách ra: quyết định nghiệp vụ ở service (thuần, không biết HTTP), đọc/ghi dữ liệu ở repository, phụ thuộc một chiều controller→service→repository. Điểm ăn điểm: cảnh báo cả hai chiều rò tầng (SQL trong controller, if nghiệp vụ trong repository) lẫn over-layering (đủ tầng + mapper cho CRUD không có logic) — số tầng nên tương xứng độ phức tạp thật.
Hands-on
Lấy một fat controller thật (handler vừa validate, vừa tính toán, vừa query DB, vừa format) và tách thành ba tầng: controller mỏng chỉ dịch HTTP, service chứa logic nghiệp vụ thuần, repository chỉ đọc/ghi dữ liệu. Viết unit test cho service mà không mock req/res hay DB thật để thấy nó test được độc lập. Sau đó gọi lại chính service đó từ một điểm vào không-HTTP (một script/cron giả lập) để chứng minh logic tái sử dụng được mà không copy. Cố tình để một câu query rò lên controller và một if nghiệp vụ rò xuống repository, rồi sửa về đúng tầng; cuối cùng đối chiếu với một CRUD tầm thường để quyết định chỗ nào không cần tầng service.
Top comments (0)