ykitaa.dev
18

業務ルールをどこに置くか — Go の DDD サンプルで整理する集約・コンテキスト・ドメインサービス

DDD の入門書を読むと、エンティティ、値オブジェクト、集約、リポジトリ、ドメインサービス……と用語が並びます。ひとつずつの説明は分かるのに、いざ自分のコードに落とそうとすると「で、この処理はどこに書けばいいんだ」で止まる。私はそこで止まりました。

そこで EC サイトの「商品カタログ」と「注文」を題材に、Go + gin で小さな API を作ってみました。作り終えて分かったのは、並んでいた用語はどれも「業務ルールをどこに置くか」という一つの問いへの答えだった、ということです。置き場所の候補が複数あるから語彙が必要で、語彙があるから「ここではない、あそこだ」と議論できる。

この記事では、その一本の軸に沿って、サンプルのコードを引きながら主要な概念を整理します。扱うのは値オブジェクトとエンティティ、集約、境界づけられたコンテキスト、ドメインサービス、そして「別の集約の情報が必要になったとき」の扱い方です。

題材にするモデル

具体的な話をするために、まずどんな業務を想定したかを決めておきます。サンプルが前提にしているルールは次のとおりです。

  • 商品には名前と価格があり、価格改定と販売終了ができる。販売終了した商品は価格を変えられない
  • 会員はメールアドレスを持ち、これは会員間で重複してはいけない
  • 会員にはランク(regular / silver / gold)があり、gold は 10%、silver は 5% 引きで注文できる
  • 注文は明細 10 件まで、1 明細の数量は 1〜99
  • 注文明細には注文した時点の商品名と単価を残す。あとでカタログ側の価格が変わっても、確定済みの注文の金額は動かない
  • 退会した会員は注文できない。逆に、未完了(発送前)の注文が残っている会員は退会できない
  • 注文は確定 → 発送 / キャンセルと遷移する。確定前の注文は発送できないし、発送済みの注文はキャンセルできない

どれも「業務の言葉で語れるルール」です。この記事は、これらをどこに書けば散らからないかという話だと思って読んでください。

前提:オニオンアーキテクチャという「器」

ルールの置き場所を議論するには、まず置き場所の候補、つまり層が必要です。サンプルはオニオンアーキテクチャを採っています。アプリを同心円状の層に分け、依存を必ず外側から内側へ向ける構成です。

  presentation ----> usecase ----> domain
                        ^             ^
                        |             |
  infrastructure -------+-------------+

矢印は「import している」向きです。presentation(gin のハンドラ)は usecase(操作の手順)を呼び、usecasedomain(業務ルール)を使う。infrastructure(データストアや外部連携)は、その両方が定義したインターフェースを実装します。

注目してほしいのは、中心の domain から出ていく矢印が 1 本もないことです。domain は Go の標準ライブラリと自分自身しか知りません。玉ねぎに喩えられるのはこの入れ子構造からで、外側の皮を剥がしても芯は変わらない、という含意があります。

なぜ内側に向けるのか。業務ルールは、DB や Web フレームワークよりも寿命が長いからです。データストアを差し替えても、gin を別のフレームワークに替えても、「gold 会員は 10% 引き」というルールそのものは変わりません。変わりにくいものを中心に置き、変わりやすいものを外側に置けば、外側の変更が中心に波及しにくくなります。副次的な効果として、ドメイン層は DB も HTTP もなしに単体テストできます。

もっとも、「外側を替えても内側は一切変わらない」と言い切れるほど綺麗にはいきません。この記事の最後で触れるように、楽観的排他制御のバージョン番号のように、永続化の都合が集約に染み出してくることはあります。依存を内側に向けるのは、外側の関心をゼロにする魔法ではなく、染み出しを一箇所に限定して自覚的に扱うための構えです

ここで問題になるのが、リポジトリです。ユースケースは注文を保存したいけれど、保存の実装はインフラ層にある。内側が外側を呼ぶことになってしまいます。解決策は、インターフェースを内側に置くことです。

internal/ordering/domain/customer/repository.go
// Repository は Customer 集約の永続化インターフェース。実体はインフラ層。
type Repository interface {
	Save(ctx context.Context, c *Customer) error
	// FindByID / FindByEmail は見つからない場合 (nil, nil) を返す。
	FindByID(ctx context.Context, id CustomerID) (*Customer, error)
	FindByEmail(ctx context.Context, email Email) (*Customer, error)
}

「こういう機能がほしい」とドメイン層が宣言し、インフラ層がそれに合わせて実装する。依存の向きが逆転するので**依存性逆転の原則(DIP)**と呼ばれます。実装とインターフェースを結びつけるのは cmd/server/main.go ひとつだけで、ここが Composition Root になります。

器はこれで決まりました。ここからは、器の中心に何を、どういう形で置くかという話に入ります。

同一性から決まる二種類の型:値オブジェクトとエンティティ

ドメイン層に最初に現れるのは、業務に出てくる「もの」を表す型です。これらは二つに分かれます。分ける基準は、機能でも大きさでもなく、同一性をどう判定するかです。

会員は名前を変えても同じ会員です。ID が同じなら同じもの。こういう、ID で見分けるものがエンティティです。一方、「1,000 円」と「1,000 円」は区別する必要がありません。値そのもので見分けるものが値オブジェクトで、ID を持たず、一度作ったら変更しません。

この区別が効いてくるのは、値オブジェクトを積極的に作りはじめたときです。サンプルでは数量を int ではなく Quantity 型にしています。

internal/ordering/domain/order/value_objects.go
// Quantity は数量(値オブジェクト)。1〜99。
type Quantity struct{ value int }
 
const maxQuantity = 99
 
func NewQuantity(v int) (Quantity, error) {
	if v < 1 || v > maxQuantity {
		return Quantity{}, domainerr.Validation("quantity must be between 1 and %d", maxQuantity)
	}
	return Quantity{value: v}, nil
}
 
func (q Quantity) Int() int { return q.value }

たった数行ですが、得られるものが三つあります。

ひとつめは、検証の場所が一箇所に決まること。NewQuantity を通って作られた値は必ず 1〜99 なので、受け取った側は「0 だったらどうしよう」を考えずに済みます。int で持ち回していると、ハンドラでも、ユースケースでも、集約の中でも、念のためのチェックが増えていきます。

ただし Go では「コンストラクタを通らずには作れない」とまでは言えません。var q Quantity と書けばゼロ値、つまり 0 という不正な数量が手に入ってしまいます。構造体のフィールドとして宣言した場合も同じです。

var q order.Quantity
q.Int() // => 0(NewQuantity なら弾かれる値)

これは Go の値オブジェクトにつきまとう制約で、言語機能だけでは塞げません。実務では、ゼロ値を受け取りうる境界(リポジトリでの復元、外部入力のデコード)で Validate() のような検証を明示的に呼ぶか、ゼロ値が意味を持たない型はポインタで扱う、といった追加の約束が要ります。値オブジェクトが保証するのは「正規の入口を通った値の正しさ」であって、「不正な値が存在しないこと」ではない — この線引きを押さえておかないと、検証を省略しすぎて足をすくわれます。

ふたつめは、取り違えがコンパイルエラーになること。たとえば明細の金額を計算する関数を素朴に書くと、こうなります。

func lineTotal(quantity int, unitPrice int) int {
	return quantity * unitPrice
}
 
lineTotal(1200, 2) // 単価と数量が逆。それでもコンパイルは通る

1200 が数量、2 が単価として扱われます。掛け算だけなら結果は変わりませんが、ここに「数量は 99 まで」の検証や送料の判定が挟まっていれば、エラーも出さずに違う答えを返します。どちらも int である以上、コンパイラには区別する手がかりがありません。

値オブジェクトにすると、この取り違えが型の不一致になります。サンプルには「金額に数量を掛ける」Money.MultiplyBy(Quantity) はありますが、その逆はありません。試しに逆向きに書いてみると、こうなります。

qty.MultiplyBy undefined (type order.Quantity has no field or method MultiplyBy)

意味の通る向きでしか書けず、間違えれば実行する前に止まります。intstring のような汎用の型を業務的な意味を持つ値に使い続けることは primitive obsession と呼ばれますが、値オブジェクトはその解毒剤でもあります。サンプルで CustomerIDOrderID を別々の型にしているのも同じ理由です。

みっつめは、関連する計算の置き場所ができることです。金額の加算や割引の計算は、どこかのユースケースに書くのではなく Money に置けます。

internal/ordering/domain/order/value_objects.go
// Money は金額(値オブジェクト)。不変で、演算は常に新しい値を返す。
type Money struct{ amount int64 }
 
func (m Money) Add(o Money) Money           { return Money{amount: m.amount + o.amount} }
func (m Money) MultiplyBy(q Quantity) Money { return Money{amount: m.amount * int64(q.value)} }
 
// PercentOf は m の rate% を切り捨てで返す。
func (m Money) PercentOf(rate int64) Money { return Money{amount: m.amount * rate / 100} }

Add が新しい Money を返している点が、値オブジェクトらしいところです。元の値は書き換えません。「1,000 円」という値そのものが変化することはなく、別の金額が生まれるだけだからです。

値オブジェクトは「値の正しさ」だけでなく、表記の揺れを吸収する場所にもなります。メールアドレスは前後の空白を落とし、小文字に正規化してから保持しています。

internal/ordering/domain/customer/value_objects.go
func NewEmail(v string) (Email, error) {
	v = strings.ToLower(strings.TrimSpace(v))
	if len(v) > 254 || !emailPattern.MatchString(v) {
		return Email{}, domainerr.Validation("invalid email: %q", v)
	}
	return Email{value: v}, nil
}

" Taro@Example.COM ""taro@example.com" が別人として登録されてしまう事故は、この一箇所で防げます。重複チェックをする側は、正規化について何も知らなくて済みます。

対してエンティティは、同じ ID のまま状態が変わっていくものなので、その場で書き換えます。

internal/ordering/domain/order/order.go
func (o *Order) Ship() error {
	if o.status != StatusPlaced {
		return domainerr.Precondition("only placed orders can be shipped (current: %s)", o.status)
	}
	o.status = StatusShipped
	return nil
}

ここで大事なのは、フィールドが非公開で、変更手段がメソッドしかないことです。SetStatus("shipped") のような setter を公開してしまうと、「発送できるのは確定済みの注文だけ」というルールを呼び出し側が守らなければならなくなります。ルールを持つ側が状態を持つ。この形を崩さないのが、エンティティを書くときの勘所です。

もう一点、実装上の作法として、検証を全部済ませてから書き換える順番にしています。途中で失敗して、半分だけ状態が変わったオブジェクトが残ると、その後の挙動が読めなくなるためです。

整合性の単位:集約と集約ルート

値オブジェクトとエンティティが揃うと、次の疑問が出てきます。「明細は 10 件まで」というルールは、誰が知っているべきなのか。

明細 1 件は、自分が何件目なのかを知りません。判断できるのは、明細の全体を持っている注文の側だけです。このように、一緒に整合性を守らなければならないオブジェクトのまとまりを集約と呼び、その代表を集約ルートと呼びます。サンプルの Order 集約は、ルートの Order と、内部エンティティの OrderLine で構成されています。

外部からの操作は必ずルートを通します。明細の追加はこうなります。

internal/ordering/domain/order/order.go
const maxLines = 10
 
// AddLine は明細を追加する。同じ商品は数量を合算する。
func (o *Order) AddLine(p OrderableProduct, q Quantity) error {
	if o.status != StatusDraft {
		return domainerr.Precondition("cannot modify an order that is already %s", o.status)
	}
	if !p.IsOnSale() {
		return domainerr.Precondition("product %s is not on sale", p.ID())
	}
	for _, l := range o.lines {
		if l.productID == p.ID() {
			return l.addQuantity(q)
		}
	}
	if len(o.lines) >= maxLines {
		return domainerr.Validation("an order can have at most %d lines", maxLines)
	}
	o.lines = append(o.lines, &OrderLine{
		productID:   p.ID(),
		productName: p.Name(),
		unitPrice:   p.UnitPrice(),
		quantity:    q,
	})
	return nil
}

この 1 メソッドに、「確定後は変更できない」「販売終了した商品は追加できない」「同じ商品は合算する」「明細は 10 件まで」という 4 つのルールが集まっています。逆に言えば、明細まわりのルールを知りたければここを読めばいいという状態になっています。

まとまりを維持するために、内部の OrderLine は外から触れないようにしてあります。数量を足すメソッドは非公開の addQuantity で、Order からしか呼べません。取得メソッドもスライスのコピーを返し、外から要素を差し替えられないようにしています。

internal/ordering/domain/order/order.go
// Lines は明細のコピーを返す(外部からスライスを書き換えられないように)。
func (o *Order) Lines() []*OrderLine {
	out := make([]*OrderLine, len(o.lines))
	copy(out, o.lines)
	return out
}

集約でもう一つ重要なのが、他の集約は ID で参照するという点です。OrderCustomer オブジェクトではなく CustomerID だけを持ちます。

internal/ordering/domain/order/order.go
type Order struct {
	id         OrderID
	customerID customer.CustomerID
	lines      []*OrderLine
	discount   Money
	status     Status
	placedAt   time.Time
}

もし Customer そのものを抱えていたら、注文を読むたびに会員を読み込むことになり、しかも「注文が持っている会員」と「DB にいる会員」のどちらが正なのかが曖昧になります。参照を ID に限ると、集約の境界がそのままロードと保存の境界になります。リポジトリを集約ごとに 1 つ用意するのも同じ理由です。

この線引きから、運用上の原則が導かれます。1 回のユースケースで更新する集約は原則 1 つにする、というものです。サンプルの注文確定も、更新するのは Order だけで、Customer とカタログの商品は読むだけです。裏を返せば、集約を大きく取りすぎると、この原則を守れなくなります。「会員は注文を持つ」という自然言語につられて Customer の中に []Order を置くと、注文 1 件の更新で会員全体をロックすることになります。集約は、同時に守る必要がある範囲までで切るのがよいバランスです。

言葉の範囲:境界づけられたコンテキスト

ここまでは一つの機能の内側の話でした。視野を広げると、別の問題が現れます。「商品」とは何か、という問題です。

カタログの側から見た商品は、価格を改定したり販売終了にしたりする管理の対象です。だから Product はエンティティであり、集約ルートです。

internal/catalog/domain/product/product.go
// Product は Product 集約のルートエンティティ。
type Product struct {
	id     ProductID
	name   ProductName
	price  Price
	status Status
}
 
// ChangePrice: 販売終了した商品の価格は変更できない。
func (p *Product) ChangePrice(price Price) error {
	if !p.IsOnSale() {
		return domainerr.Precondition("cannot change price of a discontinued product")
	}
	p.price = price
	return nil
}

ところが注文の側から見た商品は、まるで違います。注文が必要としているのは「注文した時点の名前と単価、そして今売っているかどうか」だけです。価格改定の履歴も販売終了の手続きも要りません。それどころか、注文確定後にカタログの価格が変わっても、確定済みの注文の金額は動いてはいけない。つまり注文側の商品は、管理対象ではなくその瞬間のスナップショットです。

そこで、注文コンテキストには別の型を用意しています。ID を持っていますが、これは値オブジェクトです。

internal/ordering/domain/order/product_catalog.go
// OrderableProduct は注文コンテキストから見た「注文可能な商品」のスナップショット(値オブジェクト)。
type OrderableProduct struct {
	id        ProductID
	name      string
	unitPrice Money
	onSale    bool
}

同じ「商品」という言葉が、どこまで同じ意味で通じるか。その範囲が境界づけられたコンテキストです。サンプルは catalogordering の 2 つに分けています。1 つの Product 型で両方の要求を満たそうとすると、価格改定のメソッドと注文時点の単価が同居した、誰のためでもない型ができあがります。モデルを統一しないこと自体が設計の選択だ、というのがこのパターンの肝です。

ちなみに、先ほどの AddLinep.Name()p.UnitPrice() を明細にコピーしていたのは、この判断の帰結です。明細が自分で名前と単価を持つから、カタログ側がどう変わろうと確定済みの注文は揺らぎません。「注文時点の価格で確定する」という業務ルールが、モデルの形そのものに現れています。

分けたうえで、注文側はカタログの情報を取りに行く必要があります。ここで直接カタログの型を使うと、せっかく分けた境界が崩れます。そこで、ドメイン層にはポート(インターフェース)だけを置きます。

internal/ordering/domain/order/product_catalog.go
// ProductCatalog は他コンテキスト(カタログ)から商品情報を得るためのポート。
type ProductCatalog interface {
	// FindOrderableProduct は見つからない場合 (nil, nil) を返す。
	FindOrderableProduct(ctx context.Context, id ProductID) (*OrderableProduct, error)
}

実装はインフラ層に置き、カタログが公開している DTO を OrderableProduct翻訳します。これが腐敗防止層(ACL)です。

internal/ordering/infrastructure/acl/catalog_adapter.go
func (a *CatalogAdapter) FindOrderableProduct(ctx context.Context, id order.ProductID) (*order.OrderableProduct, error) {
	dto, err := a.products.FindByID(ctx, id.String())
	if err != nil || dto == nil {
		return nil, err
	}
	price, err := order.NewMoney(dto.Price)
	if err != nil {
		return nil, err
	}
	p := order.NewOrderableProduct(id, dto.Name, price, dto.Status == "on_sale")
	return &p, nil
}

dto.Status == "on_sale" という、カタログ側の都合を知っている行が、このファイルの中だけに閉じているのが要点です。カタログがステータスの表現を変えても、影響はここで止まります。そして将来カタログを別サービスに切り出すとしても、置き換えるのはこのアダプタを HTTP クライアントにするだけで、注文のドメイン層は 1 行も変わりません。

この境界を可視化するために、ディレクトリもコンテキストを最上位にしています(internal/catalog/...internal/ordering/...)。層を最上位にすると domain/ の下に両コンテキストのモデルが混ざり、どちらの言葉なのかが読み取れなくなるためです。コンテキストをまたぐ依存も grep "ddd/internal/catalog" で全部見つかります。コンテキストが 1 つしかないうちは、層ごとの構成で十分だと思います。

どこにも置けないルール:ドメインサービス

ここまでで、ほとんどのルールはエンティティか値オブジェクトに収まりました。残るのが、どのオブジェクトに持たせても不自然なルールです。

先に抽象的な基準を書いておきます。ドメインサービスの出番は、そのルールを、特定のオブジェクトの持ち物だと言い切れないときです。現れ方は次の 3 つです。

  • 複数の集約にまたがるルール — 判断に 2 つ以上の集約の状態が要る。どちらか一方に持たせると、もう一方の内部事情を抱え込む
  • 同じ型の、他のインスタンスの状態が要るルール — 一意性や件数の上限など。オブジェクトは自分以外の同類を知らない
  • 操作そのものが業務用語になっているルール — 口座間の「送金」のように、どの当事者の持ち物とも言えない。accountA.TransferTo(accountB) より TransferService.Transfer(from, to, amount) のほうが業務の言葉に近い

前の 2 つは「判断の材料がオブジェクトの内側に収まらない」形、3 つめは「材料ではなく言葉の所属」で決まる形です。

サンプルには、このうち最初の 2 つが出てきます。「退会した会員は注文できない」「会員ランクに応じて値引きする」が 1 つめの形で、CustomerOrder の両方が揃わないと判断できません。どちらか一方に置けば、もう一方の内部事情を抱え込むことになります。「メールアドレスは会員間で重複してはいけない」は 2 つめの形で、Customer のメソッドにするのは無理があります。自分以外の会員を知らないからです。3 つめの形は出てきませんが、判断に迷ったときの物差しとして覚えておくと役に立ちます。

1 つめの形、つまり注文まわりのルールから見ていきます。これを引き受けるのが PlacementService で、名前のとおり「注文確定(placement)」を担当するドメインサービスです。自分では状態を持たず、判断に必要な CustomerOrder はすべて引数で受け取ります。

internal/ordering/domain/order/placement_service.go
// PlacementService は注文確定のドメインサービス。
// Customer と Order の 2 つの集約をまたぐルールを扱う。
type PlacementService struct{}
 
var discountRates = map[customer.Rank]int64{
	customer.RankRegular: 0,
	customer.RankSilver:  5,
	customer.RankGold:    10,
}
 
func (s *PlacementService) Discount(c *customer.Customer, o *Order) Money {
	return o.Subtotal().PercentOf(discountRates[c.Rank()])
}

値引き額は、会員のランク(Customer)と注文の小計(Order)が揃って初めて決まります。どちらの集約にも単独では置けないので、両方を受け取れるサービスが計算する。ここまでは素直な話です。

もう一つのルール「退会した会員は注文できない」も、このサービスが受け持ちます。ただしそちらは注文を確定する処理と一体になっているので、このサービスを必ず通らせるにはどうするかという話と合わせて、次の節で見ます。

ドメインサービスを「唯一の入口」にする

ドメインサービスを用意しても、ユースケースがそれを通さずにエンティティを直接操作できるなら、ルールは守られません。「注文確定の前に会員の状態を確認すること」がレビューでの口約束になってしまいます。

Go では、小文字で始まる識別子はパッケージの外から参照できません。この性質を使って、確定操作そのものを非公開にし、同じパッケージのドメインサービスだけを入口にすることができます。

internal/ordering/domain/order/order.go
// place は注文を確定する。非公開にしているのは、確定の前に必ず
// 「注文者が有効な会員か」の確認とランク割引の算出(PlacementService)を通させるため。
// Order 自身は「値引きが小計を超えない」という自分の不変条件だけを検証する。
func (o *Order) place(discount Money, now time.Time) error {
	if o.status != StatusDraft {
		return domainerr.Precondition("order is already %s", o.status)
	}
	if len(o.lines) == 0 {
		return domainerr.Validation("order must have at least one line")
	}
	if discount.GreaterThan(o.Subtotal()) {
		return domainerr.Validation("discount must not exceed subtotal")
	}
	o.discount = discount
	o.status = StatusPlaced
	o.placedAt = now
	return nil
}

この place を呼ぶのが、先ほどのドメインサービスです。集約をまたぐ確認(注文と会員が対応しているか、退会していないか)を済ませ、ランク割引を算出したうえで、最後に place を呼びます。

internal/ordering/domain/order/placement_service.go
func (s *PlacementService) Place(c *customer.Customer, o *Order, now time.Time) error {
	if o.CustomerID() != c.ID() {
		return domainerr.Validation("order does not belong to the customer")
	}
	if !c.IsActive() {
		return domainerr.Precondition("withdrawn customer cannot place orders")
	}
	// 小文字の place を呼べるのは、このサービスが同じ order パッケージにいるから
	return o.place(s.Discount(c, o), now)
}

役割分担がはっきりします。サービスは「外の材料が要る判断」を担当し、集約は「自分の不変条件の確認と状態変更」を担当する。状態を書き換えられるのは最後まで Order 自身だけで、サービスはフィールドに触れていません。

そしてこの 2 つは、ファイルこそ分かれていますが同じ order パッケージにあります。Go の可視性はパッケージ単位なので、サービスからは place が見え、外側のユースケースからは見えません。この配置が、次に説明する強制力の前提になっています。

2 つめの形、メールアドレスの一意性を扱う会員登録も同じ作りです。newCustomer は非公開で、RegistrationService.Register だけが会員を生成できます。

internal/ordering/domain/customer/registration_service.go
// Register は重複を確認したうえで新規会員を作る。永続化は呼び出し側(ユースケース)の責務。
func (s *RegistrationService) Register(ctx context.Context, name Name, email Email) (*Customer, error) {
	found, err := s.repo.FindByEmail(ctx, email)
	if err != nil {
		return nil, err
	}
	if found != nil {
		return nil, domainerr.Conflict("email is already registered: %s", email)
	}
	return newCustomer(name, email), nil
}

ここで、2 つのサービスの違いにも触れておきます。RegistrationService はリポジトリを持ちますが、PlacementService は持ちません。分かれ目は呼び出し側が材料を渡せるかどうかです。注文確定に必要な CustomerOrder は呼び出し側が特定できますが、「同じメールの会員が存在するか」は渡しようがなく、探す行為そのものがルールの一部だからです。

ただし、リポジトリを持ってよいのはここまでです。エンティティや値オブジェクトには持たせませんCustomer が会員リポジトリを抱えた瞬間、ドメインの中心にデータ取得の事情が入り込み、テストのたびに用意が必要になります。リポジトリを触ってよいのはユースケースと、必要な場合のドメインサービスまで、という線引きです。

これで、ユースケース側で確認を飛ばそうとするとビルドが通りません。「先に確認する」という約束が、人の注意力ではなくコンパイラによって守られます。設計意図をコードの可視性で表現できるのは、Go のパッケージ単位のカプセル化がうまくはまるところだと感じました。

一方で、Ship()Cancel()ChangeRank() は公開したままです。これらは自分の状態だけでルールを判定でき、どこから呼ばれても破られないからです。閉じるべきは「前提条件が外にあるもの」だけで、何でも非公開にすればよいわけではありません。

ただし、この防御は完全ではありません。永続化から集約を復元するための Reconstruct は公開せざるを得ず(Go の可視性はパッケージ単位なので、「リポジトリの実装にだけ公開する」ができない)、そこを通せば検証を迂回できます。実際に試すと、小計 1,000 円の注文に値引き 999,999 円を入れた「確定済み」の注文が、エラーも出さずに作れました。ルールを完全に強制する仕組みではなく、うっかりを防ぐ仕組みだと捉えるのが正確です。

そして、ドメインサービスは便利なぶん使いすぎに注意が要ります。エンティティに置けるルールまでサービスに書き出すと、エンティティは getter と setter だけのデータ入れ物になります(ドメインモデル貧血症)。また「複数の集約に共通する処理」をまとめたいだけなら、それはドメインサービスではなく Money のような値オブジェクトの仕事です。「またぐ」と「共通する」は別物で、後者をサービスに集めると、何でも入る共通処理置き場になっていきます。

集約の外を覗きたいとき:関数を渡す

最後に、実装していて一番悩んだところを書きます。「未完了の注文がある会員は退会できない」というルールです。

これは明らかに Customer のルールです。退会できるかどうかを決めるのは会員自身のはずです。ところが判定には Order の情報が要ります。かといって Customer に注文リポジトリを持たせると、エンティティがデータ取得というインフラ的な関心を抱え込み、テストのたびに DB かモックが必要になります。前節のドメインサービスに出すこともできますが、そうすると「退会」という会員の中心的な操作が Customer の外に出ていってしまいます。

サンプルで採ったのは、判定結果を返す関数を引数で受け取る方法です。

internal/ordering/domain/customer/customer.go
// ActiveOrderChecker は「その顧客に未完了(発送前)の注文があるか」を答える関数型。
type ActiveOrderChecker func(ctx context.Context, id CustomerID) (bool, error)
 
// Withdraw は退会する。未完了の注文が残っている場合は退会できない。
func (c *Customer) Withdraw(ctx context.Context, hasActiveOrders ActiveOrderChecker) error {
	if !c.IsActive() {
		return domainerr.Precondition("customer is already withdrawn")
	}
	has, err := hasActiveOrders(ctx, c.id)
	if err != nil {
		return err
	}
	if has {
		return domainerr.Precondition("customer with active orders cannot withdraw")
	}
	c.status = StatusWithdrawn
	return nil
}

関数の中身はユースケース側で組み立てます。ここでだけ注文リポジトリを使います。

internal/ordering/usecase/command/customer_usecase.go
hasActiveOrders := func(ctx context.Context, id customer.CustomerID) (bool, error) {
	orders, err := u.orderRepo.FindByCustomerID(ctx, id)
	if err != nil {
		return false, err
	}
	for _, o := range orders {
		if o.IsActive() {
			return true, nil
		}
	}
	return false, nil
}
if err := c.Withdraw(ctx, hasActiveOrders); err != nil {
	return err
}
return u.customerRepo.Save(ctx, c)

この形にすると、責務がきれいに分かれます。Customer は「どうやって調べるか」を知らず、「調べた結果をどう扱うか」だけを知っている。「何をもって未完了とするか」の定義は Order.IsActive() に閉じていて、注文側のルールが変わっても会員側は影響を受けない。そして退会の可否という判断そのものは Customer に残ります。テストでは func(...) (bool, error) { return true, nil } を渡すだけで、未完了注文があるケースを再現できます。

もちろん、これが唯一の解ではありません。事前に取得して値で渡せるなら、それが一番単純です。関数渡しが向くのは、取得コストが高い、あるいは条件次第では取得しなくてよい場合。そして判断そのものが複数集約にまたがる主題になっているなら、素直にドメインサービスにするべきです。今回は「会員のルールだが材料が外にある」という配置だったので、関数渡しがしっくりきました。

ひとつ制約があります。この方法で他の集約を覗くのは読むところまでにすべきです。渡した関数の中で別の集約を更新しはじめると、1 トランザクションで複数の集約を書き換えることになり、集約で境界を切った意味がなくなります。

やってみて残った課題

サンプルは学習用なので、正直に書いておくと同時実行の問題は解けていません。ユースケースは「読み込み → 変更 → 保存」で動いていて、その間に他のリクエストが割り込むのを防いでいません。同じ注文に発送とキャンセルが同時に来れば両方成功しますし、同じメールアドレスでの同時登録も両方通ります。

ここで効くのは、ドメインサービスで入口を絞ったことではありません。検査の時点では条件を満たしていて、検査してから保存するまでの間に前提が変わってしまう(TOCTOU、検査と使用の時間差)ためです。ドメインモデルは「ある瞬間の状態が正しいか」しか判定できず、その判定が保存まで有効であり続けることは保証できません。

この手の問題は、集約にバージョン番号を持たせて保存時に食い違えば失敗させる(楽観的排他制御)か、DB の一意制約で防ぐしかありません。そして前者を採ると、永続化の都合であるバージョン番号が集約のフィールドとして現れます。冒頭で「依存を内側に向ける」と書きましたが、現実にはこうした染み出しが起きる、というのがまさにこの箇所です。

ドメインモデルで守れる範囲と、永続化層で守るしかない範囲は別で、その境目には妥協が残る。実装してみて一番腑に落ちたのは、この点でした。

まとめとリポジトリ

振り返ると、出てきた語彙はすべて「ルールをどこに置くか」への答えでした。値の正しさは値オブジェクトへ、状態遷移のルールはエンティティへ、複数オブジェクトにまたがる整合性は集約ルートへ、どこにも属さないルールはドメインサービスへ。そして同じ言葉が別の意味を持ちはじめたら、そこがコンテキストの境界で、またぐときは翻訳を挟む。

置き場所が決まると、読むときも迷いません。「発送できる条件は?」と思ったら Order を開けばいい。この状態を作れることが、DDD の実利だと思います。

コードは全体を公開しています。この記事で触れたのは設計判断の中心部分だけで、CQRS による参照系の分離、ドメインエラーと HTTP ステータスの対応、テストの書き方などは README とコードに書いてあります。

https://github.com/ykitaa/playground_goDDD/ ディレクトリです。

go run ./cmd/server   # http://localhost:8080 で起動
go test ./...         # テスト実行

読む順番としては、internal/ordering/domain/ 配下のテストから入るのがおすすめです。ドメインのルールが一覧できるので、この記事で書いたルールが実際にどう表現されているかを、上から順に確認できます。