NestJSとTypeScriptで実践するドメイン駆動設計:本番環境で使えるBounded Context・Aggregate・Domain Events

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

NestJSのコードベースがメンテナンスの地獄と化すとき

こんな場面を想像してほしい。NestJSとTypeScriptで構築した新しいECプラットフォームの開発を始めて3スプリントが経過した。コードベースは一見きれいに見える。サービスは薄く、コントローラーは最小限で、CIもすべてパスしている。そこへプロダクトチームがロードマップを持ち込んでくる――サブスクリプション課金、ロイヤルティポイント、在庫予約――すべてが同じ「注文」という概念に紐付いている。

気づけばOrderServiceは800行に膨れ上がっている。updateOrder()メソッドは5つの別サービスを呼び出している。注文がキャンセルされたときに何が動くのか、チームの誰も自信を持って追跡できない。本番環境でこの壁に何度もぶつかってきた。そのたびに、解決策はTypeScriptのリファクタリングではなく、設計の変更だった。

これはTypeScriptの問題ではなく、設計の問題だ。

根本原因:貧血ドメインモデルの罠

NestJSのチュートリアルの多くは、ビジネスロジックをサービスに入れるよう教える。最初は綺麗に見える――UserServiceOrderServicePaymentService。しかしこのパターンには名前がある。Eric Evansはこれを貧血ドメインモデル(Anemic Domain Model)と呼んだ。エンティティはデータの入れ物になり、実際の振る舞いはサービスクラス全体に散らばってしまう。

症状はすぐに現れる:

  • 循環参照でお互いをインポートするサービス群
  • 複数のサービスに重複したビジネスルール
  • 単一のアサーションを検証するためにDIツリーの半分をモックする必要があるテスト
  • 特定のルールがどこにあるのかを追いかけるのに何日も費やす新規開発者

根本的な問題はオーナーシップ――あるいはその欠如だ。「注文は発送前しかキャンセルできない」というルールを誰が持つべきか?OrderServiceか?ShipmentServiceか?どこかのユーティリティヘルパーか?誰も答えを持っていないと、そのルールはあちこちに散在するか、さらに悪い場合にはコードベース全体で一貫性のない適用をされてしまう。

2つのアプローチを並べて比較

DDDの詳細に入る前に、同じキャンセルロジックを2つのスタイルで比較してみよう――トレードオフが具体的に見えてくる:

貧血サービスアプローチ:

// 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('発送済み注文はキャンセルできません');
  }

  order.status = 'cancelled';
  await this.orderRepo.save(order);
  await this.notificationService.sendCancellationEmail(order.userId);
  await this.inventoryService.releaseStock(order.items);
}

DDDアグリゲートアプローチ:

// order.aggregate.ts
export class Order extends AggregateRoot {
  private status: OrderStatus;
  private items: OrderItem[];

  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new OrderCancellationError('発送済みの注文はキャンセルできません');
    }
    this.status = OrderStatus.CANCELLED;
    this.apply(new OrderCancelledEvent(this.id, this.items));
  }
}

// order.service.ts — オーケストレーションのみ
async cancelOrder(orderId: string): Promise<void> {
  const order = await this.orderRepository.findById(orderId);
  order.cancel();
  await this.orderRepository.save(order);
}

ビジネスルールがドメインオブジェクトの中に移動した。通知と在庫更新は、OrderCancelledEventに反応するイベントサブスクライバーで行われる。サービスは純粋なオーケストレーションになり、ビジネスロジックは一切見当たらない。

NestJSにおけるDDDの構成要素

NestJSモジュールとしてのBounded Context

Bounded Contextとは、一貫したドメイン概念のセットを囲む境界のことだ。NestJSでは、各コンテキストは自然とモジュールに対応する。OrderingContextInventoryContextの内部に直接アクセスすべきではない――両者は公開イベントや明示的なAPIコントラクトを通じて通信し、直接サービスを呼び出してはならない。

// ordering/ordering.module.ts
@Module({
  imports: [CqrsModule],
  providers: [
    OrderService,
    OrderRepository,
    OrderCancelledHandler, // イベント経由でサイドエフェクトを処理
  ],
  exports: [OrderService],
})
export class OrderingModule {}

// inventory/inventory.module.ts
@Module({
  imports: [CqrsModule],
  providers: [
    InventoryService,
    InventoryRepository,
    ReleaseStockOnOrderCancelled, // コンテキスト間イベントに反応
  ],
})
export class InventoryModule {}

適切なAggregateの構築

NestJSには@nestjs/cqrsが付属しており、ドメインイベントの収集と発行に特化したAggregateRootベースクラスが含まれている。実際のバリデーションガードを持つ完全なOrderアグリゲートを見てみよう:

// 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('注文には少なくとも1つの商品が必要です');
    }
    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(`ステータス ${this.status} の注文は確認できません`);
    }
    this.status = OrderStatus.CONFIRMED;
  }

  cancel(): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new Error('発送済みの注文はキャンセルできません');
    }
    this.status = OrderStatus.CANCELLED;
    this.apply(new OrderCancelledEvent(this.id, this.items));
  }

  getStatus(): OrderStatus {
    return this.status;
  }
}

コンテキスト境界を越えるDomain Events

ドメインイベントを使うと、Bounded Context同士が結合せずに互いの動きに反応できる。プレーンなクラスとして定義しよう――フレームワークの魔法も、デコレーターも不要だ:

// 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);
  }
}

在庫コンテキストはイベントに反応する。注文が内部でどう動いているかは一切知らない。2つ目の反応――例えば倉庫アラートやプッシュ通知――を追加するには、ハンドラーファイルを1つ追加するだけでよく、既存のコードへの変更はゼロだ。これが本当の見返りだ。

AggregateのRepositoryパターン

DDDにおけるリポジトリは、汎用的なデータベースラッパーではない。ドメイン言語を話し、メソッドを呼び出せる状態に完全に再構成されたアグリゲートを返す:

// 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);
    // 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)),
    );
  }
}

コマンドハンドラーの接続

CQRSではコマンドが状態変化を駆動する。注文キャンセルの完全なフローをエンドツーエンドで見てみよう:

// 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('注文が見つかりません');

    order.cancel(); // ドメインロジックはアグリゲートの中に留まる
    await this.orderRepository.save(order); // ここでイベントが発行される
  }
}

コントローラーはコマンドをディスパッチするだけ――ビジネスロジックも条件分岐もない:

// 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));
  }
}

この構造が実際にもたらすもの

本番環境で一貫して得られる3つの具体的なメリットがある:

  • テスタビリティ:アグリゲートはDI依存のないピュアなTypeScriptクラスだ。キャンセルロジックのテストはorder.cancel()を1回呼ぶだけ――モックも、テストダブルも、モジュールの起動も不要だ。
  • オンボーディングの高速化:新しいエンジニアは十数個のサービスファイルをgrepするのではなく、アグリゲートのメソッドを読むだけでビジネスルールがわかる。私のチームのあるエンジニアは、「注文って何?」という状態から、通常1週間かかる課金機能を2日でリリースした。
  • ピンポイントな変更の分離OrderCancelledEventへの新しい反応を追加するには、新しいハンドラーを1つ追加するだけ。他は何も変わらない。既存コードに触れないため、デグレードのリスクもない。

注意点が1つある:NestJSの@nestjs/cqrsEventBusはプロセス内のみで動作する。マイクロサービス構成では、ドメインイベントをメッセージブローカーへ橋渡しする必要がある――RabbitMQとKafkaはどちらも相性がいい。ドメインモデルはまったく同じまま。変わるのはトランスポート層だけだ。

DDDが間違った選択になるとき

DDDには実際に前払いのコストがかかる。意味のあるビジネスルールを持たないCRUD管理パネルであれば、シンプルなサービス層が素直に正解だ――複雑にしすぎる必要はない。

このパターンがコストに見合うのは、複雑な不変条件があり、分離が必要な複数のBounded Contextがあり、異なる速度で進化するビジネスルールを持つ場合だ。ToDoアプリには過剰だ。課金、在庫、フルフィルメントが別々のスケジュールで動く注文管理システムには、セットアップに費やす時間のすべてが価値を持つ。

まずアグリゲートを特定することから始めよう。「データの整合性を保つために、どのオブジェクト群が一緒に変化しなければならないか?」と問いかける。この問いはどんなアーキテクチャ図よりも早く本質を見抜く。答えはたいてい、アグリゲートの境界を直接指し示している。

Share: