Transaction ScriptとDomain Modelの使い分け

日頃開発していると、設計パターンの選択に悩む場面が多々あります。ECサイトを例に取りましょう。商品の登録はシンプルなCRUDですが、注文処理や在庫管理には複雑なビジネスルールが絡みます。同じシステム内でTransaction ScriptとDomain Modelをどう使い分けるべきでしょうか。

ECサイトの構成要素

典型的なECサイトは、以下のような機能を持っています。

  1. 商品の登録・管理
  2. カート機能
  3. 注文処理
  4. 在庫管理
  5. 決済処理
  6. 配送管理

これらの機能は、複雑さのレベルが大きく異なります。

機能ごとの複雑さを評価する

商品の登録(低複雑度)

async function registerProduct(input: RegisterProductInput): Promise<Product> {
  const product = await prisma.product.create({
    data: {
      name: input.name,
      description: input.description,
      price: input.price,
      categoryId: input.categoryId,
      imageUrl: input.imageUrl,
    }
  });

  await auditLog.record('product_registered', product.id);
  return product;
}

これは典型的なCRUD操作です。入力を受け取り、バリデーションし、保存する。Transaction Scriptで十分に表現できます。

注文処理(高複雑度)

一方、注文処理には多くのビジネスルールがあります。

  • 在庫が十分にあるか確認する
  • 顧客の与信枠を確認する
  • 会員ランクに応じた割引を適用する
  • 送料を計算する(重量、配送先、配送方法)
  • クーポンの適用条件を確認する
  • ポイントの付与と利用を処理する

これらのルールをTransaction Scriptで書くと、条件分岐の嵐になります。

// Transaction Scriptで書くと...
async function placeOrder(cartId: string, customerId: string, shippingAddress: Address) {
  const cart = await prisma.cart.findUnique({
    where: { id: cartId },
    include: { items: { include: { product: true } } }
  });

  const customer = await prisma.customer.findUnique({ where: { id: customerId } });

  // 在庫チェック
  for (const item of cart.items) {
    const inventory = await prisma.inventory.findUnique({
      where: { productId: item.productId }
    });
    if (inventory.quantity < item.quantity) {
      throw new OutOfStockError(item.productId);
    }
  }

  // 与信チェック
  const totalAmount = calculateTotal(cart);
  if (customer.creditLimit < totalAmount) {
    throw new CreditLimitExceededError();
  }

  // 会員割引
  let discount = 0;
  if (customer.rank === 'GOLD') {
    discount = totalAmount * 0.1;
  } else if (customer.rank === 'SILVER') {
    discount = totalAmount * 0.05;
  }

  // 送料計算
  const totalWeight = cart.items.reduce((sum, item) => sum + item.product.weight * item.quantity, 0);
  const shippingFee = calculateShippingFee(totalWeight, shippingAddress);

  // クーポン適用(条件が複雑)
  // ...

  // 注文作成
  const order = await prisma.order.create({
    data: {
      customerId,
      totalAmount: totalAmount - discount + shippingFee,
      // ...
    }
  });

  // 在庫減算
  // ...

  // ポイント付与
  // ...
}

このコードには問題があります。ビジネスルールが手続きの中に埋もれており、ルールの変更や追加が困難です。

Domain Modelで表現する

同じ注文ロジックをDomain Modelで書き直してみましょう。

class Order {
  private id: OrderId;
  private customer: Customer;
  private items: OrderItem[];
  private shippingAddress: Address;
  private status: OrderStatus;

  static createFromCart(cart: Cart, customer: Customer, shippingAddress: Address): OrderResult {
    if (cart.isEmpty()) {
      return OrderResult.emptyCart();
    }

    const stockCheck = cart.checkStock();
    if (stockCheck.hasOutOfStock()) {
      return OrderResult.outOfStock(stockCheck.outOfStockItems);
    }

    const order = new Order(customer, cart.toOrderItems(), shippingAddress);

    if (!customer.canAfford(order.totalBeforeDiscount)) {
      return OrderResult.creditLimitExceeded();
    }

    order.applyMemberDiscount(customer.rank);
    order.calculateShipping();

    return OrderResult.success(order);
  }

  private applyMemberDiscount(rank: MemberRank): void {
    const discountRate = rank.discountRate;
    this.discount = this.subtotal.multiply(discountRate);
  }

  private calculateShipping(): void {
    this.shippingFee = ShippingCalculator.calculate(
      this.totalWeight,
      this.shippingAddress
    );
  }

  get totalAmount(): Money {
    return this.subtotal.subtract(this.discount).add(this.shippingFee);
  }
}

ビジネスルールがドメインオブジェクトにカプセル化されています。applyMemberDiscountcalculateShippingといったメソッド名は、ビジネスの意図を明確に表現しています。

MemberRankの設計

会員ランクによる割引は、それ自体が独立した概念として設計できます。

class MemberRank {
  private constructor(
    private readonly name: string,
    private readonly _discountRate: number,
    private readonly _pointRate: number
  ) {}

  static readonly GOLD = new MemberRank('Gold', 0.10, 0.05);
  static readonly SILVER = new MemberRank('Silver', 0.05, 0.03);
  static readonly BRONZE = new MemberRank('Bronze', 0.02, 0.01);
  static readonly REGULAR = new MemberRank('Regular', 0, 0.01);

  get discountRate(): number {
    return this._discountRate;
  }

  get pointRate(): number {
    return this._pointRate;
  }

  calculateDiscount(amount: Money): Money {
    return amount.multiply(this._discountRate);
  }

  calculatePoints(amount: Money): number {
    return Math.floor(amount.value * this._pointRate);
  }
}

このように設計すると、ランクごとの特典が変更になっても、影響範囲が限定されます。

同じシステム内での使い分け

一つのシステム内で、Transaction ScriptとDomain Modelを共存させることは可能です。むしろ推奨されます。

使い分けの基準

機能推奨パターン理由
商品の登録Transaction ScriptシンプルなCRUD
商品画像のアップロードTransaction Scriptデータの受け渡しのみ
カートへの追加状況による複雑なプロモーションがあればDomain Model
注文処理Domain Model複雑なビジネスルール
在庫管理Domain Model引当や予約など複雑なロジック
レポート出力Transaction Script読み取り専用の集計処理

境界をどこに引くか

重要なのは、Domain Modelを使う領域とTransaction Scriptを使う領域の境界を明確にすることです。

Transaction ScriptDomain Model
商品CRUD注文処理
画像アップロード在庫管理
お気に入り登録割引・クーポン
レポート出力配送料計算
通知送信ポイント管理

実装上の注意点

Transaction ScriptからDomain Modelを呼ぶ

Transaction Script側からDomain Modelを利用することは問題ありません。

// Transaction Script
async function handleCheckout(cartId: string, customerId: string, addressId: string) {
  // リポジトリからドメインオブジェクトを取得
  const cart = await cartRepository.findById(cartId);
  const customer = await customerRepository.findById(customerId);
  const address = await addressRepository.findById(addressId);

  // ドメインオブジェクトにビジネスロジックを委譲
  const result = Order.createFromCart(cart, customer, address);

  if (result.isFailure()) {
    return { success: false, error: result.error };
  }

  // 保存
  await orderRepository.save(result.order);
  await inventoryService.decreaseStock(result.order.items);

  // 通知などの副作用(これはTransaction Scriptで十分)
  await notificationService.sendOrderConfirmation(result.order);

  return { success: true, orderId: result.order.id };
}

UseCaseやApplication Service層はTransaction Scriptのスタイルで書きつつ、複雑なビジネスロジックはDomain Modelに委譲する構成です。

移行戦略

既存のTransaction ScriptをDomain Modelに移行する場合、段階的に進めることをお勧めします。

  1. 最も複雑なビジネスルールを持つ部分を特定する
  2. その部分だけをドメインオブジェクトに抽出する
  3. 既存のTransaction Scriptからそのドメインオブジェクトを呼び出す
  4. テストを書いてリグレッションを防ぐ
  5. 徐々に範囲を広げる

すべてをDomain Modelに書き換える必要はありません。コストに見合う部分だけを移行すればよいのです。

まとめ

ECサイトの設計において、Transaction ScriptとDomain Modelは排他的な選択ではありません。機能の複雑さに応じて適切なパターンを選び、共存させることが現実的なアプローチです。

商品登録のようなシンプルなCRUDにDomain Modelを適用するのは過剰ですし、注文処理のような複雑なビジネスルールをTransaction Scriptで書くのは保守性の問題を引き起こします。

大切なのは、「このロジックはどの程度複雑か」「将来的に変更が多そうか」「ルールが複数箇所で共有されるか」といった観点で判断することです。

参考文献

  • Martin Fowler『Patterns of Enterprise Application Architecture』(2002)
  • Eric Evans『Domain-Driven Design』(2003)
  • Vaughn Vernon『Implementing Domain-Driven Design』(2013)