1. Làm quen với DTO
DTO (Data Transfer Object, "đối tượng truyền dữ liệu") là một thuật ngữ xuất phát từ lập trình chuyên nghiệp, đặc biệt là trong thế giới ứng dụng phân tán và các service (ví dụ như khi một service gọi service khác qua mạng). Thực chất, nó chỉ là một object — đa phần là class C# bình thường — với nhiệm vụ duy nhất: chứa một tập dữ liệu và truyền an toàn giữa các tầng của chương trình hoặc giữa các ứng dụng.
Hãy tưởng tượng một cuộc trò chuyện giữa client và server. Mỗi lần user gửi tin nhắn, client sẽ tạo một object chứa text, thời gian và tên user, gửi cho server, còn server sẽ quyết định xử lý thế nào. Sẽ tuyệt hơn nếu object này không có gì thừa: chỉ những gì cần thiết để truyền dữ liệu. Đó chính là class DTO.
public class UserDto
{
public int Id { get; set; }
public string Name { get; set; }
}
Xong! Không có business logic, không có method phức tạp — chỉ có property thôi.
Class chỉ để truyền dữ liệu
Hãy tưởng tượng bạn làm dịch vụ giao pizza. Người giao hàng của bạn (DTO) không cần biết nấu pizza, nhận order hay sửa xe đạp. Việc duy nhất của họ là mang hộp pizza từ điểm A đến điểm B. Class DTO cũng vậy: nhiệm vụ là lưu trữ dữ liệu đơn giản. Không cần tự kiểm tra tính hợp lệ, không cần biết cách hiển thị trên UI — chỉ lưu trữ thôi.
- DTO — kiểu như "người không nghề" hay "object không logic", chỉ mang dữ liệu đi thôi.
- Dùng để không làm bẩn business logic với chi tiết truyền dữ liệu: vali càng nhỏ càng dễ mang.
2. Vấn đề của các class DTO thông thường trong thực tế
Nghe thì có vẻ ngon: khai báo class với auto-property, sống khỏe! Nhưng thực tế không đơn giản vậy đâu.
Tính có thể thay đổi: chuyện gì có thể xảy ra?
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; }
}
Giờ tưởng tượng ở đâu đó trong code, ai đó vô tình đổi property:
ProductDto dto = new ProductDto { Id = 1, Name = "Sữa" };
SomeApiProcess(dto);
dto.Name = "Sữa chua"; // Ôi trời!
Vì tất cả property đều có set public, rất dễ vô tình làm hỏng object hoặc tệ hơn, đổi ở một chỗ mà nó lại "lan" ra cả hệ thống. Điều này thành vấn đề lớn khi object được nhiều module và thread cùng dùng.
Copy và so sánh — công việc nhàm chán không hồi kết
Giả sử bạn muốn tạo bản copy của ProductDto, chỉ đổi mỗi tên.
var otherDto = new ProductDto { Id = dto.Id, Name = "Phô mai" };
Nếu property nhiều, việc này thành một quá trình copy thủ công vừa chán vừa dễ bug.
Còn nếu bạn cần so sánh object? Lại phải override Equals và GetHashCode, mà thường thì làm qua loa hoặc quên luôn.
Bất biến — không dành cho class "đơn giản"
Hầu hết trường hợp DTO nên là bất biến: nhận dữ liệu một lần — không đổi nữa. Nhưng class với auto-property thì không hợp: ai cũng có thể đổi bất kỳ field nào.
3. Thực tế trong dự án lớn sẽ ra sao?
Cùng xem một ví dụ thực tế nhé.
Bạn là dev ở một trang thương mại điện tử khổng lồ. Bạn cần truyền thông tin đơn hàng qua mạng. Bạn tạo class như này:
public class OrderDto
{
public int Id { get; set; }
public string Customer { get; set; }
public double Total { get; set; }
}
Mọi thứ ổn cho đến khi có vài trăm ngàn đơn mỗi tuần. Đống microservice bắt đầu chuyền DTO qua lại, ai đó quên mất Total phải tính chứ không phải nhập tay, ai đó vô tình đổi Customer sau khi gửi... Và thế là bug xuất hiện, rất khó bắt.
Làm sao cải thiện?
- Dùng chỉ get-property (không có set).
- Tạo constructor đặc biệt để set value chỉ khi tạo object.
- Override Equals và GetHashCode…
Bạn thấy vấn đề chưa? Thứ lẽ ra chỉ là "vali" đơn giản, bỗng hóa thành quái vật rắc rối.
4. Tóm tắt các vấn đề điển hình của class DTO
Dự án nhỏ thì bug do đổi DTO còn bắt được bằng tay. Dự án lớn — thảm họa luôn. DTO dùng cho xác thực, chuyển tiền, xử lý đơn hàng, — bất kỳ thay đổi dữ liệu trái phép nào cũng có thể thành mất tiền hoặc (chết thật!) dính án hình sự.
Dưới đây là "cheat sheet" các vấn đề mà gần như ai code DTO bằng class cũng gặp:
| Vấn đề | Mô tả |
|---|---|
| Tính có thể thay đổi | Dữ liệu dễ bị đổi ở bất cứ đâu — dễ sinh bug (đặc biệt khi đa luồng) |
| Clone | Copy object rất bất tiện, thường phải làm tay |
| So sánh | Mặc định so sánh theo reference, không phải theo value |
| Hỗ trợ with-copy | Muốn copy và đổi một phần dữ liệu mà không phải copy tay |
| Lồng nhau không rõ ràng | DTO lồng nhau phải copy hết, rất phiền |
5. Ví dụ: truyền dữ liệu giữa các tầng trong app của chúng ta
Giả sử bạn có app ghi chú, lưu task. Trước đây bạn sẽ có class như này:
public class TaskDto
{
public int Id { get; set; }
public string Description { get; set; }
public DateTime DueDate { get; set; }
}
Bạn đọc TaskDto từ file, thêm vào collection, rồi hiển thị lên màn hình. Mọi thứ ổn... cho đến khi ai đó ở module khác vô tình đổi DueDate ngay trước khi serialize — và user bị rối tung.
7. Có cách nào ngon hơn không?
Có chứ! Và không chỉ có mà còn nên dùng! Để giải quyết các vấn đề này, C# có một cấu trúc đặc biệt — record. Nó giải quyết gần hết nỗi đau với class DTO, gần như là "phép thuật".
record làm được gì:
- Mặc định so sánh theo value: so sánh object theo nội dung, không phải reference.
- Bất biến: chỉ có get-property, value set qua constructor.
- Clone dễ dàng: có cú pháp with để copy và đổi một phần.
- Tự động sinh method so sánh và in ra.
public record TaskDto(int Id, string Description, DateTime DueDate);
Ngon chưa? Quá ngon! Nhưng phần này — để dành cho bài sau nhé.
GO TO FULL VERSION