はじめに
DDDとは?という議論が尽きません。
- 「レイヤードアーキテクチャ、Repositoryなどは軽量DDDでありDDDではない」
- 「ユビキタス言語に基づいたドメインモデリングこそDDDの本質である」
とは言うものの、レイヤードアーキテクチャから先行して理解することが多いのが実情です。
なぜドメインモデリングの導入が進まないことが多いのか考えてみると、初学者にはドメインモデリングを実施したときの最終的な実装とそうでないときの実装の差がわかりづらく、どのような価値があるのかがわかりづらいためだと思います。
**「ドメインモデリングをしたからドメインが素晴らしく良い実装になった」という例を紹介できればいいのですが、なかなか適切な具体的な例で説明することが難しいです。
そこでモデリングよりも技術的な視点にはなってしまいますが「集約を意識したDDDな実装とDDDではない実装」**を具体的に紹介することで、実装レベルのメリットをお伝えすることで、結果としてドメインを中心に考えるドメインモデリングの価値を部分的にでも紹介できればと思います。
サンプルコード
ワークアウト(ジムのトレーニング)メニューを作成するシステムを作る例で考えていきたいと思います。
ワークアウトプランはユーザごとに作成され、その中にはワークアウトが複数存在しています。また1つのワークアウトプランが持てるワークアウトの数は3つが上限です。
今回の実装はどちらも軽量DDDを採用します。 1
またドメインとデータベースのエンティティを同じ構造体で実装します。2
DDDではない実装
テーブルの設計は以下のようになりました。
// ワークアウトプランはユーザごとに作成されます。
// ドメインの親子関係としてはワークアウトを持ちますが、DBとしては正規化するため保存されません。
type WorkoutPlan struct {
ID WorkoutPlanID
UserID UserID
Workouts []*Workout "db:no"// DBのライブラリがdb:noタグがついている場合は保存しない
}
// ワークアウトはその名前と関連するワークアウトプランを表すためにWorkoutPlanIDを持ちます。
type Workout struct {
ID WorkoutID
WorkoutPlanID WorkoutPlanID
Name string
}
続いてテーブルに従ってユースケースを実装していきます。
以下のコードはテーブルの設計だけで考えれば特に違和感のない実装だと思います。
// ワークアウトプランにワークアウトを追加します
func (u *Usecase) AddWorkout(ctx context.Context, workoutPlanID domain.WorkoutPlanID, workoutName string) error {
// WorkoutPlanIDから複数のWorkoutを取得する
workouts, err := u.repos.DatastoreWorkout.GetMultiByWorkoutPlanID(ctx, workoutPlanID)
if err != nil {
return err
}
// ワークアウトが3つ以上だったらエラーにする
if len(workouts) >= 3 {
return errors.New("追加できるワークアウトは3つまでです。")
}
// 新たなワークアウトを作成して保存する
workout := domain.NewWorkout(workoutPlanID, workoutName)
if err := u.repos.DatastoreWorkout.Save(ctx, workout); err != nil {
return err
}
return nil
図にすると以下のようになります。
しかしこのコードには以下の問題があります。
// ワークアウトプランにワークアウトを追加します
func (u *Usecase) AddWorkout(ctx context.Context, workoutPlanID domain.WorkoutPlanID, workoutName string) error {
workouts, err := u.repos.DatastoreWorkout.GetMultiByWorkoutPlanID(ctx, workoutPlanID)
if err != nil {
return err
}
// 問題点. ユースケースにロジックが漏れ出している
// ドメインに制約のロジックが含まれず、ドメインだけのコードを読んでも要件を把握することはできない。
if len(workouts) >= 3 {
return errors.New("追加できるワークアウトは3つまでです。")
}
workout := domain.NewWorkout(workoutPlanID, workoutName)
if err := u.repos.DatastoreWorkout.Save(ctx, workout); err != nil {
return err
}
return nil
ドメインで適切に制約を表現するためにはどのようにするべきでしょうか?
集約を意識したDDDな実装
適切にドメインで制約のビジネスロジックを表現するためには、ユースケースからは集約のルートエンティティだけにアクセスすることが大切です。なぜなら制約は集約の単位として存在しているためです。
集約を意識したコードを紹介します。
type WorkoutPlan struct {
ID WorkoutPlanID
UserID UserID
Workouts []*Workout
}
type Workout struct {
Name string
}
// 問題点を解決: 先ほどUsecaseに書かれていた実装がドメインに実装されている
func (w WorkoutPlan) AddWorkout(workout *Workout) error {
if len(w.Workouts) >= 3 {
return errors.New("追加できるワークアウトは3つまでです。")
}
w.Workouts = append(w.Workouts, workout)
return nil
}
func (u *Usecase) AddWorkout(ctx context.Context, workoutPlanID domain.WorkoutPlanID, workoutName string) error {
// 先ほどは集約のルートエンティティではないWorkoutを直接取得していたが、
// ルートエンティティであるWorkoutPlanを取得する
workoutPlan, err := u.repos.WorkoutPlan.Get(ctx, workoutPlanID)
if err != nil {
return err
}
workout := domain.NewWorkout(workoutName)
// 問題点を解決: WorkoutPlanのドメインのメソッドを実行する
if err := workoutPlan.AddWorkout(workout); err != nil {
return err
}
if err := u.repos.WorkoutPlan.Save(ctx, workoutPlan); err != nil {
return err
}
return nil
}
ドメインで制約を表現したことで、他のユースケースでワークアウトプランにワークアウトを追加する必要があったときに、必ず制約を守ることができるようになります。
またこうすることでドメインのユニットテストだけで、ビジネスロジックのテストができるようになります。
もしユースケースにビジネスロジックが漏れると、ビジネスロジックのためにユースケースのテストが必要になります。ユースケースのユニットテストはRepositoryなどのモックが必要なりテスト工数が増加するので、可能な限りドメインのテストだけでビジネスロジックのテストが行われることが望ましいです。
まとめ
今回の話では、集約を理解することで以下のような設計や実装としてのメリットを紹介しました。
- ドメインにビジネスロジックを集中することができる
- ユニットテストを簡単に実施できる
しかし実際にはドメインモデリングの視点から理解することが望ましいです。
今回の例では、ドメインモデリングの時点で「ワークアウトが3つしか追加できない」という制約の設計をしていれば、この制約がドメインに表現することが当然になります。
しかしDBを基準に実装を始めると、どうしてもDBの都合に引っ張られた設計をしがちです。
そのようにならないためにも、今回のお話を思い出して頂ければと思います。
また私はDDDで紹介されているレイヤードアーキテクチャなどを含む多くの手法はドメインに集中するためのテクニックだと考えています。これはドメインモデリングをする上でとても大切です。
なぜならモデリングした結果をそのまま実装に反映できなければ、モデリングは意味がないものになってしまうためです。
軽量DDDとドメインモデリングは両輪ではありますが、必ず両方同時にできるようになっていく必要もなく、今回のように実装面でのメリットを理解することでドメインモデリングの大切さに気がついたように、片方を理解することでもう片方の理解に繋がるということがあると思います。
そのため私がこの記事の最初に述べたような「軽量DDDばかりで...」という意見に惑わされずに興味ある分野を伸ばしていくこともとても大切だと思います。
追伸1 ドメインモデリングを実施しないDDD
また私はドメインモデリングを実施しない軽量DDD自体にも大きな価値があると考えています。
軽量DDDにより事前にアーキテクチャが定まっていて集約を意識した設計ができることで、開発時の余計な設計検討を削減することできます。
もし開発時に多くのアーキテクチャレベルでの設計を検討しているのであれば、その時間は無駄かもしれません。事前にチームとしてアーキテクチャを決めておき、ドメイン、API、テーブル設計だけを検討することが効率的な開発に繋がります。
追伸2 DDDが正解?
今回のお話はDDDとしてはこれが正しいだろうと言う話であり、そもそもDDDが主張するように境界づけられたコンテキストの単位で集約を構成して、その単位でRepositoryが存在して、その単位でトランザクションを構成するべきかという議論はあるかと思います。
そのため必ずしも前者がNGであるとは言えません。今回のサンプルではとてもシンプルな例であるためDDDの実装をしない理由は特に見当たりませんが、より複雑な状況ではそうでない状況があるかもしれません。
例えばDDDであるコードはWorkoutPlanを引いてくる必要がありパフォーマンスでは劣ります。ときにはドメインを意識することよりもパフォーマンスを優先した方がいいことがあるかもしれません。
このようなことを考えてDDDが必ず正しく必ず守ることができるという前提には立たず、常にバランスをとって考えるべきでしょう。
ただバランスをとる場合には、感情論にならないようにその理由を説明できることが大切です。