Modular Monolith với NestJS: Kiến trúc Scalable mà không tốn “chi phí” Microservice

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

Buổi “khám nghiệm tử thi” Microservices lúc 2 giờ sáng

Đó là lúc 2 giờ sáng ngày thứ Ba khi điện thoại của tôi rung lên liên hồi. PagerDuty báo cáo một đợt tăng vọt lỗi 500 trong luồng thanh toán (checkout), khiến toàn bộ doanh thu bị đình trệ. Sau bốn mươi phút điên cuồng lục lọi các bản log phân tán trên sáu repository khác nhau, tôi đã tìm ra thủ phạm. Discount Service đã thất bại khi xác thực với User Service do lỗi 503 tạm thời trong service mesh của chúng tôi. Chúng tôi đang quản lý mười lăm microservices cho một ứng dụng có chưa đầy 500 người dùng đồng thời.

Chúng tôi đã tự đẩy mình vào thế bí do over-engineering. Bằng cách chạy theo cơn sốt microservices, chúng tôi đã đánh đổi mã nguồn đơn giản để lấy một cơ sở hạ tầng đắt đỏ và mong manh. Đêm đó đã làm rõ một điều: hầu hết các đội ngũ không cần các đơn vị triển khai độc lập; họ cần các ranh giới được thực thi chặt chẽ. Nhận thức này đã dẫn dắt đội ngũ của chúng tôi quay trở lại với Modular Monolith.

Lựa chọn kiến trúc một cách khôn ngoan

Để hiểu tại sao phương pháp này hiệu quả, hãy nhìn vào các cấp độ thiết kế backend. Ở một đầu là Traditional Monolith (Monolith truyền thống), thường được gọi là “Big Ball of Mud” (Đống bùn khổng lồ). Ở đây, mọi thứ bị xáo trộn. Việc thay đổi một thuộc tính của thực thể User có thể làm hỏng trình tạo Invoice vì chúng dùng chung database context và global namespace.

Ở phía đối diện là Microservices. Mặc dù chúng mang lại sự cô lập hoàn toàn, nhưng cái giá phải trả là rất lớn. Bạn sẽ đối mặt với sự phức tạp khi triển khai, độ trễ mạng và cơn ác mộng về tính nhất quán của dữ liệu. Quản lý Sagas và các distributed transactions (giao dịch phân tán) tự thân nó đã là một công việc toàn thời gian.

Modular Monolith mang lại một điểm trung gian thực tế. Bạn duy trì một codebase duy nhất và một đơn vị triển khai duy nhất trong khi vẫn thực thi sự phân tách logic nghiêm ngặt. Mỗi mô-đun vẫn tự đóng gói. Nếu một tính năng cụ thể—như xử lý hình ảnh—cuối cùng yêu cầu khả năng mở rộng cực cao, bạn có thể tách mô-đun đó thành một microservice chỉ trong một buổi chiều thay vì mất hàng tháng trời. Các ranh giới đã có sẵn ở đó.

Ưu và nhược điểm: Nhìn nhận thực tế

Tôi đã triển khai kiến trúc này trong các môi trường sản xuất từ startup đến doanh nghiệp quy mô vừa. Kết quả thường ổn định, nhưng nó không phải là giải pháp thần kỳ cho mọi vấn đề. Bạn phải cân nhắc các sự đánh đổi.

Lợi ích

  • Triển khai tinh gọn: Bạn quản lý một pipeline CI/CD và một Docker image duy nhất. Một GitHub Action duy nhất có thể triển khai toàn bộ stack của bạn trong chưa đầy ba phút.
  • Hiệu năng thuần túy: Các mô-đun giao tiếp thông qua các lời gọi hàm trong bộ nhớ (in-memory). Điều này loại bỏ độ trễ 20ms–50ms thường thấy của các lời gọi nội bộ qua HTTP hoặc gRPC.
  • Trải nghiệm lập trình viên (DX) vượt trội: Đội ngũ của bạn có thể chạy toàn bộ hệ thống trên một chiếc laptop 16GB RAM tiêu chuẩn. Không còn cảnh phải bật hai mươi container chỉ để sửa một lỗi CSS.
  • Type Safety: Trình biên dịch TypeScript đóng vai trò là tuyến phòng thủ đầu tiên. Nó phát hiện các thay đổi gây lỗi (breaking changes) giữa các mô-đun ngay lập tức trong quá trình phát triển.

Nhược điểm

  • Cùng chung số phận: Nếu lỗi rò rỉ bộ nhớ (memory leak) trong Reporting Module tiêu tốn hết RAM khả dụng, Payment Module cũng sẽ bị sập theo.
  • Tranh chấp tài nguyên: Tất cả các mô-đun dùng chung một nguồn tài nguyên CPU. Bạn không thể dễ dàng cấp thêm sức mạnh tính toán cho Search Module mà không mở rộng quy mô (scale) toàn bộ ứng dụng.

Bản thiết kế thành công với NestJS

NestJS được thiết kế cho mô hình này. Hệ thống mô-đun của nó cung cấp một framework hoàn hảo để vạch ra các ranh giới. Tuy nhiên, lệnh nest generate module tiêu chuẩn chỉ là điểm bắt đầu. Để ngăn chặn tình trạng “spaghetti imports”, bạn cần một chiến lược cấu trúc thư mục rõ ràng.

Dưới đây là cấu trúc thư mục được tối ưu hóa cho một modular monolith cấp độ production:


src/
├── modules/
│   ├── orders/
│   │   ├── domain/ (Logic nghiệp vụ)
│   │   ├── infrastructure/ (Schema DB, Repositories)
│   │   ├── presentation/ (Controllers)
│   │   ├── orders.module.ts
│   │   └── index.ts (API công khai)
│   ├── users/
│   └── payments/
├── common/ (Decorators và filters dùng chung)
└── main.ts

File index.ts trong mỗi mô-đun là người gác cổng của bạn. Nó đóng vai trò là “Public API”. Các mô-đun khác chỉ được phép import những gì bạn xuất khẩu (export) rõ ràng tại đây. Nếu một lập trình viên truy cập sâu vào ../users/infrastructure/user.repository.ts, họ đã vi phạm kiến trúc và tạo ra nợ kỹ thuật (technical debt).

Giảm sự phụ thuộc với Event-Driven Logic

Cách nhanh nhất để phá hỏng một monolith là cho phép Mô-đun A gọi trực tiếp các service riêng tư của Mô-đun B. Điều này tạo ra sự phụ thuộc chặt chẽ (tight coupling) khiến các thay đổi trong tương lai trở nên bất khả thi. Thay vào đó, hãy sử dụng các sự kiện nội bộ (internal events) để giao tiếp giữa các mô-đun.

1. Cài đặt Event Emitter

NestJS cung cấp một event bus gọn nhẹ trong bộ nhớ:

bash
npm install --save @nestjs/event-emitter

2. Đăng ký Module

typescript
// app.module.ts
@Module({
  imports: [
    EventEmitterModule.forRoot(),
    UsersModule,
    OrdersModule,
  ],
})
export class AppModule {}

3. Phát sự kiện (Emitting Events)

Khi một người dùng đăng ký, bạn có thể cần gửi một email chào mừng và khởi tạo hồ sơ thanh toán của họ. Thay vì UsersService phải phụ thuộc vào ba service khác, nó chỉ đơn giản là phát đi một thông điệp.

typescript
@Injectable()
export class UsersService {
  constructor(private eventEmitter: EventEmitter2) {}

  async create(dto: CreateUserDto) {
    const newUser = await this.userRepo.save(dto);
    
    this.eventEmitter.emit('user.created', {
      userId: newUser.id,
      email: newUser.email,
    });

    return newUser;
  }
}

4. Xử lý sự kiện

EmailModule lắng nghe sự kiện này. UsersModule hoàn toàn không biết ai đang lắng nghe, giúp logic luôn được tách biệt và sạch sẽ.

typescript
@Injectable()
export class NotificationsListener {
  @OnEvent('user.created')
  handleUserCreatedEvent(payload: UserCreatedPayload) {
    // Logic để kích hoạt SendGrid hoặc Amazon SES
    console.log(`Đang đưa email chào mừng vào hàng đợi cho: ${payload.email}`);
  }
}

Tự động hóa việc thực thi ranh giới

Review code thủ công rồi cũng sẽ có lúc bỏ sót. Để duy trì tính mô-đun qua nhiều năm phát triển, hãy sử dụng các công cụ như Nx hoặc Sheriff. Chúng cho phép bạn định nghĩa các quy tắc phụ thuộc cứng rắn. Ví dụ, bạn có thể thiết lập quy tắc: “Mô-đun ‘Payments’ không bao giờ được phép import từ mô-đun ‘Admin’.”

Nếu một lập trình viên vô tình vượt qua ranh giới đó, linter sẽ báo lỗi. Pull Request (PR) không thể được merge. Sự tự động hóa này đảm bảo kiến trúc của bạn luôn sạch sẽ ngay cả khi đội ngũ tăng từ hai lên hai mươi người.

Lời kết

Chọn Modular Monolith không phải là vì lười biếng. Đó là một quyết định thực tế để ưu tiên tốc độ phát triển và khả năng bảo trì. Phương pháp này cho phép bạn đi nhanh và giữ cho mã nguồn sạch sẽ. Bạn có thể trì hoãn chi phí khổng lồ của microservices cho đến khi lưu lượng truy cập—và ngân sách DevOps của bạn—thực sự xứng đáng với sự thay đổi đó.

Trước khi bắt đầu dự án tiếp theo, đừng lo lắng về việc tạo ra bao nhiêu service. Hãy tập trung vào cách xác định ranh giới của bạn. Bản thân bạn trong tương lai sẽ biết ơn vì những giấc ngủ ngon.

Share: