TypeScriptでDDDを実装する意義

TypeScriptは、JavaやC#のようなクラスベースのOOP機能と、関数型プログラミングの要素を兼ね備えた言語です。 その構造的型付け(Structural Typing)と強力な型推論により、DDDのパターンを型レベルで表現できます。

この章では、DDDの主要なビルディングブロックをTypeScriptで実装する具体的な方法を学びます。 単にコードを書くだけでなく、型システムを活用してドメインルールをコンパイル時に強制するテクニックに焦点を当てます。

Value Objectの実装

Value Object(値オブジェクト)は、不変で属性値のみで等価性が判断されるオブジェクトです。 TypeScriptでは大きく2つのアプローチがあります。

アプローチ1: クラスベース(Object.freeze + equals)

伝統的なDDD実装スタイルです。Object.freezeで不変性を保証し、equalsメソッドで等価性を判定します。

// 値オブジェクトの基底クラス
abstract class ValueObject<T extends Record<string, unknown>> {
  protected readonly props: Readonly<T>;

  protected constructor(props: T) {
    this.props = Object.freeze({ ...props });
  }

  equals(other: ValueObject<T>): boolean {
    if (other === null || other === undefined) return false;
    return JSON.stringify(this.props) === JSON.stringify(other.props);
  }
}

// 具体的な値オブジェクト: Money
class Money extends ValueObject<{ amount: number; currency: string }> {
  private constructor(amount: number, currency: string) {
    super({ amount, currency });
  }

  // ファクトリメソッドでバリデーション
  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new Error('金額は0以上');
    if (!['JPY', 'USD', 'EUR'].includes(currency)) {
      throw new Error(`未対応通貨: ${currency}`);
    }
    return new Money(amount, currency);
  }

  get amount(): number { return this.props.amount; }
  get currency(): string { return this.props.currency; }

  // 副作用なし: 新しいインスタンスを返す
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('異なる通貨は加算できません');
    }
    return Money.create(this.amount + other.amount, this.currency);
  }
}

アプローチ2: Branded Types(ランタイムコストゼロ)

TypeScript独自のテクニックで、型レベルでのみ区別される「ブランド」をプリミティブ型に付与します。 ランタイムでは通常のstringnumberと同じで、パフォーマンスの低下がありません。

// Branded Typeの定義
type Brand<T, B extends string> = T & { readonly __brand: B };

// 値オブジェクトをBranded Typeで表現
type Email = Brand<string, 'Email'>;
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

// コンストラクタ関数でバリデーション
function createEmail(value: string): Email {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(value)) {
    throw new Error(`無効なメールアドレス: ${value}`);
  }
  return value as Email;
}

function createUserId(value: string): UserId {
  if (!value || value.length === 0) {
    throw new Error('UserIdは空にできません');
  }
  return value as UserId;
}

// 型安全: UserIdとOrderIdを混同するとコンパイルエラー
function findUser(id: UserId): User { /* ... */ }

const userId = createUserId('user-123');
const orderId = 'order-456' as OrderId;

findUser(userId);   // OK
findUser(orderId);  // コンパイルエラー!型 'OrderId' を型 'UserId' に割り当てられません
観点 クラスベース Branded Types
ランタイムコスト Object.freeze分のオーバーヘッドあり ゼロ(型情報は消去される)
等価性判定 equalsメソッドで明示的に比較 ===演算子で比較可能(プリミティブ)
メソッド追加 add, formatなどのメソッドを定義可能 スタンドアロン関数として定義
バリデーション ファクトリメソッド内で実行 コンストラクタ関数内で実行
適用場面 振る舞いを持つ複合的な値(Money等) ID類、メールアドレス等の単純なラッパー

Entityの実装

Entity(エンティティ)は、IDベースで同一性が判定され、ライフサイクルを通じて状態が変化するオブジェクトです。 TypeScriptではprivateコンストラクタ + ファクトリメソッドパターンを用いて、常に有効な状態で生成されることを保証します。

// エンティティの基底クラス
abstract class Entity<TId> {
  protected constructor(
    private readonly _id: TId
  ) {}

  get id(): TId { return this._id; }

  // IDベースの等価性判定
  equals(other: Entity<TId>): boolean {
    if (other === null || other === undefined) return false;
    return this._id === other._id;
  }
}

// 具体的なエンティティ: User
type UserStatus = 'active' | 'suspended' | 'deleted';

class User extends Entity<UserId> {
  private _name: string;
  private _email: Email;
  private _status: UserStatus;

  // privateコンストラクタ: 外部からのnewを禁止
  private constructor(
    id: UserId,
    name: string,
    email: Email,
    status: UserStatus
  ) {
    super(id);
    this._name = name;
    this._email = email;
    this._status = status;
  }

  // ファクトリメソッド: バリデーション付き
  static create(params: {
    id: UserId;
    name: string;
    email: Email;
  }): User {
    if (params.name.length < 1 || params.name.length > 50) {
      throw new Error('名前は1〜50文字');
    }
    return new User(params.id, params.name, params.email, 'active');
  }

  // ドメインロジックをエンティティ内に保持
  suspend(reason: string): void {
    if (this._status !== 'active') {
      throw new Error('アクティブなユーザーのみ停止できます');
    }
    this._status = 'suspended';
  }

  reactivate(): void {
    if (this._status !== 'suspended') {
      throw new Error('停止中のユーザーのみ再開できます');
    }
    this._status = 'active';
  }
}

Aggregate Rootの実装

Aggregate Root(集約ルート)は、トランザクション境界を定義するエンティティです。 TypeScriptでの実装では、ドメインイベントの管理を集約ルートの責務として組み込みます。

classDiagram
  class AggregateRoot~TId~ {
    -id: TId
    -domainEvents: DomainEvent[]
    +addDomainEvent(event)
    +clearDomainEvents()
    +getDomainEvents()
  }
  class Order {
    -items: OrderItem[]
    -status: OrderStatus
    -shippingAddress: Address
    +addItem(productId, quantity, price)
    +confirm()
    +cancel(reason)
  }
  class OrderItem {
    -productId: ProductId
    -quantity: Quantity
    -unitPrice: Money
  }
  class Address {
    -street: string
    -city: string
    -postalCode: string
    +equals(other)
  }
  AggregateRoot <|-- Order
  Order *-- OrderItem
  Order *-- Address
  note for Order "集約ルート:\n外部からのアクセスポイント"
  note for OrderItem "エンティティ:\n集約内部でのみ操作"
  note for Address "値オブジェクト:\n不変"
Order集約のクラス図: AggregateRootを継承したOrderが集約ルートとなり、OrderItem(エンティティ)とAddress(値オブジェクト)を内包する
// 集約ルートの基底クラス: ドメインイベント管理機能付き
abstract class AggregateRoot<TId> extends Entity<TId> {
  private _domainEvents: IDomainEvent[] = [];

  protected addDomainEvent(event: IDomainEvent): void {
    this._domainEvents.push(event);
  }

  getDomainEvents(): readonly IDomainEvent[] {
    return [...this._domainEvents];
  }

  clearDomainEvents(): void {
    this._domainEvents = [];
  }
}

// Order集約ルート
class Order extends AggregateRoot<OrderId> {
  private _items: OrderItem[] = [];
  private _status: OrderStatus = 'draft';
  private _shippingAddress: Address;

  private constructor(
    id: OrderId,
    customerId: CustomerId,
    shippingAddress: Address
  ) {
    super(id);
    this._shippingAddress = shippingAddress;
  }

  static create(params: {
    id: OrderId;
    customerId: CustomerId;
    shippingAddress: Address;
  }): Order {
    const order = new Order(
      params.id,
      params.customerId,
      params.shippingAddress
    );
    // 生成時にドメインイベントを発行
    order.addDomainEvent(new OrderCreated({
      orderId: params.id,
      customerId: params.customerId,
      createdAt: new Date(),
    }));
    return order;
  }

  addItem(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
    if (this._status !== 'draft') {
      throw new Error('下書き状態でのみ商品を追加できます');
    }
    this._items.push(new OrderItem(productId, quantity, unitPrice));
  }

  confirm(): void {
    if (this._items.length === 0) {
      throw new Error('商品がない注文は確定できません');
    }
    this._status = 'confirmed';
    this.addDomainEvent(new OrderConfirmed({
      orderId: this.id,
      totalAmount: this.calculateTotal(),
      confirmedAt: new Date(),
    }));
  }

  private calculateTotal(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.subtotal),
      Money.create(0, 'JPY')
    );
  }
}

Domain Eventの実装

Domain Event(ドメインイベント)は、ドメインで起きた重要な出来事を表す不変のオブジェクトです。 TypeScriptではIDomainEventインターフェースを定義し、中央ディスパッチャで配信します。

// ドメインイベントのインターフェース
interface IDomainEvent {
  readonly eventId: string;
  readonly occurredOn: Date;
  readonly eventType: string;
}

// 具体的なドメインイベント
class OrderConfirmed implements IDomainEvent {
  readonly eventId: string;
  readonly occurredOn: Date;
  readonly eventType = 'OrderConfirmed';

  constructor(
    readonly payload: {
      orderId: OrderId;
      totalAmount: Money;
      confirmedAt: Date;
    }
  ) {
    this.eventId = crypto.randomUUID();
    this.occurredOn = new Date();
  }
}

// 中央ディスパッチャ
class DomainEventDispatcher {
  private handlers = new Map<string, Array<(event: IDomainEvent) => void>>();

  register<T extends IDomainEvent>(
    eventType: string,
    handler: (event: T) => void
  ): void {
    const existing = this.handlers.get(eventType) ?? [];
    existing.push(handler as (event: IDomainEvent) => void);
    this.handlers.set(eventType, existing);
  }

  dispatch(events: IDomainEvent[]): void {
    for (const event of events) {
      const handlers = this.handlers.get(event.eventType) ?? [];
      handlers.forEach(handler => handler(event));
    }
  }
}

Repositoryの実装

Repository(リポジトリ)はドメイン層にインターフェースを定義し、インフラストラクチャ層に具象実装を配置します。 依存性逆転の原則(DIP)により、ドメイン層がインフラストラクチャの詳細に依存しません。

// ドメイン層: リポジトリインターフェース
// src/domain/repositories/IOrderRepository.ts
interface IOrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  delete(id: OrderId): Promise<void>;
  nextId(): OrderId;
}

// インフラストラクチャ層: 具象実装
// src/infrastructure/repositories/PrismaOrderRepository.ts
class PrismaOrderRepository implements IOrderRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findById(id: OrderId): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    if (!record) return null;
    return OrderMapper.toDomain(record);
  }

  async save(order: Order): Promise<void> {
    const data = OrderMapper.toPersistence(order);
    await this.prisma.order.upsert({
      where: { id: order.id },
      create: data,
      update: data,
    });
    // 永続化成功後にドメインイベントをディスパッチ
    this.dispatcher.dispatch(order.getDomainEvents());
    order.clearDomainEvents();
  }

  nextId(): OrderId {
    return createOrderId(crypto.randomUUID());
  }
}

TypeScript型システムの高度な活用

Branded Types — IDの型安全性

前述のBranded Typesをさらに発展させ、unique symbolを使ったより厳密な実装を見てみましょう。

// unique symbolによるBranded Types(より厳密)
declare const UserIdBrand: unique symbol;
declare const OrderIdBrand: unique symbol;

type UserId = string & { readonly [UserIdBrand]: never };
type OrderId = string & { readonly [OrderIdBrand]: never };

// この方法ではasによるキャストも型安全
function createUserId(value: string): UserId {
  return value as UserId;
}

// UserIdとOrderIdは絶対に混同できない
function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }

const userId = createUserId('u-1');
const orderId = 'o-1' as OrderId;

getUser(userId);    // OK
getUser(orderId);   // コンパイルエラー
getUser('raw-str'); // コンパイルエラー(生のstringも不可)

Discriminated Unions — 状態遷移の型表現

TypeScriptのDiscriminated Unions(判別可能なユニオン型)は、 エンティティの状態遷移を型レベルで表現する強力なツールです。 不正な状態遷移をコンパイル時に防止できます。

// 注文の状態をDiscriminated Unionsで表現
type DraftOrder = {
  readonly status: 'draft';
  readonly id: OrderId;
  readonly items: OrderItem[];
  // 下書き状態でのみ商品追加が可能
  addItem(item: OrderItem): DraftOrder;
  confirm(): ConfirmedOrder;
};

type ConfirmedOrder = {
  readonly status: 'confirmed';
  readonly id: OrderId;
  readonly items: readonly OrderItem[];
  readonly confirmedAt: Date;
  // 確定後は商品追加不可 → addItemメソッドが存在しない
  ship(trackingNumber: string): ShippedOrder;
  cancel(reason: string): CancelledOrder;
};

type ShippedOrder = {
  readonly status: 'shipped';
  readonly id: OrderId;
  readonly items: readonly OrderItem[];
  readonly confirmedAt: Date;
  readonly shippedAt: Date;
  readonly trackingNumber: string;
  // 出荷後はキャンセル不可 → cancelメソッドが存在しない
  deliver(): DeliveredOrder;
};

type CancelledOrder = {
  readonly status: 'cancelled';
  readonly id: OrderId;
  readonly cancelledAt: Date;
  readonly reason: string;
  // 最終状態: 操作メソッドなし
};

type DeliveredOrder = {
  readonly status: 'delivered';
  readonly id: OrderId;
  readonly deliveredAt: Date;
  // 最終状態: 操作メソッドなし
};

// ユニオン型で全状態を表現
type Order = DraftOrder | ConfirmedOrder | ShippedOrder
           | CancelledOrder | DeliveredOrder;

// 型ガードで状態に応じた処理を安全に分岐
function processOrder(order: Order): void {
  switch (order.status) {
    case 'draft':
      order.addItem(newItem); // OK: DraftOrderにのみ存在
      break;
    case 'confirmed':
      order.ship('TRK-123'); // OK: ConfirmedOrderにのみ存在
      break;
    case 'shipped':
      // order.cancel('理由'); // コンパイルエラー!ShippedOrderにcancelはない
      order.deliver();
      break;
  }
}
stateDiagram-v2
  [*] --> Draft
  Draft --> Confirmed: confirm()
  Draft --> Cancelled: cancel()
  Confirmed --> Shipped: ship()
  Confirmed --> Cancelled: cancel()
  Shipped --> Delivered: deliver()
  Cancelled --> [*]
  Delivered --> [*]

  state Draft {
    addItem() 可能
  }
  state Confirmed {
    addItem() 不可
  }
  state Shipped {
    cancel() 不可
  }
Discriminated Unionsで表現した注文の状態遷移図: 各状態で利用可能な操作が型レベルで制限され、不正な遷移はコンパイルエラーになる

Mapped Types / Template Literal Types

TypeScriptのMapped TypesTemplate Literal Typesを組み合わせると、 ドメインイベントのハンドラ型を自動生成できます。

// イベント名からハンドラ名を自動生成
type EventHandlerName<T extends string> = `on${Capitalize<T>}`;

// イベントマップの定義
interface OrderEvents {
  orderCreated: { orderId: OrderId; createdAt: Date };
  orderConfirmed: { orderId: OrderId; totalAmount: Money };
  orderShipped: { orderId: OrderId; trackingNumber: string };
}

// Mapped Typesでハンドラ型を自動生成
type OrderEventHandlers = {
  [K in keyof OrderEvents as EventHandlerName<K & string>]:
    (event: OrderEvents[K]) => void;
};

// 生成される型:
// {
//   onOrderCreated: (event: { orderId: OrderId; createdAt: Date }) => void;
//   onOrderConfirmed: (event: { orderId: OrderId; totalAmount: Money }) => void;
//   onOrderShipped: (event: { orderId: OrderId; trackingNumber: string }) => void;
// }

// 実装時にハンドラの漏れがコンパイルエラーで検出される
const handlers: OrderEventHandlers = {
  onOrderCreated: (e) => console.log(`注文作成: ${e.orderId}`),
  onOrderConfirmed: (e) => console.log(`注文確定: ${e.totalAmount}`),
  onOrderShipped: (e) => console.log(`出荷: ${e.trackingNumber}`),
  // 1つでも欠けるとコンパイルエラー
};

関数型DDDアプローチ — Deciderパターン

近年注目されているDeciderパターンは、Jérémie Chassaingが提唱した関数型DDDのアプローチです。 集約を「コマンドを受け取り、現在の状態に基づいてイベントを返す純粋関数」として表現します。

// Deciderパターンの型定義
interface Decider<Command, Event, State> {
  decide: (command: Command, state: State) => Event[];
  evolve: (state: State, event: Event) => State;
  initialState: State;
}

// 注文集約をDeciderパターンで実装
type OrderCommand =
  | { type: 'CreateOrder'; orderId: string; customerId: string }
  | { type: 'AddItem'; productId: string; quantity: number; price: number }
  | { type: 'ConfirmOrder' }
  | { type: 'CancelOrder'; reason: string };

type OrderEvent =
  | { type: 'OrderCreated'; orderId: string; customerId: string }
  | { type: 'ItemAdded'; productId: string; quantity: number; price: number }
  | { type: 'OrderConfirmed'; totalAmount: number }
  | { type: 'OrderCancelled'; reason: string };

type OrderState = {
  exists: boolean;
  status: 'draft' | 'confirmed' | 'cancelled';
  items: Array<{ productId: string; quantity: number; price: number }>;
};

const orderDecider: Decider<OrderCommand, OrderEvent, OrderState> = {
  initialState: { exists: false, status: 'draft', items: [] },

  // 純粋関数: 副作用なし、テストが容易
  decide: (command, state) => {
    switch (command.type) {
      case 'CreateOrder':
        if (state.exists) return []; // 冪等性
        return [{ type: 'OrderCreated', orderId: command.orderId,
                  customerId: command.customerId }];

      case 'AddItem':
        if (state.status !== 'draft') return [];
        return [{ type: 'ItemAdded', productId: command.productId,
                  quantity: command.quantity, price: command.price }];

      case 'ConfirmOrder':
        if (state.items.length === 0) return [];
        const total = state.items.reduce((s, i) => s + i.price * i.quantity, 0);
        return [{ type: 'OrderConfirmed', totalAmount: total }];

      case 'CancelOrder':
        if (state.status === 'cancelled') return [];
        return [{ type: 'OrderCancelled', reason: command.reason }];
    }
  },

  // 状態遷移: イベントを状態に適用
  evolve: (state, event) => {
    switch (event.type) {
      case 'OrderCreated':
        return { ...state, exists: true, status: 'draft' };
      case 'ItemAdded':
        return { ...state, items: [...state.items,
          { productId: event.productId, quantity: event.quantity,
            price: event.price }] };
      case 'OrderConfirmed':
        return { ...state, status: 'confirmed' };
      case 'OrderCancelled':
        return { ...state, status: 'cancelled' };
    }
  },
};

// テスト: 純粋関数なのでモック不要
const state = orderDecider.initialState;
const events = orderDecider.decide(
  { type: 'CreateOrder', orderId: 'o-1', customerId: 'c-1' },
  state
);
console.log(events);
// [{ type: 'OrderCreated', orderId: 'o-1', customerId: 'c-1' }]

TypeScript DDDの課題

TypeScriptでDDDを実装する際には、言語の特性から生じるいくつかの課題があります。 これらを認識した上で、適切なトレードオフを選択することが重要です。

課題 詳細 対処法
構造的型付けの限界 TypeScriptは構造的型付けのため、同じ構造を持つ異なるValue Objectが互換になる Branded Typesで名目的型付けを模倣する
ボイラープレートの多さ Value Object・Entity・Aggregate毎にファクトリ、equals、バリデーションが必要 基底クラスやヘルパー関数で共通化する
ランタイムバリデーション 型はコンパイル時のみ有効で、APIからの入力はランタイムチェックが必須 zodやvalibotで入力バリデーション、ドメイン層はBranded Typesで保護
privateの不完全さ TypeScriptのprivateはコンパイル時のみ。ランタイムではアクセス可能 ES2022の#privateフィールドを使用する
イミュータビリティの限界 Object.freezeはshallow freeze。ネストしたオブジェクトは変更可能 Readonly型 + deep freezeユーティリティを併用

理解度チェック

問題 0 / 40%
Q1

Branded Typesの最大の利点は何ですか?

キーボード: 1〜4 で選択、Enter で回答