【無料配布】10日間で学べるTypeScript学習資料

【TypeScript】DDD完全ガイド:型安全なドメイン駆動設計の実践

typescript-ddd

本記事では、TypeScriptを用いてDDDを実践するための具体的な実装パターンやTypeScript特有の型システムを活かしたテクニックを徹底解説します。

目次

DDD(ドメイン駆動設計)の基本概念とTypeScriptの相性

DDDは、ソフトウェアが対象とする業務領域(ドメイン)の知識をモデル化し、それをコードに直接反映させる設計手法です。

DDD(ドメイン駆動設計)の基本概念
  • ユビキタス言語とドメインモデリング
  • 構造的型付けとDDDの交差点

ユビキタス言語とドメインモデリング

DDDの根幹には「ユビキタス言語(Ubiquitous Language)」という概念があります。

これは、開発者とドメインエキスパート(業務の専門家)が同じ言葉を使ってコミュニケーションを取り、その言葉をそのままクラス名やメソッド名としてコードに落とし込むという手法です。

例えば、「ユーザーが商品をカートに入れる」という業務フローがある場合、TypeScriptのコード上でもuser.addItemToCart(item)のように、ドメインの言葉をそのまま表現します。

構造的型付けとDDDの交差点

JavaやC#のような言語は「公称型(Nominal Typing)」を採用していますが、TypeScriptは「構造的型付け(Structural Typing)」を採用しています。

これは、オブジェクトの形(プロパティやメソッド)が同じであれば、同じ型として扱われるという特徴です。

DDDでは「ID」や「金額」といった概念を厳密に区別したいため、このTypeScriptの仕様が障壁になることがあります。

しかし、TypeScriptの高度な型推論やユーティリティを活用することで、この壁を乗り越え、より柔軟で安全なドメインモデルを構築することが可能です。

戦術的設計(Tactical Design)の実装

DDDをコードに落とし込むための具体的なパターンである「戦術的設計」をTypeScriptでどのように実装するかを見ていきましょう。

戦術的設計の実装
  • 値オブジェクト:不変性とルールのカプセル化
  • エンティティ:同一性とライフサイクルの管理
  • 集約:データ整合性を守るトランザクション境界

値オブジェクト:不変性とルールのカプセル化

値オブジェクトは、システム内の「計測可能・記述可能」な概念を表現します。

最大のルールは「不変(Immutable)であること」です。

例えば、「メールアドレス」を単なるstringで扱うと、どこかで不正な文字列が混入するリスクがあります。

これをクラスとしてカプセル化します。

export class EmailAddress {
  private readonly value: string;

  constructor(value: string) {
    if (!this.isValid(value)) {
      throw new Error("無効なメールアドレスの形式です");
    }
    this.value = value;
  }

  private isValid(value: string): boolean {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return regex.test(value);
  }

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

  // 値オブジェクトは「属性が全て同じなら同一」とみなす
  public equals(other: EmailAddress): boolean {
    return this.value === other.getValue();
  }
}

エンティティ:同一性とライフサイクルの管理

エンティティは、属性が変わっても「同じもの」として識別されるオブジェクトです。

例えば、ユーザーは名前や年齢が変わっても、同一のユーザーとして認識されます。

これを区別するために「識別子(ID)」を持ちます。

export class User {
  constructor(
    public readonly id: UserId, // 識別子
    private name: UserName,     // 値オブジェクト
    private email: EmailAddress // 値オブジェクト
  ) {}

  public changeEmail(newEmail: EmailAddress): void {
    this.email = newEmail;
  }

  // エンティティは「IDが同じなら同一」とみなす
  public equals(other: User): boolean {
    return this.id.equals(other.id);
  }
}

集約:データ整合性を守るトランザクション境界

集約は、必ず一緒に整合性を保たなければならないエンティティと値オブジェクトのまとまりです。

外部からの操作は、必ず集約の代表である「集約ルート(Aggregate Root)」を通して行います。

例えば、「注文(Order)」と「注文明細(OrderItem)」がある場合、明細だけを直接追加・削除するのではなく、必ず Order.addItem() のように集約ルートを経由させることで、合計金額の計算などのビジネスルールが破綻するのを防ぎます。

アーキテクチャとディレクトリ構成

ドメインモデルを隔離し、外部の技術(データベースやUI)から保護するために、レイヤードアーキテクチャやオニオンアーキテクチャ(クリーンアーキテクチャ)を採用します。

アーキテクチャとディレクトリ構成
  • レイヤードアーキテクチャの適用
  • 依存関係逆転の原則とInterfaceの活用

レイヤードアーキテクチャの適用

TypeScriptプロジェクトでは、以下のようなディレクトリ構成が一般的です。

  • domain/
    ドメインモデル(Entity, Value Object, Repository Interface)を配置。
    外部依存は一切持たない。
  • usecase/
    アプリケーションのユースケース(ユーザー登録など)を実装。
    ドメインモデルを操作する。
  • infrastructure/
    データベースへの保存(PrismaやTypeORM)や外部API通信の実装。
  • presentation/
    ExpressのコントローラーやNext.jsのAPI Routesなど。

依存関係逆転の原則とInterfaceの活用

DDDにおいて最も重要なのが、ドメイン層がインフラ層に依存してはならないということです。

ここでTypeScriptのinterfaceが活躍します。

// domain/repository/UserRepository.ts
// ドメイン層でインターフェース(契約)だけを定義する
export interface UserRepository {
  findById(id: UserId): Promise<User | null>;
  save(user: User): Promise<void>;
}

データベースへの実際の保存処理は、このインターフェースを実装(implements)したクラスをインフラ層に配置します。

これにより、テスト時にモック(偽物のデータベース)を注入しやすくなり、ビジネスロジックの単体テストが極めて容易になります。

TypeScriptによるDDD実装テクニック

  • 公称型のシミュレート
  • 失敗を型で表現する

公称型のシミュレート

前述の通り、TypeScriptは構造的型付けであるため、単なるstringのラップクラスでは意図しない代入を防ぎきれない場合があります。

そこで、「Branded Types(またはOpaque Types)」と呼ばれるテクニックを使います。

// ユーティリティ型の定義
type Brand<K, T> = K & { __brand: T };

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;

const userId = "123" as UserId;
const orderId = "456" as OrderId;

// 以下の代入はコンパイルエラーになり、型安全性が劇的に向上する!
// const specificId: UserId = orderId;

関数の引数にOrderIdを渡すべきところに誤ってUserIdを渡してしまうバグを、コンパイル時点で完全に防ぐことができます。

失敗を型で表現する

DDDでは、業務上の例外(在庫不足など)と、システム上の例外(DB接続エラーなど)を明確に区別します。

TypeScriptではthrow Errorを多用すると、呼び出し元でどのようなエラーが返ってくるか型で追えなくなります。

これを解決するために、Rust等の言語で見られるResult型を導入するのがトレンドです。

type Success<T> = { isSuccess: true; value: T };
type Failure<E> = { isSuccess: false; error: E };
type Result<T, E> = Success<T> | Failure<E>;

// ユースケースの戻り値として使用
function registerUser(command: RegisterCommand): Result<User, EmailAlreadyExistsError> {
  // ...
}

エラーハンドリングをコンパイラに強制させることができ、より堅牢なアプリケーション設計が可能になります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次