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 がコード生成するなら、ドメイン言語が明確なことが最大のアドバンテージになるんじゃないかなって感じてる。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事