日頃開発していると、設計パターンの選択に悩む場面が多々あります。ECサイトを例に取りましょう。商品の登録はシンプルなCRUDですが、注文処理や在庫管理には複雑なビジネスルールが絡みます。同じシステム内でTransaction ScriptとDomain Modelをどう使い分けるべきでしょうか。
ECサイトの構成要素
典型的なECサイトは、以下のような機能を持っています。
- 商品の登録・管理
- カート機能
- 注文処理
- 在庫管理
- 決済処理
- 配送管理
これらの機能は、複雑さのレベルが大きく異なります。
機能ごとの複雑さを評価する
商品の登録(低複雑度)
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);
}
}
ビジネスルールがドメインオブジェクトにカプセル化されています。applyMemberDiscountやcalculateShippingといったメソッド名は、ビジネスの意図を明確に表現しています。
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 Script | Domain 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に移行する場合、段階的に進めることをお勧めします。
- 最も複雑なビジネスルールを持つ部分を特定する
- その部分だけをドメインオブジェクトに抽出する
- 既存のTransaction Scriptからそのドメインオブジェクトを呼び出す
- テストを書いてリグレッションを防ぐ
- 徐々に範囲を広げる
すべてを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)