
Ngừng Trả DB Model Ra API: Hãy Sử Dụng DTO
Việc trả thẳng model database ra API dễ dẫn đến rò rỉ dữ liệu và lỗi vỡ giao diện. Tìm hiểu cách Data Transfer Object (DTO) giải quyết vấn đề này.
Mọi chuyện thường bắt đầu rất đơn giản. Bạn đang viết một endpoint REST API mới để lấy thông tin profile người dùng. Bạn viết một câu query móc dữ liệu từ database lên, và sau đó trả thẳng record đó về cho client.
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
});
Bùm. Xong việc. Frontend nhận được data, task được kéo sang cột "Done", và bạn đi pha một ly cà phê. Trong những năm đầu đi làm, tôi đã viết chính xác đoạn code này không biết bao nhiêu lần. Cảm giác code rất nhanh và nhàn.
Nhưng tua nhanh đến vài tháng sau. Đội bảo mật quét hệ thống và cảnh báo một lỗ hổng rò rỉ dữ liệu nghiêm trọng. Hoặc team frontend than phiền rằng API response tự nhiên bị đổi field name khiến app crash. Chuyện gì đã xảy ra?
Bằng việc ném thẳng model của database ra ngoài, bạn đã trói buộc chặt chẽ (tightly coupled) cấu trúc database với API contract. Tệ hơn nữa, bạn rất có thể đang làm rò rỉ dữ liệu nhạy cảm.
Đã đến lúc ngừng việc phơi bày các model database nội bộ ra ngoài và bắt đầu sử dụng Data Transfer Object (DTO).
Hiểm Hoạ Của Việc Trả Về Database Model
Khi bạn ném một entity nguyên bản từ database vào HTTP response, bạn đang mời gọi hai thảm họa lớn đến với hệ thống của mình:
1. Rò Rỉ Dữ Liệu Ngoài Ý Muốn (Rủi Ro Bảo Mật)
Các database model thường chứa những trường (field) mà client tuyệt đối không được phép thấy. Hãy nghĩ tới password_hash, reset_token, internal_notes, hay is_banned. Nếu bạn chỉ đơn giản return user, toàn bộ đống field đó sẽ bị serialize thành JSON và gửi tuốt luốt qua mạng.
Ngay cả khi bạn nhớ viết delete user.password_hash trước khi trả về (một cách làm phổ biến nhưng rất dễ lỗi), bạn vẫn luôn ở trạng thái "quên một field là toang". Vài tháng sau, một dev khác thêm cột salary vào bảng users. Cột này sẽ tự động bị rò rỉ qua endpoint cũ trừ khi người dev đó nhớ vào update lại đoạn code xóa field.
2. Tight Coupling (Ác Mộng Bảo Trì)
Database schema và API payload phục vụ hai mục đích hoàn toàn khác nhau. Database được tối ưu cho việc lưu trữ, đánh index và chuẩn hóa (normalization). Còn API được tối ưu để các ứng dụng client dễ dàng tiêu thụ (consume).
Nếu API trả về database model, bất kỳ lần refactor nào dưới database cũng lập tức biến thành một breaking change đập vỡ frontend. Việc bạn đổi tên cột từ first_name sang firstName trong PostgreSQL không nên là lý do khiến app mobile bị sập, nhưng nếu không có một lớp phiên dịch đứng giữa, chuyện đó chắc chắn sẽ xảy ra.
Bức Tường Lửa: Data Transfer Object (DTO)

Data Transfer Object (DTO) đúng như tên gọi của nó: một object đơn giản chuyên dùng để đóng gói dữ liệu và vận chuyển nó giữa các thành phần của hệ thống. Nó không chứa business logic hay connection tới DB — nó chỉ chứa dữ liệu thuần.
Hãy coi DTO như một bức tường lửa nghiêm ngặt nằm giữa trạng thái nội bộ của ứng dụng và thế giới bên ngoài.
Thay vì trả ra entity của database, bạn sẽ map (ánh xạ) nó sang một cấu trúc DTO cụ thể trước khi nó rời khỏi server. Hãy sửa lại ví dụ ban đầu:
// 1. Định nghĩa API contract (DTO)
interface UserProfileDto {
id: string;
username: string;
fullName: string;
joinedAt: string;
}
// 2. Viết hàm mapping thuần (pure function)
function toUserProfileDto(user: UserEntity): UserProfileDto {
return {
id: user.id,
username: user.username,
fullName: `${user.first_name} ${user.last_name}`,
joinedAt: user.created_at.toISOString(),
};
}
// 3. Áp dụng vào route
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(toUserProfileDto(user));
});
Hãy nhìn xem chúng ta đạt được gì:
- Kiểm soát hoàn toàn: Chúng ta định nghĩa rõ ràng từng byte dữ liệu đi ra ngoài. Nếu ai đó thêm
credit_card_stripe_idvàoUserEntity, nó sẽ không bao giờ tự động chui ra ngoài API response. - Decoupling (Giảm phụ thuộc): Frontend chỉ kỳ vọng nhận được
fullName. Kể cả khi database đổi cấu trúc từ 2 cộtfirst_name/last_namethành một cộtnameduy nhất, ta chỉ việc sửa lại hàmtoUserProfileDto. Contract của API vẫn giữ nguyên không suy suyển.
Inbound DTO: Canh Gác Cửa Vào
DTO không chỉ dùng cho đầu ra (outbound response); nó thậm chí còn quan trọng hơn ở đầu vào (inbound request).
Thử lấy ví dụ một endpoint tạo user. Nếu bạn lấy thẳng req.body và ném vào hàm insert của ORM, một user ác ý có thể gửi payload {"username": "hacker", "isAdmin": true}. Nếu DB của bạn có cột isAdmin với giá trị mặc định là false, ORM rất có thể sẽ hồn nhiên ghi đè nó thành true vì bạn đã cho phép truyền thẳng dữ liệu.
Bằng cách dùng Inbound DTO (ví dụ CreateUserDto), bạn chủ động "nhặt" đúng những field mình cần:
function toCreateUserPayload(body: any) {
// Chỉ lấy đúng những field ta cho phép
return {
username: String(body.username),
email: String(body.email),
password: String(body.password),
};
}
Lưu ý: Trong các app Node.js hiện đại, bạn nên kết hợp Inbound DTO với các thư viện validation như Zod hoặc Joi để vừa đảm bảo type safety vừa validate rule cùng lúc.
Lời Than Phiền Về "Boilerplate"
Phản ứng phổ biến nhất khi áp dụng DTO là: "Trời ơi, viết boilerplate nhiều quá! Tự nhiên phải khai báo property 2 lần rồi còn phải viết hàm map qua lại."
Đúng, bạn phải viết thêm code. Nhưng sự cồng kềnh đó chính là một tính năng, không phải lỗi.
Việc phải viết những dòng code mapping đó ép bạn phải đưa ra các quyết định có chủ đích về ranh giới dữ liệu. Với các dự án lớn (như C# hay Java), bạn có thể dùng các tool tự động (như AutoMapper). Nhưng với phần lớn các dự án TypeScript hay Python, việc viết các hàm mapping tường minh (explicit) lại rất dễ viết, dễ test và dễ đọc.
Tổng Kết
Schema database là chuyện nội bộ (private) của backend. Còn API là một bản hợp đồng công khai (public contract). Đừng bao giờ làm mờ ranh giới giữa hai thứ này.
Chỉ bằng cách bỏ thêm vài phút để định nghĩa Data Transfer Object và viết hàm mapping, bạn sẽ xây dựng được những API bảo mật, bền vững và có thể thoải mái tiến hóa dưới DB mà không làm vỡ app của client. Đừng ném thẳng raw record vào mặt người dùng nữa, hãy thiết kế các contract có chủ đích.
viết bởi
Nguyên Tech
Phản hồi
Đang tải bình luận…