🔺

DDDになぜ失敗するのか - DDDのトリレンマ

2024/05/22に公開

DDDは理論を知った時には完璧に思えるもので、「これが正しく実践できれば素晴らしいコードが書ける!」と感じたものでした。
一方でいざ実装に入ると「え、これってどう書くの・・・?」という場面に何度も遭遇し、理想と現実の乖離に何度も悩まされることとなりました。
今回はその迷いの要因となるDDDのトリレンマから考察を深めていきます。

DDDのトリレンマ

DDDのトリレンマの初出はおそらく『Domain model purity vs. domain model completeness (DDD Trilemma)』で、日本では2023年1月ごろから話題になり始めました。

記事の例をもとに解説すると、たとえば以下のようなUserモデルがあったとします。

type User struct {
    company Company
    email   string
}

func (u *User) ChangeEmail(newEmail string) error {
    if (!u.company.IsEmailCorporate(newEmail)) {
        return errors.New("Incorrect email domain");
    }

    u.email = newEmail;

    return nil;
}

この場合、ChangeEmailは全てのドメインロジックを内包した『完全性』のあるものであると言えます。

ではここに同じメールアドレスが複数登録できないルールが追加されたらどうでしょうか?
素直な実装は、以下のようにコントローラーに記載する方法です。

func (c *UserController) ChangeEmail(userID int, newEmail string)
error {
    user, found = c.userRepository.GetByEmail(newEmail);
    if (found && user.ID != userID) {
        return errors.New("Email is already taken")
    }

    user = c.userRepository.GetById(userId);

    err = user.ChangeEmail(newEmail);
    if (err) {
        return err;
    }

    c.userRepository.Save(user);

    return nil;
}

しかしこの場合はChangeEmailのドメインロジックがドメインモデルとコントローラーに分散して記述されることになります。
つまりドメインモデルの『完全性』は失われます。

完全性を取り戻すために、repositoryを依存として渡したらどうでしょうか?

func (u *User) ChangeEmail(newEmail string, repository Repository) error {
    if (!u.company.IsEmailCorporate(newEmail)) {
        return errors.New("Incorrect email domain");
    }

    user, found = repository.GetByEmail(newEmail);
    if (found && user.ID != userID) {
        return errors.New("Email is already taken")
    }

    u.email = newEmail;

    return nil;
}

ドメインロジックをUserモデルのChangeEmailに完結することはできますが、ドメインモデルがリポジトリに依存することになってしまいます。
このようにドメインモデルがドメインモデルかプリミティブ型以外に依存した状況は『純粋性』が失われたと言えます。

どちらも満たす方法として、以下のような書き方が考えられます。

func (u *User) ChangeEmail(newEmail string, allUsers []User) error {
    if (!u.company.IsEmailCorporate(newEmail)) {
        return errors.New("Incorrect email domain");
    }

    for _, user := range allUsers {
        if user.email == newEmail {
            return errors.New("Email already in use")
        }
    }

    u.email = newEmail;

    return nil;
}

この場合は完全性も純粋性も保たれますが、全てのUserを取得し関数に渡す必要があるため『性能』を犠牲にすることとなります。

このように以下の3つの要素のうち2つしか選ぶことができないというのが、DDDのトリレンマです。

  • 完全性:ドメインモデル内でドメインロジックが完結している
  • 純粋性:ドメインモデルはドメインモデルかプリミティブ型にしか依存しない
  • 性能:リソース使用量が大きすぎない

どれを選び、どれを捨てるか

3つの要素のうち2つしか選ぶことができない、つまりDDDで開発する上でこのトリレンマから何を選び何を捨てるのか常に判断を迫られることになります。
これに対し元記事では、性能を選ぶのは現実的でなく、完全性より純粋性を優先させる方が良いと結論づけられています。

「では、これで悩むことなく実装できますね!」...なんてことにはなりません。

あくまでこれはふわっとした指針でしかありません。
現実の開発の中では、何を捨てるかはどこまで行っても流動的であり、ただ思考停止でルールに従って決められるものではありません。

性能をいつ捨てるか

わかりやすいのは、性能をいつ捨てるかです。
先ほどの例だと、ユニークチェックのために全ユーザレコードを取得していたため明らかに筋が悪そうな選択肢でした。

しかし現実の例は多種多様です。

たとえば最大6名のチームを組むことができる場合、チームに含まれるユーザ全体を1つの集約とするのはどうでしょう?
6レコードぐらいであれば問題なさそうですね。

では電子書籍サービスを開発する際に、本の集約の中に全ページを含めるべきでしょうか?
1つの本は何千ページもあるかもしれません。

アンケート機能の質問項目はどうでしょう?
現実的に質問は数個でも、仕様上制約はないとしたら?
後から仕様が変わって1集約で収めるには莫大すぎるレコードアクセスが発生したら?

The choice between larger and smaller aggregates is a trade-off between simplicity and performance
Domain model purity and lazy loadingより

ありとあらゆるケースの中で、具体的にどこで線引きをしていくのか。
もちろん経験あるエンジニアであればDB負荷を鑑みて程よいバランス感で意思決定ができるかもしれません。
しかし現実に開発を進めていく中で、全開発メンバーが常にこの意思決定を正しく行うのは難しいのではないでしょうか。

遅延読み込み

そこでDomain model purity and lazy loadingでは、遅延読み込みを用いることでAggregateのパフォーマンスを損なうことなくより大きな集約を操作できるとしています。
要するにデータが必要になったタイミングでDBリクエストを実行することで、性能を保ちつつ集約を大きくすることができます。

元記事ではSupplierを使うと純粋性が失われるとしていますが、そこを妥協すれば以下の記事のように書くこともできます。

遅延読み込みでパフォーマンスの劣化を抑えながら集約を広げることができるため、遅延読み込みを前提としてチーム開発を進めるのであれば性能とのトレードオフに悩む問題から一定解消されるかもしれません。

完全性vs純粋性

では完全性と純粋性とトレードオフについてはどうでしょうか。
元記事では純粋性を優先させる方が良い理由としてテスタビリティの低下をあげています。プロセス外の依存関係と統合されることで単体テストのためにモックやスタブが必要になるからです。
であれば完全性を選んだ際のテスタビリティの低下さえ防げれば、完全性を選ぶ理由も生まれてきそうです。

データベースとテスタビリティ

プロセス外の依存関係で主となるのはデータベースとの疎通でしょう。
実際にここまで上がった例でも、データベース疎通をどのように記述するかが全てでした。

ではデータベースとの疎通をモックせず、テスト用のデータベースを立ち上げてそこで実行する場合はどうでしょうか。
現代であればコンテナで簡単にデータベースは立ち上がりますし、Testcontainerのようなテスト時にテスト用のDBを立ち上げるライブラリも存在します。
もちろん実行時間は落ちますが、特に開発体験としてはそこまで損なうことなくテストを書くことができます。

モックをどこまで使うかで古典派とロンドン学派に分かれると言いますが、古典派として割り切るのであれば積極的に完全性を選ぶ選択肢も出てくるでしょう。

おわりに

まとめると以下のようになります。

  • DDDにはトリレンマがあり、完全性・純粋性・性能から1つ捨てる必要がある
  • 性能をいつ捨てるかの判断は難しいが、遅延読み込みによりその意思決定の難度を下げることができる
  • データベース疎通をモックせずテストすると割り切るなら、純粋性を捨てることができる

つまり遅延読み込みを積極的に行い、データベース疎通をモックせずテストを行うことで、トリレンマの悩みから解放されるということです。
完全性と性能を常に選ぶということになります。

Ruby on Rails

で、これってまんまRailsなんですよね。

実際に過去DDDを参考にしたプロジェクトのコードと、Ruby on Railsを採用しているプロジェクトのコードを比較したときに、Ruby on Railsの方がよっぽどドメインロジックがモデルに記述されていると感じました。
というのもDDD側はまさにドメインモデル貧血症でトランザクションスクリプト的になっているケースばかりだったからです。

もちろんこれはチームのスキルレベルにも依存します。
ただRuby on Railsのように実装者が悩む余地の少ないアーキテクチャのほうが実装者のスキルへの依存は少ないのは間違いないでしょう。

しかし日本におけるDDDのトレンドは、ある種Ruby on Rails的なものからの逃避先としてDDDが選択されてきた側面が強いように感じます。
そのためその対極である純粋性を優先し積極的にモックする『正しいDDD』が正義となり、実践の難しさから崩壊につながりやすいのではないでしょうか。

※私自身一番最初に書いたスクラップがRuby / Railsの辛みであるように個人的に好みというわけではありません。

Discussion