Clean Architecture trong Node.js: Bí kíp thoát cảnh ‘Spaghetti Code’ khi dự án phình to

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Nỗi ám ảnh mang tên “Spaghetti Code”

Bạn đã bao giờ mở một file Controller và thấy 1000 dòng code hỗn độn chưa? Lúc mới khởi động, code nhồi nhét vào Controller giúp tính năng ra lò rất nhanh. Tuy nhiên, khi dự án cán mốc 50.000 dòng code, cơn ác mộng bắt đầu. Chỉ cần sửa một logic nhỏ ở database, bạn có thể làm hỏng luôn phần gửi mail hoặc tính toán hóa đơn.

Tôi từng mất trắng một tuần chỉ để refactor lại hệ thống vì logic nghiệp vụ (business logic) bị trộn lẫn quá sâu với framework Express. Bài học xương máu ở đây là: Cái gì dễ thay đổi thì phải cô lập nó. Database có thể đổi từ MongoDB sang PostgreSQL, framework có thể chuyển từ Express sang Fastify. Thế nhưng, quy tắc tính chiết khấu cho khách hàng VIP của bạn thì nên được bảo vệ độc lập.

Clean Architecture: Đừng coi đó là lý thuyết suông

Nhiều người e ngại Clean Architecture vì trông nó có vẻ rắc rối. Thực chất, quy tắc vàng của Uncle Bob chỉ gói gọn trong một câu: Sự phụ thuộc chỉ được hướng vào bên trong. Các layer lõi không được phép biết bất cứ điều gì về thế giới bên ngoài như database hay UI.

Trong dự án Node.js thực tế, tôi thường chia thành 4 lớp bảo vệ:

  • Domain (Entities): Nơi chứa các quy tắc nghiệp vụ cơ bản nhất. Đây là tầng “bất biến” trước mọi thay đổi công nghệ.
  • Use Cases: Chứa các kịch bản sử dụng cụ thể. Ví dụ: Quy trình đăng ký tài khoản gồm kiểm tra email, mã hóa password và lưu trữ.
  • Interface Adapters: Cầu nối chuyển đổi dữ liệu. Tại đây, Controller sẽ nhận request từ user và biến nó thành định dạng mà Use Case hiểu được.
  • Infrastructure: Tầng chứa các công cụ bên ngoài. Đây là nơi Express, TypeORM, Redis hay AWS SDK trú ngụ.

Cấu trúc thư mục giúp bạn tìm file trong 3 giây

Thay vì sắp xếp theo loại file (controllers, models), hãy sắp xếp theo chức năng và layer. Cách tổ chức này giúp bạn định vị logic cực nhanh mà không cần dùng đến phím tắt Ctrl+F liên tục:

src/
 ├── domain/           # Chứa Entity và logic lõi (Ví dụ: User, Order)
 ├── use-cases/        # Các nghiệp vụ cụ thể (Ví dụ: RegisterUser, Checkout)
 ├── interfaces/       # Controllers, DTOs và định nghĩa Repository
 ├── infrastructure/   # Hiện thực hóa database, mail service, logger
 └── main.ts           # Điểm khởi đầu để kết nối các mảnh ghép

Thực hành: Xây dựng tính năng Đăng ký người dùng

Hãy xem cách chúng ta tách nhỏ một API để đạt được sự linh hoạt tối đa.

1. Domain Layer: Trái tim của hệ thống

Entity này hoàn toàn thuần khiết. Nó không phụ thuộc vào bất kỳ thư viện ORM nào như Mongoose hay Sequelize.

// src/domain/entities/User.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly password: string
  ) {}

  // Logic kiểm tra email không cần thư viện bên ngoài
  public static isValidEmail(email: string): boolean {
    return /\S+@\S+\.\S+/.test(email);
  }
}

2. Use Case Layer: Điều phối nghiệp vụ

Ở tầng này, chúng ta định nghĩa Interface cho Repository. Use Case chỉ gọi interface, nó không quan tâm bạn đang lưu dữ liệu vào MySQL hay một file Excel.

// src/use-cases/register-user.use-case.ts
import { User } from "../domain/entities/User";
import { IUserRepository } from "./user-repository.interface";

export class RegisterUserUseCase {
  constructor(private userRepository: IUserRepository) {}

  async execute(data: any) {
    if (!User.isValidEmail(data.email)) throw new Error("Email sai định dạng");

    const exists = await this.userRepository.findByEmail(data.email);
    if (exists) throw new Error("Email đã được sử dụng");

    const user = new User(Date.now().toString(), data.email, data.password);
    return await this.userRepository.save(user);
  }
}

3. Infrastructure Layer: Kết nối Database thực tế

Nếu một ngày sếp yêu cầu đổi từ MongoDB sang PostgreSQL? Bạn chỉ cần tạo một file Repository mới ở đây. Logic ở Use Case phía trên hoàn toàn không phải sửa một dòng code nào.

// src/infrastructure/repositories/mongo-user.repository.ts
export class MongoUserRepository implements IUserRepository {
  async findByEmail(email: string) {
    // Gọi tới Mongoose Model tại đây
    return null; 
  }

  async save(user: User) {
    console.log("Đã lưu vào MongoDB thành công");
    return user;
  }
}

Kinh nghiệm thực chiến: Khi nào nên “phá rào”?

Áp dụng Clean Architecture một cách máy móc có thể dẫn đến tình trạng Over-engineering. Với những dự án MVP (Minimum Viable Product) chỉ có 3-5 API, việc chia quá nhiều layer sẽ làm chậm tốc độ phát triển. Đôi khi cấu hình Controller-Service truyền thống lại hiệu quả hơn.

Tuy nhiên, nếu xác định dự án sẽ chạy lâu dài, hãy chú trọng vào Unit Test. Điểm mạnh nhất của kiến trúc này là test cực dễ. Bạn có thể mock hoàn toàn Repository để test logic của Use Case mà không cần bật Database. Tôi luôn khuyến khích team đạt mức 80% code coverage cho layer Use Cases để đảm bảo an toàn khi refactor.

Một mẹo nhỏ: Hãy thử viết Use Case trước khi cài đặt Express. Nếu bạn có thể chạy được logic nghiệp vụ thông qua test script mà chưa cần đến web server, bạn đã thành công.

Kết luận

Clean Architecture không phải là một chiếc đũa thần, nó là một sự đầu tư. Ban đầu bạn sẽ thấy tốn thời gian tạo nhiều file và interface. Nhưng tin tôi đi, sau một năm khi cần thay đổi công nghệ hoặc thêm tính năng phức tạp, bạn sẽ thấy biết ơn vì đã chia layer rõ ràng ngay từ ngày đầu. Hãy thử áp dụng cho module tiếp theo trong dự án của bạn nhé!

Share: