Khi Codebase NestJS Của Bạn Trở Thành Cơn Ác Mộng Bảo Trì
Hình dung thế này: bạn đang chạy sprint thứ ba của một nền tảng thương mại điện tử mới xây bằng NestJS và TypeScript. Codebase trông rất gọn gàng. Service thì mỏng, controller tối giản, và tất cả đều pass CI. Rồi đội sản phẩm tung ra roadmap: thanh toán theo gói, điểm tích lũy khách hàng, và đặt trước hàng tồn kho — tất cả đều liên quan đến khái niệm “order” (đơn hàng) đó.
Bỗng dưng, OrderService của bạn phình lên 800 dòng. Method updateOrder() gọi đến năm service khác. Không ai trong team có thể tự tin truy ra điều gì xảy ra khi một đơn hàng bị hủy. Tôi đã đụng phải bức tường này trong production — không chỉ một lần. Mỗi lần như vậy, giải pháp không phải là refactor TypeScript. Mà là thay đổi thiết kế.
Đây là vấn đề thiết kế, không phải vấn đề TypeScript.
Nguyên Nhân Gốc Rễ: Bẫy Anemic Domain Model
Hầu hết tutorial NestJS đều dạy bạn đặt business logic vào bên trong service. Thoạt nhìn có vẻ gọn — một UserService, một OrderService, một PaymentService. Nhưng pattern này có tên riêng. Eric Evans gọi nó là anemic domain model (mô hình domain thiếu máu). Các entity của bạn trở thành những túi chứa dữ liệu. Behavior thực sự bị phân tán khắp nơi trong các class service.
Triệu chứng xuất hiện rất nhanh:
- Các service import lẫn nhau tạo thành vòng tròn circular dependency
- Business rule bị trùng lặp ở nhiều service
- Test phải mock nửa cây dependency chỉ để kiểm tra một assertion đơn giản
- Developer mới mất nhiều ngày truy tìm xem một rule cụ thể thực sự nằm ở đâu
Vấn đề cốt lõi là sự sở hữu — hay chính xác hơn là sự thiếu sở hữu. Ai chịu trách nhiệm cho quy tắc “đơn hàng chỉ có thể hủy nếu chưa được giao”? Nó nằm trong OrderService? ShipmentService? Hay một utility helper nào đó? Khi không ai đồng thuận, nó xuất hiện ở khắp nơi — hoặc tệ hơn, bị áp dụng không nhất quán xuyên suốt codebase.
Hai Cách Tiếp Cận Song Song
Trước khi đi sâu vào DDD, hãy xem cùng một logic hủy đơn hàng viết theo cả hai kiểu — để thấy rõ sự khác biệt:
Cách tiếp cận anemic service:
// order.service.ts
async cancelOrder(orderId: string): Promise<void> {
const order = await this.orderRepo.findById(orderId);
const shipment = await this.shipmentService.findByOrder(orderId);
if (shipment && shipment.status === 'shipped') {
throw new Error('Không thể hủy đơn hàng đã giao');
}
order.status = 'cancelled';
await this.orderRepo.save(order);
await this.notificationService.sendCancellationEmail(order.userId);
await this.inventoryService.releaseStock(order.items);
}
Cách tiếp cận DDD aggregate:
// order.aggregate.ts
export class Order extends AggregateRoot {
private status: OrderStatus;
private items: OrderItem[];
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new OrderCancellationError('Không thể hủy đơn hàng đã giao');
}
this.status = OrderStatus.CANCELLED;
this.apply(new OrderCancelledEvent(this.id, this.items));
}
}
// order.service.ts — giờ chỉ là điều phối
async cancelOrder(orderId: string): Promise<void> {
const order = await this.orderRepository.findById(orderId);
order.cancel();
await this.orderRepository.save(order);
}
Business rule đã chuyển vào bên trong domain object. Thông báo và cập nhật tồn kho xảy ra trong event subscriber phản ứng với OrderCancelledEvent. Service giờ đây chỉ thuần điều phối — không còn business logic nào trong tầm nhìn.
Các Thành Phần DDD trong NestJS
Bounded Context như là NestJS Module
Bounded Context là ranh giới bao quanh một tập hợp các khái niệm domain liên kết chặt chẽ. Trong NestJS, mỗi context ánh xạ tự nhiên vào một module. OrderingContext không nên truy cập trực tiếp vào bên trong InventoryContext — chúng giao tiếp thông qua event được publish hoặc hợp đồng API rõ ràng, không phải gọi service trực tiếp.
// ordering/ordering.module.ts
@Module({
imports: [CqrsModule],
providers: [
OrderService,
OrderRepository,
OrderCancelledHandler, // Xử lý side effect thông qua event
],
exports: [OrderService],
})
export class OrderingModule {}
// inventory/inventory.module.ts
@Module({
imports: [CqrsModule],
providers: [
InventoryService,
InventoryRepository,
ReleaseStockOnOrderCancelled, // Phản ứng với event liên context
],
})
export class InventoryModule {}
Xây Dựng Aggregate Đúng Cách
NestJS đi kèm với @nestjs/cqrs, bao gồm class cơ sở AggregateRoot được thiết kế để thu thập và publish domain event. Đây là một aggregate Order đầy đủ với các guard validation thực tế:
// ordering/domain/order.aggregate.ts
import { AggregateRoot } from '@nestjs/cqrs';
import { OrderCancelledEvent } from '../events/order-cancelled.event';
import { OrderPlacedEvent } from '../events/order-placed.event';
export enum OrderStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
SHIPPED = 'SHIPPED',
CANCELLED = 'CANCELLED',
}
export class Order extends AggregateRoot {
constructor(
public readonly id: string,
private status: OrderStatus,
private readonly customerId: string,
private items: OrderItem[],
) {
super();
}
static place(id: string, customerId: string, items: OrderItem[]): Order {
if (items.length === 0) {
throw new Error('Đơn hàng phải có ít nhất một sản phẩm');
}
const order = new Order(id, OrderStatus.PENDING, customerId, items);
order.apply(new OrderPlacedEvent(id, customerId, items));
return order;
}
confirm(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error(`Không thể xác nhận đơn hàng ở trạng thái ${this.status}`);
}
this.status = OrderStatus.CONFIRMED;
}
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error('Không thể hủy đơn hàng đã giao');
}
this.status = OrderStatus.CANCELLED;
this.apply(new OrderCancelledEvent(this.id, this.items));
}
getStatus(): OrderStatus {
return this.status;
}
}
Domain Event Vượt Ranh Giới Context
Domain event cho phép các bounded context phản ứng lẫn nhau mà không cần coupling. Định nghĩa chúng như các class thông thường — không có phép thuật framework, không có decorator:
// ordering/events/order-cancelled.event.ts
export class OrderCancelledEvent {
constructor(
public readonly orderId: string,
public readonly items: OrderItem[],
) {}
}
// inventory/handlers/release-stock.handler.ts
@EventsHandler(OrderCancelledEvent)
export class ReleaseStockOnOrderCancelled
implements IEventHandler<OrderCancelledEvent> {
constructor(private readonly inventoryService: InventoryService) {}
async handle(event: OrderCancelledEvent): Promise<void> {
await this.inventoryService.releaseStock(event.items);
}
}
Inventory context phản ứng với event đó. Nó không biết gì về cách order hoạt động bên trong. Thêm một phản ứng mới — chẳng hạn, cảnh báo kho hàng hay push notification — chỉ cần thêm một file handler mới và không thay đổi gì trong code hiện có. Đó là phần thưởng thực sự.
Repository Pattern cho Aggregate
Repository trong DDD không phải là wrapper database đa năng thông thường. Chúng nói ngôn ngữ domain và trả về aggregate đã được tái tạo đầy đủ — sẵn sàng để gọi method:
// ordering/repositories/order.repository.ts
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
findByCustomer(customerId: string): Promise<Order[]>;
}
// ordering/repositories/typeorm-order.repository.ts
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
constructor(
@InjectRepository(OrderEntity)
private readonly repo: Repository<OrderEntity>,
private readonly eventBus: EventBus,
) {}
async save(order: Order): Promise<void> {
const entity = this.toEntity(order);
await this.repo.save(entity);
// Publish domain event đã được thu thập bởi AggregateRoot
order.getUncommittedEvents().forEach(event => this.eventBus.publish(event));
order.commit();
}
async findById(id: string): Promise<Order | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.toDomain(entity) : null;
}
private toDomain(entity: OrderEntity): Order {
return new Order(
entity.id,
entity.status as OrderStatus,
entity.customerId,
entity.items.map(i => new OrderItem(i.productId, i.quantity, i.price)),
);
}
}
Kết Nối Command Handler
Command là thứ thúc đẩy thay đổi trạng thái trong CQRS. Đây là luồng hủy đơn hàng hoàn chỉnh, từ đầu đến cuối:
// ordering/commands/cancel-order.command.ts
export class CancelOrderCommand {
constructor(public readonly orderId: string) {}
}
// ordering/handlers/cancel-order.handler.ts
@CommandHandler(CancelOrderCommand)
export class CancelOrderHandler implements ICommandHandler<CancelOrderCommand> {
constructor(private readonly orderRepository: OrderRepository) {}
async execute(command: CancelOrderCommand): Promise<void> {
const order = await this.orderRepository.findById(command.orderId);
if (!order) throw new NotFoundException('Không tìm thấy đơn hàng');
order.cancel(); // Domain logic nằm trong aggregate
await this.orderRepository.save(order); // Event được publish ở đây
}
}
Controller chỉ đơn giản dispatch command — không business logic, không điều kiện rẽ nhánh:
// ordering/order.controller.ts
@Controller('orders')
export class OrderController {
constructor(private readonly commandBus: CommandBus) {}
@Delete(':id')
async cancel(@Param('id') id: string): Promise<void> {
await this.commandBus.execute(new CancelOrderCommand(id));
}
}
Những Gì Kiến Trúc Này Mang Lại Trong Thực Tế
Ba lợi ích cụ thể xuất hiện nhất quán trong môi trường production:
- Khả năng test: Aggregate là các class TypeScript thuần túy không có dependency DI nào. Test logic hủy đơn chỉ là một lệnh gọi
order.cancel()— không mock, không test double, không cần khởi động module. - Onboarding nhanh hơn: Kỹ sư mới tìm business rule bằng cách đọc aggregate method, không phải grep khắp hàng chục file service. Một kỹ sư trong team tôi đã đi từ “đơn hàng là cái gì vậy?” đến việc ship tính năng thanh toán trong hai ngày thay vì một tuần như thường lệ.
- Cô lập thay đổi chính xác: Thêm phản ứng mới cho
OrderCancelledEventchỉ cần một handler mới. Không có gì khác thay đổi. Không có rủi ro regression khi chạm vào code hiện có.
Một điều cần lưu ý: EventBus của NestJS từ @nestjs/cqrs chỉ hoạt động trong cùng một process. Trong kiến trúc microservice, bạn sẽ cần bridge domain event sang một message broker — RabbitMQ và Kafka đều hoạt động tốt ở đây. Domain model vẫn giữ nguyên. Chỉ có tầng transport thay đổi.
Khi Nào DDD Là Lựa Chọn Sai
DDD thêm vào độ phức tạp ban đầu không nhỏ. Với một admin panel CRUD đơn giản không có business rule đáng kể, một service layer đơn giản thực sự là lựa chọn đúng — đừng làm nó phức tạp hơn cần thiết.
Pattern này xứng đáng với chi phí bỏ ra khi bạn có invariant phức tạp, nhiều bounded context cần tách rời, và business rule phát triển với tốc độ khác nhau. Với một app to-do: quá mức cần thiết. Với hệ thống quản lý đơn hàng có thanh toán, tồn kho và fulfillment chạy theo lịch riêng biệt: đáng từng giờ thiết lập.
Hãy bắt đầu bằng việc xác định aggregate của bạn. Hãy hỏi: “nhóm object nào phải thay đổi cùng nhau để giữ dữ liệu nhất quán?” Câu hỏi đó xuyên thủng mọi sơ đồ kiến trúc nhanh hơn bất kỳ thứ gì khác. Câu trả lời thường chỉ thẳng vào ranh giới aggregate của bạn.

