Node.jsプロジェクトへのドメイン駆動設計(DDD)の適用:「スパゲッティコード」から持続可能なアーキテクチャへ

Development tutorial - IT technology blog
Development tutorial - IT technology blog

スパゲッティコードから洗練されたアーキテクチャへ

Node.jsを書き始めたばかりの頃、私はつい慣れでコントローラーにすべてのロジックを詰め込んでしまう癖がありました。その結果、createOrderのような関数が300〜400行にも膨れ上がり、バリデーションから税計算、在庫減算まであらゆる処理を抱え込むことになりました。クライアントからキャンペーンロジックの些細な修正を依頼されるたびに、無関係な箇所が壊れないかチーム全員が息を潜めて作業していました。

私はかつて、5万行以上のコードで稼働するシステムのレガシーコードのリファクタリングに参加したことがあります。そこで得た最も貴重な教訓は、「トランザクションスクリプト」(コードを上から下へ逐次的に流し込む書き方)を続けていると、システムはいずれ自らの重みで崩壊するということです。それが、私がドメイン駆動設計(DDD)を真剣に学び始めたきっかけでした。

アプローチの現実的な比較

プロジェクトを開始する際、私たちは主に2つの direction の間で迷うことになります:

  • トランザクションスクリプト(即席メンのような思考): コントローラーやサービス内にロジックを逐次的に記述します。小規模なプロジェクトでは極めて迅速ですが、ロジックが断片化して至る所に重複するため、スケール時には悲劇を招きます。
  • ドメイン駆動設計 (DDD): すべては「ドメイン」(核心となるビジネスルール)を中心に展開します。コードはビジネスの運用実態を正確に反映しなければなりません。初期設定には20〜30%ほど余計に時間がかかりますが、長期的な安定性は計り知れない価値があります。

DDDを導入するコストとメリット

メリット:

  • リスクの局所化: ビジネスロジックが一箇所に集約され、データベースやUIから完全に切り離されます。
  • 共通言語の確立: 開発者とプロダクトマネージャーが共通の言語(ユビキタス言語)を使用することで、要件定義時の誤解を最小限に抑えられます。
  • マイクロサービスへの対応: 「境界づけられたコンテキスト」が明確に分離されているため、将来的なシステムの分割が数ヶ月ではなく数日で完了します。

デメリット:

  • 学習コストが高い: DDDは忍耐高く取り組める人向けの手法です。
  • ボイラープレートコード: 通常よりも多くのクラスやインターフェースを作成する必要があります。

TypeScriptによるDDDの実践的な実装

DDDを効果的に実装するためには、各コンポーネントの境界を明確にする必要があります。

1. 境界づけられたコンテキスト(Bounded Context)の設定

システム全体をカバーする「巨大な」データベーススキーマを構築しようとするのではなく、細かく分割しましょう。例えばECサイトでは、販売チームから見たProduct(価格、説明)と、物流チームから見たProduct(サイズ、重量、棚の位置)では、その性質が大きく異なります。

2. 値オブジェクト (Value Object) – 不変性が生む力

値オブジェクト(VO)はIDを必要とせず、その値自体によって識別されるオブジェクトです。例えば、メールアドレス、電話番号、金額などがこれに当たります。

// value-objects/email.vo.ts
export class Email {
  private readonly value: string;

  constructor(email: string) {
    if (!email.includes('@')) {
      throw new Error('メールアドレスの形式が正しくありません');
    }
    this.value = email;
  }

  getValue(): string {
    return this.value;
  }

  equals(other: Email): boolean {
    return this.value === other.getValue();
  }
}

VOを使用することで、不正なデータを入り口(バリデーション)で遮断でき、サービス層のバリデーションロジックを40%削減できます。

3. エンティティ (Entity) – 識別子を持つオブジェクト

VOとは異なり、エンティティは一意のIDによって識別されます。たとえ他のすべての属性が同じであっても, IDが異なれば、それらは別々の実体として扱われます。

// entities/user.entity.ts
import { Email } from '../value-objects/email.vo';

export class User {
  constructor(
    public readonly id: string,
    private email: Email,
    private name: string
  ) {}

  changeName(newName: string): void {
    if (newName.trim().length < 2) throw new Error('名前が無効です');
    this.name = newName;
  }
}

4. リポジトリパターン – データベースにロジックを邪魔させない

DDDにおいて、ドメイン層はMySQLを使っているかMongoDBを使っているかを関知すべきではありません。リポジトリは、メモリ上にあるエンティティのコレクションのような役割を果たします。

// repositories/user.repository.interface.ts
export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

その後、Infrastructure層でTypeORMやPrismaを使用してこのインターフェースを実装します。このアプローチにより、ストレージ技術の変更が非常に柔軟になります。

現場からのアドバイス

多くのプロジェクトでDDDを適用してきた経験から、圧倒されないためのポイントをいくつか紹介します:

  1. 適材適所: プロジェクトが社内向けのシンプルなCRUDアプリであれば、DDDは不要です。それは「鶏を割くに焉んぞ牛刀を用いん(小さなことに大げさな手段を用いること)」の典型です。
  2. ドメインが先、データベースは後: テーブルを作成し始める前に、エンティティの図やビジネスフローを紙に書いて整理しましょう。
  3. 依存性の注入(DI)の活用: インターフェースは常にドメイン層に置き、実装は外部から注入します。これにより、データベースを起動することなくユニットテストを極めて迅速に実行できます。

私がこのモデルを運送管理プロジェクトに適用した際、Trip(配送)エンティティを時間の重複チェックルールから切り離したことで、旧バージョンと比較してビジネスロジックのバグを80%削減できました。

DDDはすべての問題に対する万能薬ではありませんが、複雑なロジックを持つNode.jsシステムを制御するための最も強力なツールです。まずはいくつかの値オブジェクトを切り出すことから始めてみてください。コードが驚くほど「クリーン」になり、開発が楽になるのを実感できるはずです。

Share: