Từ đống Code Spaghetti đến kiến trúc bài bản
Hồi mới chân ướt chân ráo viết Node.js, mình cứ quen tay là “tống” hết logic vào Controller. Kết quả là những hàm createOrder phình to tới 300-400 dòng, ôm đồm đủ thứ từ validate, tính thuế đến trừ kho. Mỗi lần khách đòi sửa nhẹ logic khuyến mãi là cả team lại “nín thở” vì sợ hỏng những phần không liên quan.
Mình từng tham gia refactor một hệ thống vận hành hơn 50.000 dòng code. Bài học đắt giá nhất mình rút ra là: nếu cứ viết theo kiểu “Transaction Script” (đổ code từ trên xuống dưới), hệ thống sẽ sớm sụp đổ dưới sức nặng của chính nó. Đó là lúc mình bắt đầu nghiêm túc tìm hiểu về Domain-Driven Design (DDD).
So sánh thực tế các cách tiếp cận
Chúng ta thường phân vân giữa hai hướng đi chính khi bắt đầu một dự án:
- Transaction Script (Lối tư duy mì ăn liền): Bạn viết logic tuần tự ngay trong Controller hoặc Service. Cách này cực nhanh cho các dự án nhỏ, nhưng lại là thảm họa khi scale vì logic bị xé lẻ và lặp lại ở khắp nơi.
- Domain-Driven Design (DDD): Mọi thứ xoay quanh “Domain” (nghiệp vụ cốt lõi). Code phải phản ánh chính xác cách doanh nghiệp vận hành. Dù tốn thêm khoảng 20-30% thời gian thiết lập ban đầu, nhưng sự ổn định về lâu dài là vô giá.
Cái giá và phần thưởng khi dùng DDD
Ưu điểm:
- Khoanh vùng rủi ro: Logic nghiệp vụ nằm gọn một chỗ, tách biệt hoàn toàn với database hay UI.
- Tiếng nói chung: Developer và Product Manager dùng chung một ngôn ngữ (Ubiquitous Language), giảm thiểu hiểu lầm khi chốt yêu cầu.
- Sẵn sàng cho Microservices: Các Bounded Context được phân tách rõ ràng giúp việc xé nhỏ hệ thống sau này chỉ mất vài ngày thay vì vài tháng.
Nhược điểm:
- Rào cản gia nhập cao: DDD không dành cho người thiếu kiên nhẫn.
- Code mồi (Boilerplate): Bạn sẽ thấy mình phải tạo nhiều class và interface hơn bình thường.
Triển khai DDD thực chiến với TypeScript
Để triển khai DDD hiệu quả, chúng ta cần phân ranh giới rõ ràng cho từng thành phần.
1. Thiết lập Bounded Context
Thay vì cố xây dựng một Database Schema “khổng lồ” cho cả hệ thống, hãy chia nhỏ nó ra. Trong một sàn TMĐT, thực thể Product ở góc nhìn của team Bán hàng (giá cả, mô tả) sẽ rất khác với Product của team Kho vận (kích thước, khối lượng, vị trí kệ hàng).
2. Value Object – Sức mạnh từ sự bất biến
Value Object (VO) là những đối tượng không cần ID, được xác định bằng chính giá trị của chúng. Ví dụ: Email, Số điện thoại hoặc Số tiền.
// value-objects/email.vo.ts
export class Email {
private readonly value: string;
constructor(email: string) {
if (!email.includes('@')) {
throw new Error('Email không đúng định dạng');
}
this.value = email;
}
getValue(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.getValue();
}
}
Việc dùng VO giúp bạn chặn đứng dữ liệu rác ngay từ “vòng gửi xe”, giảm tải 40% logic validation ở tầng service.
3. Entity – Thực thể có danh tính
Khác với VO, Entity sinh ra để được định danh bằng ID duy nhất. Ngay cả khi mọi thông tin giống hệt nhau, hai người dùng có ID khác nhau vẫn là hai thực thể tách biệt.
// entities/user.entity.ts
import { Email } from '../value-objects/email.vo';
export class User {
constructor(
public readonly id: string,
private email: Email,
private name: string
) {}
changeName(newName: string): void {
if (newName.trim().length < 2) throw new Error('Tên không hợp lệ');
this.name = newName;
}
}
4. Repository Pattern – Đừng để Database làm phiền Logic
Trong DDD, tầng Domain không được phép quan tâm bạn dùng MySQL hay MongoDB. Repository đóng vai trò như một bộ sưu tập các Entity nằm trong bộ nhớ.
// repositories/user.repository.interface.ts
export interface IUserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
Sau đó, bạn mới thực thi nó ở tầng Infrastructure bằng TypeORM hoặc Prisma. Cách làm này giúp việc thay đổi công nghệ lưu trữ trở nên cực kỳ linh hoạt.
Vài lời khuyên từ thực tế
Qua nhiều dự án áp dụng DDD, mình rút ra được vài điểm mấu chốt để anh em không bị “ngợp”:
- Biết mình biết ta: Nếu dự án chỉ là CRUD đơn giản cho app nội bộ, đừng dùng DDD. Nó chẳng khác nào dùng dao mổ trâu để giết gà.
- Domain đi trước, Database theo sau: Hãy vẽ sơ đồ thực thể và luồng nghiệp vụ trên giấy trước khi bắt đầu tạo table.
- Tận dụng Dependency Injection: Luôn để Interface ở tầng Domain và Inject Implementation từ ngoài vào. Điều này giúp bạn viết Unit Test cực nhanh mà không cần bật Database lên.
Khi mình áp dụng mô hình này cho một dự án quản lý vận tải, việc tách riêng thực thể Trip (Chuyến đi) với các quy tắc kiểm tra chồng chéo thời gian đã giúp team cắt giảm tới 80% bug logic so với phiên bản cũ.
DDD không phải là liều thuốc tiên cho mọi vấn đề, nhưng nó là công cụ mạnh nhất để trị những hệ thống Node.js có logic phức tạp. Hãy bắt đầu nhỏ bằng cách tách thử một vài Value Object, bạn sẽ thấy code của mình “sạch” và dễ thở hơn rất nhiều.

