DDDで設計をしていると、何度でも同じところで手が止まる。複数の集約をまたぐロジックを、いったいどこに置けばよいのか。部屋の片付けで、どの棚にも収まらない物を手に立ち尽くすのと、まあ似たような心境である。
たとえば、ECサイトで注文を受け付ける処理を考えてみる。注文できるかどうかを決めるには、Customer と Product の両方を見てから Order を作る必要がある。さらに注文が確定すれば、在庫を減らし、クーポンを使用済みにし、支払いを作ることになる。
こうなると、途端に分からなくなる。どこまでをUseCaseに書くのか。どこからをDomain Serviceに切り出し、なにをDomain Eventに逃がすべきなのか。
紆余曲折の末、僕の中では次の整理に落ち着いている。流れはUseCase、判断はDomain ServiceないしPolicy、状態の一貫性はAggregate、副作用はDomain Eventだ。以下、そこに至るまでの考えを順に述べていく。
整合性境界としてのAggregate
そもそもDDDにおけるAggregateは、単なるオブジェクトの寄せ集めではない。同期的に整合性を守るための境界である。境界の内側では、その集約が守るべき不変条件が常に成り立っていなければならない。
Order 集約で言えば、キャンセル済みの注文は再びキャンセルできない。注文数量は1以上でなければならない。ステータスは決められた順序でしか遷移しない。どれも Order の状態だけで判断できるので、Aggregateの責務として素直に収まる。
ところが、次のようなルールになると話が変わる。利用停止中の会員は注文できない。販売終了した商品は注文できない。会員限定の商品は、一般会員には売れない。これらは Order をいくら眺めても判断できず、Customer や Product を見にいく必要がある。問題は、こういうロジックの置き場所なのだ。
UseCaseは流れを担う
UseCase、あるいはApplication Serviceの仕事は、アプリケーションとしての処理の流れを組み立てることだ。注文作成なら、まず入力を受け取り、Customer と Product を取得する。続いてトランザクションを開始し、注文できるかを確かめ、Order を生成して保存する。必要なら、通知やイベント発行もここで行う。
UseCaseが複数のrepositoryに依存していること自体は、なにも悪い設計ではない。複数の集約を取得して順番に処理を進めるのは、Application Serviceの真っ当な責務である。厄介なのは、業務上の判断がUseCaseの中に直接書かれはじめたときだ。
if (customer.isSuspended()) {
throw new Error("利用停止中の会員は注文できません");
}
if (!product.isOnSale()) {
throw new Error("この商品は販売終了しています");
}
if (product.isMembersOnly() && !customer.isPremiumMember()) {
throw new Error("この商品は会員限定です");
}
むろん、最初はこれで動く。しかし条件が増えるにつれ、UseCaseはたちまち if の物置と化す。業務ルールから名前が失われ、「注文できるかどうか」というドメイン上の判断が、手続きの中に埋もれてしまうのだ。半年後にこのコードを開いた誰かは、ラベルのない段ボールを一箱ずつ開けるように、どの if が業務ルールでどれが単なる防御なのかを確かめる羽目になる。
判断に名前を与えるDomain Service
そこで、複数の集約を見ないと下せない判断は、Domain Serviceに切り出す。いわば、判断専用の棚を一つ設えるわけだ。注文可否の判断なら、OrderPlacementPolicy のような名前が付けられる。
export class OrderPlacementPolicy {
assertCanPlace(customer: Customer, product: Product): void {
if (customer.isSuspended()) {
throw new Error("利用停止中の会員は注文できません");
}
if (!product.isOnSale()) {
throw new Error("この商品は販売終了しています");
}
if (product.isMembersOnly() && !customer.isPremiumMember()) {
throw new Error("この商品は会員限定です");
}
}
}
UseCase側に残るのは、取得と保存、そして判断の委譲だけだ。
export class PlaceOrderUseCase {
constructor(
private readonly customerRepository: CustomerRepository,
private readonly productRepository: ProductRepository,
private readonly orderRepository: OrderRepository,
private readonly orderPlacementPolicy: OrderPlacementPolicy,
private readonly tx: TransactionManager,
) {}
async execute(input: PlaceOrderInput): Promise<OrderId> {
return this.tx.run(async () => {
const customer = await this.customerRepository.findById(input.customerId);
const product = await this.productRepository.findById(input.productId);
this.orderPlacementPolicy.assertCanPlace(customer, product);
const order = Order.create({
customerId: customer.id,
productId: product.id,
quantity: input.quantity,
});
await this.orderRepository.save(order);
return order.id;
});
}
}
こうすると、役割がきれいに分かれる。UseCaseは取得・順序・トランザクション・保存を受け持つ。OrderPlacementPolicy は注文できるかどうかの業務判断を、Order は注文そのものの生成と不変条件を受け持つ。流れは読みやすいまま保たれ、注文可否のルールには OrderPlacementPolicy という名前が与えられる。棚にラベルが付くわけで、この効き目は見た目以上に大きい。
Policyという呼び名
DDDの用語に従えばDomain Serviceと呼ぶべきものでも、実務ではPolicy と名付けた方が通りがよいことが多い。OrderPlacementPolicy、PointRewardPolicy、DiscountPolicy、CancellationPolicy といった具合だ。Policyと名乗るだけで、「これは手順ではなく判断のまとまりである」と伝わる。assertCan... や can... のようなメソッド名とも、すこぶる相性がいい。
orderPlacementPolicy.assertCanPlace(customer, product);
cancellationPolicy.assertCanCancel(order, now);
discountPolicy.calculateFor(order, customer);
UseCaseの中に this.orderPlacementPolicy.assertCanPlace(customer, product) という一行があるとする。読み手は、そこで注文可否を判断しているとすぐに分かる。if を三つ並べるより、ドメインの意図がずっと前に出てくるのだ。
条件をオブジェクトにするSpecification
Policyと似たものにSpecificationがある。ざっくり言えば、「ある候補が条件を満たすかどうか」をオブジェクトとして表すパターンだ。
export interface Specification<T> {
isSatisfiedBy(candidate: T): boolean;
}
クーポンがその商品に使えるかどうかであれば、次のように書ける。
export class CouponApplicableToProductSpecification implements Specification<{
coupon: Coupon;
product: Product;
}> {
isSatisfiedBy(candidate: { coupon: Coupon; product: Product }): boolean {
return candidate.coupon.appliesTo(candidate.product.categoryId);
}
}
Specificationの本領は、条件を組み合わせたいときに発揮される。会員が利用停止中でないこと。商品が販売中であること。会員ランクが購入条件を満たしていること。こうした条件を個別のSpecificationに分けておけば、場面に応じて自由に組み替えられる。
もっとも、実務では一つ注意がいる。Specificationには、repositoryの検索条件を表すQuery Specificationと、業務ルールを表すBusiness Rule Specificationの二種類がある。前者はDBからなにを引くかを、後者はドメイン上の条件を満たすかを表す。同じ名前でも、目的はまるで違うのだ。この二つを混ぜると途端に見通しが悪くなるので、どちらの意味で使っているのかは明確にしておきたい。
個人的には、まずPolicy として判断をまとめておくのが扱いやすいと感じている。条件が増えて再利用したくなった時点で、内部をSpecificationに分ければいい。最初からSpecificationを組み合わせる設計にすると、いささか大仰になりがちだ。
複数集約を同時に更新したくなるとき
ここまでは、複数の集約を参照しつつ、主に一つの集約を作ったり更新したりする話だった。ところが実務では、複数の集約をまとめて更新したくなる場面が必ずやってくる。冒頭で触れた注文確定が、まさにそれだ。Order の作成と同時に在庫を減らし、クーポンを使用済みにし、支払いを作りたくなる。
DDDの原則に従うなら、一つのトランザクションで複数の集約を更新するのは避けたい。Aggregateは整合性境界なのだから、境界をまたぐ更新は結果整合性やDomain Eventで扱うのが基本線になる。
かといって、これを絶対の掟として扱うと、実務では身動きが取れなくなる。たとえばモノリスやモジュラモノリスで、すべてが同じDBにあるとする。一つのユーザー操作として、失敗したら全部巻き戻したい。であれば、UseCaseで単一のACIDトランザクションにまとめる判断は十分にありうる。分散システムでないなら、その方がはるかに単純なことも多い。大事なのは、無自覚にそうするのではなく、トレードオフとして選ぶことだ。
逆に、Domain EventやSagaを検討すべき場面もある。別のサービスや別のDBにまたがる場合。多少遅れて反映されても構わない場合。通知や分析、請求連携のように、副作用に近い場合。とはいえ、Sagaは魔法の杖ではない。補償処理、冪等性、リトライ、監視、デバッグと、背負い込むものが一気に増える。本当に分散した整合性が必要になったときだけ持ち込むべき代物である。
遅延してよい副作用はDomain Eventへ
Domain Eventは、ドメイン上意味のある出来事を表す。OrderPlaced や PaymentCompleted がその典型だ。注文が確定したので確認メールを送る、在庫の引当を始める。支払いが完了したので請求履歴を作る。こうした、主処理のあとに起きる副作用を扱うのに向いている。
こうした副作用は、必ずしも集約のメソッドの中で直接実行する必要はない。むしろ集約はイベントを記録するだけにとどめ、UseCaseやUnit of Workがコミットの前後で処理する形の方が扱いやすい。
const order = Order.create({
customerId: customer.id,
productId: product.id,
quantity: input.quantity,
});
order.recordEvent(new OrderPlaced(order.id));
通知や他の集約への反映はハンドラ側で行う。
export class SendOrderConfirmationEmailHandler {
async handle(event: OrderPlaced): Promise<void> {
await this.mailer.sendOrderConfirmation(event.orderId);
}
}
同じプロセス内で同期的に処理するか、メッセージキューに流して非同期に処理するかは要件次第だ。なお、分散システムに送り出すなら、Outbox Patternと冪等性もセットで考える必要がある。どちらにせよ、副作用を集約の外へ出すという方針は変わらない。
集約境界を疑うべきとき
毎回のように複数の集約を強い整合性で同時に更新したくなるなら、それは集約境界を見直せというサインかもしれない。片付けで言えば、物の置き場所ではなく、棚の仕切り自体がおかしい可能性である。
たとえばOrder と OrderLine を別々の集約にしているとする。それなのに注文を更新するたびに必ず両方をいじっているなら、同じ集約にした方が自然だろう。一方で Order と Payment はライフサイクルも責務も異なる。別の集約として、イベントでつなぐ方がしっくりくる。
判断の軸はいくつかある。同じ不変条件を守る必要があるか。同じライフサイクルで変化するか。毎回一緒に読み書きされるか。片方だけで独立して存在できるか。遅れて反映されても許容できるか。
同じ不変条件を守るなら、同じ集約に寄せる。ライフサイクルが違い、遅延も許せるなら、別の集約としてDomain Eventでつなぐ。この見直しをせずにUseCaseやDomain Serviceだけで無理に吸収しようとすると、設計は日に日に苦しくなっていく。
クーポン付きの注文で考える
もう少し判断材料が多い例として、クーポンを使って注文する場合を考えてみる。登場するのはCustomer、Product、Coupon、Order の四つだ。注文できるかどうかを決めるには、確かめることが五つある。会員が注文できる状態か、商品が販売中か、クーポンが有効期限内か、その商品に使えるか、会員がすでに使っていないか、である。
この場合も、UseCaseは各集約を取得し、OrderPlacementPolicy に判断を委ねるだけである。
export class PlaceOrderWithCouponUseCase {
constructor(
private readonly customerRepository: CustomerRepository,
private readonly productRepository: ProductRepository,
private readonly couponRepository: CouponRepository,
private readonly orderRepository: OrderRepository,
private readonly orderPlacementPolicy: OrderPlacementPolicy,
private readonly tx: TransactionManager,
) {}
async execute(input: PlaceOrderWithCouponInput): Promise<OrderId> {
return this.tx.run(async () => {
const customer = await this.customerRepository.findById(input.customerId);
const product = await this.productRepository.findById(input.productId);
const coupon = await this.couponRepository.findByCode(input.couponCode);
this.orderPlacementPolicy.assertCanPlaceWithCoupon({
customer,
product,
coupon,
});
const order = Order.create({
customerId: customer.id,
productId: product.id,
quantity: input.quantity,
couponId: coupon.id,
});
await this.orderRepository.save(order);
return order.id;
});
}
}
OrderPlacementPolicy は、総合的な判断を下す窓口に徹する。その内側では、三つの部品がそれぞれの判定を受け持つ。会員状態を見る CustomerEligibility、販売状況を見る ProductAvailability、クーポンの適用条件を見る CouponEligibility だ。Order は相変わらず、自分自身の不変条件と状態遷移だけを気にしていればよい。
もし Coupon の使用済み化や在庫の引当まで同時に済ませたくなったら、一度立ち止まる。それは本当に、同一トランザクションで必要なのか。遅れてよいなら、OrderPlaced イベントに逃がせばよい。毎回必ず強い整合性が必要なら、集約境界やトランザクションの設計そのものを見直すことになる。
置き場所の決め方
最後に、置き場所の決め方を順に並べておく。まず、そのルールが一つの集約の状態に閉じているなら、Aggregateが受け持つ。次に、複数の集約を見ないと下せない業務判断なら、Domain Service、Policy、Specificationのいずれかへ。取得や保存、トランザクション、外部連携、処理の順序であれば、UseCaseの出番だ。
他の集約への副作用で、遅延してもよいものはDomain Event Handlerに任せる。そして、毎回複数の集約を強い整合性で同時に更新したくなるなら、集約境界を見直す。あるいは、単一トランザクションを意識的に選ぶ。
すべてをUseCaseに書けば、手続き的になりすぎる。かといって、すべてをDomain Serviceに寄せれば、今度はそれが巨大な神クラスに育つ。物置を片付けたつもりが、隣にもう一つ物置を建てただけ、という顛末である。
結局のところ見るべきは、そのコードがなにをしているのか、だ。処理の流れなのか、業務判断なのか、集約自身の不変条件なのか、遅延できる副作用なのか。そこさえ見極められれば、置き場所はおのずと決まる。
以上、複数の集約をまたぐロジックの置き場所について、現時点での考えをまとめた。DDDの原則は大事だが、原則を守ること自体が目的ではない。業務ルールがどこにあり、どの整合性を守るべきで、どこまでを同期的に扱うのか。どの棚になにを置いたのかを自分の言葉で説明できる設計こそが、よい設計なのだ。