Kiến trúc Hexagonal trong Node.js: Giữ cho Logic cốt lõi luôn sạch sẽ

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Sự chuyển dịch từ tư duy Phân tầng sang Hexagonal

Hầu hết các lập trình viên bắt đầu hành trình xây dựng ứng dụng Node.js với mô hình MVC tiêu chuẩn. Bạn có các routes, controllers, services và models xếp chồng lên nhau. Cách này hoạt động ổn với các dự án nhỏ cuối tuần. Tuy nhiên, khi mã nguồn của bạn phát triển lên hơn 10.000 dòng, bạn sẽ nhận thấy logic nghiệp vụ bắt đầu bị lẫn vào các truy vấn database. Đột nhiên, các service của bạn bị ràng buộc chặt chẽ với Express hoặc một phiên bản cụ thể của TypeORM.

Kiến trúc Hexagonal (Hexagonal Architecture), hay còn gọi là Ports and Adapters, đảo ngược cấu trúc này. Hãy coi lõi ứng dụng của bạn như một hệ thống âm thanh cao cấp. Loa (database) và đầu vào (Express, CLI, hoặc các tác vụ Cron) chỉ là các thành phần bạn cắm vào phía sau. Phần lõi không quan tâm ai là nhà sản xuất loa; nó chỉ gửi tín hiệu qua một jack cắm tiêu chuẩn. Áp dụng tư duy này giúp dự án của bạn không trở thành một cơn ác mộng di sản (legacy) khó bảo trì và không dám refactor.

So sánh Kiến trúc Phân tầng và Hexagonal

Trong Kiến trúc Phân tầng (Layered Architecture) tiêu chuẩn, luồng phụ thuộc rất cứng nhắc: Controller -> Service -> Repository -> Database. Nếu bạn đổi từ MongoDB sang PostgreSQL, bạn thường phải viết lại 30% lớp Service vì nó mong đợi một cấu trúc dữ liệu cụ thể. Logic nghiệp vụ về cơ bản là con tin của hạ tầng.

Kiến trúc Hexagonal phá vỡ chuỗi này. Phần Lõi (Domain) định nghĩa các “Ports”—là các interface đơn giản mô tả những gì ứng dụng cần làm. Phần Hạ tầng (Infrastructure/Adapters) sau đó sẽ triển khai các interface đó. Logic cốt lõi của bạn hoàn toàn tách biệt và không cần biết nó đang giao tiếp với một database thực, một file CSV hay một mock để testing.

Ưu và nhược điểm của cách tiếp cận Hexagonal

Kiến trúc luôn là một sự đánh đổi. Trước khi bạn refactor toàn bộ repository của mình, hãy cân nhắc xem lợi ích có xứng đáng với công sức bỏ ra cho trường hợp cụ thể của bạn hay không.

Lợi ích

  • Độc lập Framework: Bạn có thể chuyển từ Express sang Fastify chỉ trong một buổi chiều. Thậm chí bạn có thể chạy cùng một logic đó trong một AWS Lambda function mà không phải chạm vào một dòng mã nghiệp vụ nào.
  • Kiểm thử cực nhanh: Vì logic được cô lập, các unit test không cần đợi kết nối database hoặc khởi động server. Những test này thường chạy trong vài mili giây thay vì vài giây, tạo điều kiện thuận lợi để áp dụng Property-Based Testing nhằm phát hiện các trường hợp biên.
  • Chống phụ thuộc nhà cung cấp (Vendor Lock-in): Nếu nhà cung cấp dịch vụ email của bạn tăng giá gấp đôi, bạn chỉ cần thay đổi một file adapter. Phần còn lại của hệ thống vẫn giữ nguyên hoàn toàn.

Đánh đổi

  • Chi phí ban đầu: Bạn sẽ phải viết nhiều code hơn lúc bắt đầu. Một tính năng đơn giản trước đây chỉ cần hai file thì nay có thể cần năm hoặc sáu file.
  • Ánh xạ dữ liệu (Data Mapping): Bạn phải chuyển đổi các thực thể database thành các domain model. Điều này ngăn chặn schema của database rò rỉ vào logic nghiệp vụ nhưng lại tạo ra thêm một lớp code lặp lại (boilerplate).
  • Khối lượng kiến thức: Các thành viên mới trong nhóm có thể thấy sự phân tách các thành phần này gây bối rối nếu họ chỉ quen với mô hình MVC truyền thống.

Cấu trúc thư mục khuyến nghị

Một cấu trúc thư mục rõ ràng là cách tốt nhất để thực thi các ranh giới này trong Node.js. Đây là một bố cục đã chứng minh được hiệu quả trong các môi trường production:


src/
 ├── domain/           # Logic thuần túy (Entities, Value Objects, Domain Errors)
 ├── application/      # Use cases và định nghĩa Port (Interfaces)
 ├── infrastructure/   # Adapters (TypeORM, Axios, Express, NestJS)
 └── main.ts           # "Composition Root" nơi mọi thứ được kết nối với nhau

Hướng dẫn triển khai từng bước

Hãy cùng xây dựng tính năng Đăng ký người dùng. Chúng ta sẽ sử dụng TypeScript vì hệ thống interface của nó là công cụ hoàn hảo để định nghĩa các Ports và xây dựng logic Type-safe.

1. Domain Entity

Domain là trái tim của ứng dụng. Nó không nên có bất kỳ sự phụ thuộc nào vào các thư viện bên ngoài. Nếu bạn không thể chạy file này trong một script Node.js thuần túy, có lẽ nó đang bị ràng buộc quá chặt chẽ.


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

  // Các quy tắc nghiệp vụ nằm ở đây, không phải trong controller
  public hasValidEmail(): boolean {
    return this.email.includes('@') && this.email.length > 5;
  }
}

2. Định nghĩa Port (Interface)

Port nằm ở lớp application. Nó đóng vai trò như một bản hợp đồng. Nó nói với thế giới bên ngoài rằng: “Để làm việc với ứng dụng này, bạn phải cung cấp một cách để lưu và tìm kiếm người dùng.”


// src/application/ports/user-repository.port.ts
import { User } from '../../domain/user';

export interface UserRepository {
  save(user: User): Promise<void>;
  findByEmail(email: string): Promise<User | null>;
}

3. Application Use Case

Use case điều phối logic. Lưu ý rằng nó chỉ biết về interface UserRepository. Nó không quan tâm dữ liệu sẽ được lưu vào MongoDB hay một file JSON.


// src/application/use-cases/register-user.ts
import { User } from '../../domain/user';
import { UserRepository } from '../ports/user-repository.port';

export class RegisterUser {
  constructor(private userRepository: UserRepository) {}

  async execute(email: string, passwordHash: string): Promise<void> {
    const existingUser = await this.userRepository.findByEmail(email);
    
    if (existingUser) {
      throw new Error('Email này đã được đăng ký');
    }

    const user = new User(crypto.randomUUID(), email, passwordHash);
    await this.userRepository.save(user);
  }
}

4. Infrastructure Adapter

Đây là nơi thực hiện các tác vụ nặng. Chúng ta triển khai logic database thực tế bằng các công cụ ưa thích.


// src/infrastructure/adapters/repositories/mongo-user-repository.ts
import { UserRepository } from '../../../application/ports/user-repository.port';
import { User } from '../../../domain/user';

export class MongoUserRepository implements UserRepository {
  async save(user: User): Promise<void> {
    // Giả sử logic Mongoose ở đây
    console.log(`Đang lưu ${user.email} vào MongoDB collection...`);
  }

  async findByEmail(email: string): Promise<User | null> {
    // Logic tìm kiếm trong database
    return null;
  }
}

5. Kết nối mọi thứ

Bước cuối cùng diễn ra tại điểm khởi đầu của ứng dụng. Bạn “tiêm” (inject) adapter cụ thể vào use case.


// src/main.ts
import { RegisterUser } from './application/use-cases/register-user';
import { MongoUserRepository } from './infrastructure/adapters/repositories/mongo-user-repository';

// Tiêm phụ thuộc (Dependency Injection)
const userRepository = new MongoUserRepository();
const registerUserUseCase = new RegisterUser(userRepository);

// Bây giờ có thể được gọi bởi một route Express, CLI hoặc bộ kiểm thử
registerUserUseCase.execute('[email protected]', 'secure_hash_123')
  .then(() => console.log('Thành công!'))
  .catch(err => console.error('Thất bại:', err.message));

Bài học từ thực tế sản xuất

Chuyển sang kiến trúc này thường nảy sinh những thách thức cụ thể, tương tự như khi triển khai NestJS Event Sourcing và CQRS. Dưới đây là cách xử lý chúng.

Bẫy ánh xạ (The Mapping Trap)

Một sai lầm phổ biến là truyền trực tiếp các model database—như các object của Mongoose hoặc Sequelize—vào logic nghiệp vụ. Điều này tạo ra một sự phụ thuộc ngầm làm mất đi mục đích ban đầu. Hãy luôn ánh xạ kết quả database sang Domain Entities bên trong Adapter. Nó có vẻ giống như công việc dư thừa cho đến ngày bạn cần thay đổi schema database mà không làm hỏng các test case của mình.

Quản lý phụ thuộc

Mặc dù việc khởi tạo thủ công hoạt động tốt cho các ứng dụng nhỏ, nó sẽ trở nên tẻ nhạt khi bạn thêm nhiều service hơn. Đối với các dự án lớn hơn, hãy sử dụng một container Dependency Injection (DI) như InversifyJS hoặc Awilix. Các công cụ này tự động hóa quá trình kết nối, giúp việc hoán đổi các adapter cho các môi trường khác nhau trở nên dễ dàng hơn.

Tránh thiết kế quá mức (Over-Engineering)

Nếu bạn đang xây dựng một CRUD API đơn giản với ba endpoint và có khả năng sẽ bị xóa sau ba tháng, thì Kiến trúc Hexagonal là quá mức cần thiết. Hãy sử dụng mô hình này khi logic nghiệp vụ phức tạp hoặc khi bạn dự kiến dự án sẽ được duy trì bởi một đội ngũ trong nhiều năm. Mục tiêu là đạt đến trạng thái mà logic nghiệp vụ của bạn thuần khiết đến mức bạn có thể kiểm thử, di chuyển hoặc mở rộng nó mà không phải vật lộn với chính hạ tầng của mình.

Share: