DDDでチームの言葉がようやく揃った——3年間で見えたホントの効果
コード地獄から抜け出したきっかけはユビキタス言語だった。営業とエンジニアの議論が噛み合わなかった理由、実装で気づいたこと、本当に効いた運用方法を正直に語ります。
ドメイン駆動設計を実装してチームの議論が噛み合った話
DDDに出会ったきっかけ——本当に必要だったのはコードじゃなかった
正直に言うと、3年前のうちのチームはコード地獄だった。同じ概念に5種類の名前がついてて、データベース設計とビジネスロジックが混在してて、新しいメンバーが来るたびに「どうやってこれ読むんですか」って聞かれてた。
誰かが「DDDやってみようか」って言い出したのは、実装の問題を解決する技術を探してたからじゃなくて、単純に「チーム内で同じ言葉で話す方法がない」っていう絶望からだった。
ユビキタス言語の考え方にぶつかった時、マジで目からウロコだった。「プログラマーが勝手に命名するんじゃなくて、ビジネス側とコード側で同じ言葉を使おう」っていう視点。これがなぜか、本当にシンプルなのに革命的に感じたんだよね。
ユビキタス言語——最初は「何言ってんだこいつ」だった
導入初期は最悪だった。僕が「集約」の境界を引いて、営業チームに説明しようとしたら、完全に別の概念を説明されてた。コード側では「Order集約」って呼んでる単位が、営業側では「受注」と「配送手配」は全く別の手続きだって気づかされて、2週間かけて言葉の定義を合わせた。
実装してわかったのは、ユビキタス言語って「共通言語」じゃなくて「共通の概念モデル」なんだってこと。単語を揃えるだけじゃ意味がない。
うちのチームで実際に効いたのは、月1回「言葉の定義会」を開くようにしたこと。営業、エンジニア、プロダクト、全員で「これって本当は何を指してるんだっけ」を言語化する時間を作った。最初は30分で終わるはずが2時間とかかかってたけど、この時間がないと本当にズレたまま開発が進んじゃうんだよね。
// Before:ユビキタス言語がない
type Order struct {
ID string
Status string // "pending", "confirmed", "shipped", "delivered"???
Items []OrderItem
Total float64
}
// After:ドメイン言語を明確に
type Order struct {
ID OrderID
Status OrderStatus // Pending, Confirmed, Preparing, Shipped, Delivered型
Items []OrderLine
ShippingAddress Address
BillingAddress Address
}
type OrderStatus int
const (
OrderStatusPending OrderStatus = iota
OrderStatusConfirmed
OrderStatusPreparing
OrderStatusShipped
OrderStatusDelivered
)
見てわかる通り、単なる文字列型じゃなくて、型安全にステータスを定義できた。これだけで、ありえない状態遷移を防ぐことができたんだ。
有界コンテキスト——マイクロサービスじゃなくてモノリスで本当に活躍した
DDDっていうとマイクロサービスと勘違いしてる人多いけど、うちが恩恵受けたのは正直モノリスの中での設計だった。
問題は、受注管理と請求管理が同じデータベースで同じコードから呼ばれてたんだけど、概念的には全く違うシステムだったこと。受注側では「ステータス = pending/confirmed/shipped」だけど、請求側では「ステータス = unpaid/partial/paid/overdue」。同じ Order テーブルに両方の概念が詰まってて、アップデートロジックが複雑化してた。
有界コンテキストを引いたのは簡単で、単純に責任を分けただけ:
| コンテキスト | 担当範囲 | ステータス定義 |
|---|---|---|
| Order Context | 受注に関する全てのロジック | pending / confirmed / shipping / delivered |
| Billing Context | 請求に関する全てのロジック | unpaid / partial / paid / overdue |
| Shipping Context | 配送に関する全てのロジック | preparing / in_transit / delivered |
同じモノリスの中だけど、各コンテキストが独立したパッケージで、インターフェース経由でやり取りするようにした。
// order/order.go - Order Context
package order
type Order struct {
ID OrderID
Status OrderStatus
Items []OrderLine
}
type Repository interface {
Save(ctx context.Context, order *Order) error
Get(ctx context.Context, id OrderID) (*Order, error)
}
// billing/billing.go - Billing Context
package billing
type Invoice struct {
ID InvoiceID
OrderID order.OrderID // 外部コンテキストのID参照のみ
Amount Money
PaymentStatus PaymentStatus
}
type OrderAdapter interface {
GetOrder(ctx context.Context, id order.OrderID) (*OrderSummary, error)
}
// 大事:Order構造体をそのまま使わない
type OrderSummary struct {
ID order.OrderID
Total Money
}
最初は「同じモノリスなのになんでこんなことする?」って思ってた人も多かったけど、3ヶ月で変わった。なぜなら、Billing Context のテストで Order に依存する必要がなくなったから。Mock で OrderAdapter を実装するだけで完結した。これだけで地味に開発速度が上がるんだよね。
集約——本当の難しさは、どこまでを「一緒」にするか
DDDの中で一番むずいのが集約の設計だと思ってる。抽象的だし、正解がないから。
うちの場合、Order 集約をどこまで含めるかで2回設計を変えた。最初は Order + OrderLine + Discount がすべて一つの集約で、Order リポジトリが全部を一気にロード・セーブしてた。でもそれだと、割引ロジックが更新されるたびに Order テーブル全体が書き直されて、同時実行制御がカオスになった。
集約を見直して、責任を分割した:
// 最初:全部一緒の集約
type Order struct {
ID OrderID
Lines []OrderLine
Discount *Discount // 直接埋め込み
}
func (o *Order) ApplyDiscount(d *Discount) {
o.Discount = d
}
// 改善後:集約分割
type Order struct {
ID OrderID
Lines []OrderLine
DiscountID *DiscountID // ID参照のみ
}
func (o *Order) ApplyDiscount(discountID DiscountID) error {
// Discount 集約の詳細には関わらない
o.DiscountID = &discountID
return nil
}
type Discount struct {
ID DiscountID
Code string
Percent int
}
集約のサイズが小さくなったら、テスト速度も上がった。Order のテスト時に Discount データを用意する必要がなくなったから。正直、この改善が一番わかりやすく効いたんだ。
ただ、これも完璧じゃない。Order が Discount ID で参照してるけど、実際に Discount が存在してるか確認するのはアプリケーションサービス層の責任にしておく必要がある。ここをうっかり Order 集約に入れちゃうと、また複雑化してしまう。このバランスを取るのがマジで難しい。
イベント駆動——DDD + イベントソーシングで本当に変わったこと
うちの場合、DDD 単体より、イベント駆動アーキテクチャと組み合わせたら激変した。理由は簡単で、Order が更新されたら Billing と Shipping が自動で反応する仕組みが要件として出てきたから。
DomainEvent を定義して、集約が変わった時に発行する。それをイベントハンドラが購読する仕組みにした。
package order
// DomainEvent インターフェース
type DomainEvent interface {
EventID() string
OccurredAt() time.Time
AggregateID() string
}
type OrderConfirmedEvent struct {
EventID_ string
OccurredAt_ time.Time
OrderID_ OrderID
Items_ []OrderLine
}
func (e *OrderConfirmedEvent) EventID() string { return e.EventID_ }
func (e *OrderConfirmedEvent) OccurredAt() time.Time { return e.OccurredAt_ }
func (e *OrderConfirmedEvent) AggregateID() string { return e.OrderID_.String() }
// Order 集約
type Order struct {
ID OrderID
Status OrderStatus
Items []OrderLine
Events []DomainEvent // 未発行のイベント
}
func (o *Order) Confirm() {
if o.Status != OrderStatusPending {
return
}
o.Status = OrderStatusConfirmed
o.Events = append(o.Events, &OrderConfirmedEvent{
EventID_: uuid.New().String(),
OccurredAt_: time.Now(),
OrderID_: o.ID,
Items_: o.Items,
})
}
// リポジトリで保存後、イベントを発行
type Repository interface {
Save(ctx context.Context, order *Order) ([]DomainEvent, error)
}
これで、Order が確定されたら自動的に Billing が Invoice を作成して、Shipping が準備を始める流れができた。複数のコンテキストが疎結合で動作するようになったんだ。
1年半運用してわかったのは、イベント駆動にするとデバッグが難しくなること。Order が Confirmed になった理由を追跡するのに、イベントログを遡らないといけない。個人的には、これはかなり手間なんだよね。でも利点の方が大きかった。データベースの整合性も、イベントソーシングで完全なパターンで保証できたから。
実装してわかった落とし穴——DDDで本当に失敗したポイント
1. エンティティを過度に設計する
DDD を勉強し始めた時、全てのドメインオブジェクトに ID と Equals/GetHashCode を実装するのが「正解」だと思ってた。でも、実装していくと 80% のオブジェクトは Value Object で十分だったんだ。
// これは不要に複雑
type Address struct {
ID AddressID // ???
Street string
City string
Zip string
}
// Value Object で十分
type Address struct {
Street string
City string
Zip string
}
func (a Address) Equals(other Address) bool {
return a.Street == other.Street && a.City == other.City && a.Zip == other.Zip
}
Address に ID を付けても何の利点もない。Value Object として扱った方が、シンプルだし変更にも強い。
2. ユースケースを集約に詰め込む
ビジネスロジックを「どこに置くか」は本当に難しい。Order 集約にオーダーシステム全体を詰め込もうとして、テストが破綻した。アプリケーションサービス層で複数の集約を組み合わせるのが正解だった。
// 悪い例:Order集約が責任を持ちすぎ
func (o *Order) CompletePaymentAndShip(paymentService PaymentService) error {
paid, err := paymentService.Charge(o.Total)
if err != nil {
return err
}
o.Status = OrderStatusShipped
// ...
}
// 良い例:アプリケーションサービスで調整
func (s *CompleteOrderService) Handle(ctx context.Context, orderID OrderID) error {
order, _ := s.orderRepo.Get(ctx, orderID)
payment, _ := s.paymentService.Charge(order.Total)
order.MarkAsShipped()
s.orderRepo.Save(ctx, order)
// イベント発行...
}
集約には純粋にドメインロジックだけを置く。複数の集約を組み合わせたり、外部サービスを呼んだりするのはアプリケーションサービス層の責務なんだ。この分け方を忘れると、またカオスに戻る。
3. マイクロサービスに無理矢理DDD を適用
これはうちじゃなくて知人のチームの話だけど、マイクロサービスに DDD を完璧に適用しようとして、サービス間通信が複雑になりすぎた例を見てる。DDD は設計パターンで、アーキテクチャパターンじゃない。マイクロサービスはそれとは別の問題なんだよね。
無理矢理組み合わせると、かえってシステムが複雑化する。DDD は単一プロセス内の設計に最適。マイクロサービスはそれを前提に別の設計を考える必要がある。
今、2026年のDDD運用で気づいてること
LLM を使った開発が増えてきたから、ユビキタス言語がより重要になった。Claude や ChatGPT にコンテキストを与える時、ビジネス用語を明確にしておかないと、プロンプトが完全にズレたコード生成をする。正直、これは想定してなかった使い方だった。
逆に、ドメインの概念が明確になってると、AI ツールも正確にコード生成できる。うちのチーム内では「ドメイン言語定義書」が AI コーディングのプロンプト品質を大きく左右することに気づいた。これはなかなか興味深いんだ。
そして、イベント駆動との組み合わせは今も有効。ただし、イベントログが肥大化するのを防ぐために、定期的なスナップショット戦略が必要になってきた。
// イベントスナップショット
type OrderSnapshot struct {
ID OrderID
Status OrderStatus
Items []OrderLine
SnapshotVersion int64
CreatedAt time.Time
}
// 100イベントごとにスナップショット
func (r *EventSourcedRepository) Save(ctx context.Context, order *Order) error {
for _, event := range order.Events {
r.eventStore.Append(ctx, event)
}
eventCount, _ := r.eventStore.CountSince(ctx, order.ID, 0)
if eventCount%100 == 0 {
r.snapshotStore.Save(ctx, createSnapshot(order))
}
}
こうしないと、1年運用するとイベント数が数百万になって、復旧が遅くなる。地味だけど重要な最適化だ。
まとめ
3年運用してわかったことは、DDD は完璧な設計パターンじゃなくて、チームの言語を統一する手段だってこと。
-
ユビキタス言語を定義する — 営業・エンジニア・プロダクトで同じ言葉を使う時間を作る。これが最も効果的。月1回の定義会は本当に大事。
-
有界コンテキストで責任を分ける — マイクロサービスじゃなくても、パッケージレベルでコンテキスト分割する。テスト速度が劇的に改善される。
-
集約サイズはできるだけ小さく — 悩んだら小さくしておいて、後で必要に応じて連携させる方が簡単だ。80% Value Object、20% Entity くらいの感覚で。
-
イベント駆動と組み合わせる — ドメインイベントを発行して、複数のコンテキストを疎結合に繋ぐ。スケーラビリティが格段に向上する。
-
完璧を目指さない — 理想と現実のバランスが大事。ドグマに縛られると、またカオスに戻る。
次のステップは、いまのAI時代に DDD を生かすための「プロンプトとしてのドメイン設計」かもしれない。これは 2026年版の新しいテーマになりそう。AI がコード生成するなら、ドメイン言語が明確なことが最大のアドバンテージになるんじゃないかなって感じてる。