NestJSのコードベースがメンテナンスの地獄と化すとき
こんな場面を想像してほしい。NestJSとTypeScriptで構築した新しいECプラットフォームの開発を始めて3スプリントが経過した。コードベースは一見きれいに見える。サービスは薄く、コントローラーは最小限で、CIもすべてパスしている。そこへプロダクトチームがロードマップを持ち込んでくる――サブスクリプション課金、ロイヤルティポイント、在庫予約――すべてが同じ「注文」という概念に紐付いている。
気づけばOrderServiceは800行に膨れ上がっている。updateOrder()メソッドは5つの別サービスを呼び出している。注文がキャンセルされたときに何が動くのか、チームの誰も自信を持って追跡できない。本番環境でこの壁に何度もぶつかってきた。そのたびに、解決策はTypeScriptのリファクタリングではなく、設計の変更だった。
これはTypeScriptの問題ではなく、設計の問題だ。
根本原因:貧血ドメインモデルの罠
NestJSのチュートリアルの多くは、ビジネスロジックをサービスに入れるよう教える。最初は綺麗に見える――UserService、OrderService、PaymentService。しかしこのパターンには名前がある。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では、各コンテキストは自然とモジュールに対応する。OrderingContextはInventoryContextの内部に直接アクセスすべきではない――両者は公開イベントや明示的な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/cqrsのEventBusはプロセス内のみで動作する。マイクロサービス構成では、ドメインイベントをメッセージブローカーへ橋渡しする必要がある――RabbitMQとKafkaはどちらも相性がいい。ドメインモデルはまったく同じまま。変わるのはトランスポート層だけだ。
DDDが間違った選択になるとき
DDDには実際に前払いのコストがかかる。意味のあるビジネスルールを持たないCRUD管理パネルであれば、シンプルなサービス層が素直に正解だ――複雑にしすぎる必要はない。
このパターンがコストに見合うのは、複雑な不変条件があり、分離が必要な複数のBounded Contextがあり、異なる速度で進化するビジネスルールを持つ場合だ。ToDoアプリには過剰だ。課金、在庫、フルフィルメントが別々のスケジュールで動く注文管理システムには、セットアップに費やす時間のすべてが価値を持つ。
まずアグリゲートを特定することから始めよう。「データの整合性を保つために、どのオブジェクト群が一緒に変化しなければならないか?」と問いかける。この問いはどんなアーキテクチャ図よりも早く本質を見抜く。答えはたいてい、アグリゲートの境界を直接指し示している。

