ドメイン駆動設計入門メモ
成果物
2章 値オブジェクト
値オブジェクトとはシステム固有の値をプログラム上で表現するために使用される。しばしばそういったプリミティブな値はシステム固有のルールを持つがプリミティブな値では表現力が乏しく、値オブジェクトを使用することでそういった様々なルールを表現することができるようになる。
値オブジェクトを採用するメリットの一つとしてその値に属性が追加された場合に修正が容易ということがある。もし、値オブジェクトを採用していなかった場合、修正箇所がプログラム全体に広がる可能性があるが値オブジェクトを採用している場合、修正箇所は一箇所ですむ。
どこまで値オブジェクトにするか
同じUserNameという値オブジェクトを作成するにしてもシステムや表現するドメイン領域によってその性質は異なることを理解しなければならない。
そして、なんでもかんでも値オブジェクトにすることもできるがそれはオブジェクトを量産することにもなる。値オブジェクトにする基準として以下の二つがある。
- その値にルールがあるかどうか
- その値を単体で扱いたいかどうか
ルールというのは例えばバリデーションルールなどが当てはまる。単体で扱いたいかどうかはそのままでアプリケーションのロジックでその値を単体で扱いたいかどうか。ロジックでその値を単体で扱うまで値オブジェクトにするのはまだ保留でいいだろう。
値オブジェクトの性質
- 不変
- 交換可能
- 等価性による比較
値オブジェクトは不変であれば一度作成した値が変わることはなく安全に取り扱うことができる。一度作成した値を変更したい場合、それはオブジェクトを作り直しオブジェクトを交換すること(変数への再代入)で実現する。値オブジェクトは値を表現したものであるため値オブジェクト同士を比較するほうが自然。
値オブジェクトを採用するモチベーション
- 表現力を増す
- 不正な値を存在させない
- 誤った代入を防ぐ
- ロジックの散在を防ぐ
表現力を増すというのはそのままでプリミティブなままではそれは値としての情報しか持たないがオブジェクトにすることでプロパティやルール、振る舞いを設定することができるため表現力はかなり上がる。
不正な値と誤った代入は静的型付け言語の型システムの恩恵という面が強い。ロジックの散在は前述したような修正箇所をオブジェクト一箇所に絞れる。
値オブジェクトはデータオブジェクト(DTO)とは違い、振る舞いを定義することができるので表現力はただの値からかなり上がる。
感想
- 値オブジェクトといえばプリミティブな値をオブジェクトとして表現するだけかと思ってたがそんなことはない。
- 複数のプロパティを持ったり、振る舞い(関数)を定義することもできる。
- 値オブジェクトだけでかなりドメイン知識をプログラムに落とし込むことができる。
- エンティティとの違いがまだわからないので次章以降そこを意識して読んでいく。
3章 エンティティ
エンティティという用語について
エンティティという用語はいろいろな文脈で使われており混同しがち。例えば、DBのER図はentity-relationship-diagramの略でありエンティティという用語が登場するがDDDにおけるエンティティとは違う。また、 DB操作をする際にORMを使用することが多いが、これもObject-relational mappingの略で永続化対象のデータをエンティティと呼ぶがこれもDDDのエンティティとは別のものである。
値オブジェクトとの違い
ざっくり言うとエンティテイは同一性(identity)により識別されるドメインオブジェクト。値オブジェクトはその属性により区別される。例えば、まったく同じ車のパーツがあったとして、各パーツに製造IDのような一意の番号が振られていればそれはエンティティ。そのようなIDが振られていないのであれば値オブジェクトになる。エンティテイの性質は値オブジェクトの逆の性質であり以下のようになる。
- 可変
- 同じ属性であっても区別される。
- 同一性により区別
2と3番目の性質は前述した車のパーツの話まんま。車のパーツが製造番号という同一性を持っていればそれで区別をされるためまったく同じ車のパーツだとしても区別をされることになる。可変であるという性質は例えばUserというドメインオブジェクトがあったときにユーザー名を途中で変えたいことがあるでしょう。こういったドメインオブジェクトは変化しうるものなので可変という性質をもつことになる。
また、値オブジェクトとエンティティを分類する方針としてそのドメインオブジェクトのライフサイクルを考えることも有効。Userのようなドメインオブジェクトはシステム上で作成されてから寿命があり、いつか不要になり削除される可能性があるオブジェクト。このようにライフサイクルが考えられるオブジェクトはエンティテイ。そのようなライフサイクルが考えられないドメインオブジェクトがあったとしたらそれは値オブジェクトでいいでしょう。
エンティティにするメリット
エンティティとしてコードにドメイン知識を落とし込むことでコードのドキュメント性が上がる。何も語らないデータ型なのかさまざまなビジネス上のルールを語るようなコードにするというのがエンティティを作るモチベーションの一つ。
以下はGoで実装したUserエンティティ
package user
import (
"fmt"
"github.com/google/uuid"
)
type User struct {
id uuid.UUID
name *FullName
}
func New(name *FullName) *User {
uuid := uuid.New()
return &User{id: uuid, name: name}
}
func (u *User) Equals(other *User) bool {
return u.id == other.id
}
func (u *User) ChangeName(name *FullName) {
u.name = name
}
func (u *User) String() string {
return fmt.Sprintf("id: %s, name: %s", u.id.String(), u.name.String())
}
本の中で無味無色のセッターを使うより関数名によりドキュメント性が上がるというようなことが書かれていた。今回の場合はユーザー名が値オブジェクトなのでバリデーションロジックもそっちにあるし、関数定義したところで対した情報は得られないのでわざわざ関数定義しなくても良いかもと思った。そうした方がいいケースもあるかもしれないのでその時はそうする。
4章ドメインサービス
サービスという用語について
エンティティ同様、サービスという用語は広く使われているため文脈の違いなどで混乱を招く。ドメイン駆動設計においてもサービスは2つあり、アプリケーションサービスとドメインサービスがある。本章ではドメインサービスについての説明。
ドメインサービスとは
値オブジェクトやエンティティには振る舞いを定義することができる。しかし、値オブジェクトやエンティティに定義すると不自然な振る舞いが存在する。そういった、振る舞いはドメインサービスに定義すると良い。
不自然な振る舞いとは例えばUserエンティテイに重複Userがいないか確認するExists
関数を定義したとしましょう。User自身が重複していないか確認するときにuser.Exists(user)
のような自分自身に問いかけるような不自然さが生まれてしまいます。このような振る舞いのときにドメインサービスを使用すると良い。
ドメインモデル貧血症
ドメインサービスは何でも書けるのでドメインとしての振る舞いを全てサービスに書いていくとエンティティに振る舞いが一切なくなり、ただのデータオブジェクトとなってしまうでしょう。このようなドメインモデルのことをドメインモデル貧血症という。
本来ドメインモデルの振る舞いはドメインモデルに記載されることでどのようなビジネスルールがあるかを瞬時に理解することができるといったメリットがあります。そのため、何も語らないドメインモデルを作ることは避けるべきです。ドメインサービスは極力使用しない方がいいでしょう。どうしても、値オブジェクトやエンティティに定義すると不自然な振る舞いのみをサービスとして切り出すようにした方がよい。
以下Goで実装してみたイメージ
package user
func Exists(user *User) bool {
// 本来はDBに問い合わせして重複ユーザーが存在するか確認
return true
}
// ユーザー作成する処理フロー
func main() {
name, err := user.NewFullName("yamanaka", "junichi")
if err != nil {
log.Fatal(err)
}
u := user.New(name)
if !user.Exists(u) {
log.Fatal("duplicate user name " + name.FullName())
}
// ユーザー情報をDBに登録する処理が続く
}
5 章 リポジトリ
リポジトリの責務
DB処理はだいたい複雑で長い処理になりがちで、これをそのままドメイン処理に記述するとせっかくドメインオブジェクトを作成することで読みやすくなったコードがまた読みにくくなってしまいます。このようなドメインの永続化と再構築の責務は抽象化することでドメイン処理を読みやすく保つことができます。
リポジトリはドメインを直接扱う訳ではないのでドメインオブジェクトではないが、ドメインモデルをうまく表現するのにリポジトリは不可欠です。
リポジトリのインターフェース
リポジトリを作成するときにインターフェース化しておくことで永続化先のデータストアの種類を考える必要がなくなります。MySQLで永続化していたデータをNoSQLに変更するともしなったときに、インターフェースの実装を差し替えるだけで済みます。
また、大抵データベースが絡んだテストは面倒くさいものです。テストの時にインメモリなデータベースやモックに差し替えられる点でもインターフェース化のメリットがあります。
一方で「単体テストの考え方/使い方」ではリポジトリのインターフェース化は推奨していない。なぜならば、単体テストの考え方ではテストにおけるモックの使用自体を推奨していないのと、インターフェースを作成するのは実装クラスが複数できてからと考えているからです。インターフェスは作るものではなく発見するものというやつ。
「単体テストの考え方/使い方」は質の良い単体テストを書くことが目的のため、DDDの文脈で考えるアーキテクチャと食い違うところもあるかとも思う。
個人的にはDBまるまる差し替えるような変更なんて現実問題はいらないだろうし、実装は1つだけなのだからインターフェースにしなくていいんじゃないかなとも思う。インターフェースの置き場所考える必要もあるし。インターフェースと実装が別の場所にあると肝心の実装にたどり着けない問題もあるし。テストでモックを使うならばインターフェース化は必須。モックにしないならインターフェースにするメリットもそんなにない。
以下Goでの実装例
main.go
package main
import (
"context"
"database/sql"
"ddd-demo/user"
"fmt"
"log"
_ "embed"
_ "github.com/mattn/go-sqlite3"
)
//go:embed sql/schema.sql
var ddl string
func main() {
ctx := context.Background()
db, err := sql.Open("sqlite3", "db/users.db")
if err != nil {
log.Fatal(err)
}
repository := user.NewRepository(db)
// テーブルを作成
if err = repository.CreateTables(ctx, ddl); err != nil {
log.Fatal(err)
}
// Userをドメインモデルで表現
name, err := user.NewFullName("yamanaka", "jun")
if err != nil {
log.Fatal(err)
}
u := user.New(name)
// ドメインサービスを作成
service := user.NewService(repository)
if service.Exists(u) {
log.Fatal("already exist user " + u.String())
}
// ユーザーを永続化
dto, err := repository.CreateUser(ctx, u.Id().String(), u.Name().FirstName(), u.Name().LastName())
if err != nil {
log.Fatal(err)
}
fmt.Printf("Complete created user %v", dto)
}
repository.go
package user
import (
"context"
"database/sql"
"ddd-demo/infrastructure"
)
type Repository struct {
db *sql.DB
queries *infrastructure.Queries
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db, infrastructure.New(db)}
}
func (r *Repository) CreateTables(ctx context.Context, ddl string) error {
_, err := r.db.ExecContext(ctx, ddl)
return err
}
func (r *Repository) CreateUser(ctx context.Context, id, firstName, lastName string) (*DTO, error) {
params := infrastructure.CrateUserParams{
ID: id,
FirstName: firstName,
LastName: lastName,
}
u, err := r.queries.CrateUser(ctx, params)
if err != nil {
return nil, err
}
return convertDTO(u), nil
}
func (r *Repository) FindById(ctx context.Context, id string) (*DTO, error) {
u, err := r.queries.FindUser(ctx, id)
if err != nil {
return nil, err
}
return convertDTO(u), nil
}
func (r *Repository) FindByName(ctx context.Context, firstName, lastName string) (*DTO, error) {
params := infrastructure.FindUserByNameParams{
FirstName: firstName,
LastName: lastName,
}
u, err := r.queries.FindUserByName(ctx, params)
if err != nil {
return nil, err
}
return convertDTO(u), nil
}
func convertDTO(u infrastructure.User) *DTO {
return NewDTO(u.ID, u.FirstName, u.LastName)
}
メモ
- 関係ないけどGoでパッケージきるときにuserみたいなパッケージに全部詰め込むと公開したくないのにされちゃう。user.Serviceからuser.Repository参照されちゃうみたいな。レイヤーレベルでパッケージきるのが良さそうな気がする。(どこかでドメインレベルでパッケージ切った方がわかりやすいみたいなの聞いた気がしたので)
- DTOの使い方。ORMが提供するデータオブジェクトをそのまま呼び出し元で使っていいのか?永続層に引数としてドメインオブジェクトを渡していいのか?いちいち考えるのあれなのでとりあえずレイヤー跨ぐ時はDTOに詰めて渡せば問題ない、と思う。
6章 アプリケーションサービス
アプリケーションサービスとは
DDDにおける2つのサービスのうちの一つ。前述したドメインサービスはドメインの振る舞いを表現したもの。アプリケーションサービスはアプリケーションとしての振る舞いを表現したもの。いわゆるユースケースを表現したものがアプリケーションサービスといえる。値オブジェクトやエンティティはビジネス上のドメインを表現しただけであり、それらがどう扱われるかなどは表現できていない。それをやるのがアプリケーションサービス。ドメインの漏洩になるのでアプリケーションサービスではビジネスロジックを含んではならない。
「単体テストの考え方/使い方」ではアプリケーションサービスの働きをするところをコントローラーと呼んでいた。(こうやっていろんな書籍で同じような用語を使うから混乱が...)
何にせよドメインロジックを閉じ込める、永続層をリポジトリで表現するをした後はこれらをつなぎ合わせる場所が必要でそれがDDDで言うところのアプリケーションサービスでクリーンアーキテクチャでいうところのUsecase層なんだと思う。ちなみにオニオンアーキテクチャはアプリケーションサービスとドメインサービスが両方登場する。
DTO
アプリケーションサービスで「ユーザー情報を取得する」というユースケースを実現するためのGetUser
という関数を定義したとする。このとき関数内で生成されたUser
というドメインオブジェクトを戻り値として良いかどうかはそのプロダクトがどのような道を歩むかの大きな分岐点となる。
そのドメインオブジェクトがどう扱われるかは呼び出し元次第だがドメインロジックをドメイン層以外で使えるような状態は好ましくない。結局、ドメインが散らばることになるのでドメイン知識は中で閉じ込めておくべきだろう。これをしないとドメイン知識の漏洩とか低凝集というアンチパターンになる。(低凝集にするか高凝集にするかはケースによって低凝集にしたからといってアンチパターンではたぶんないけどだいたいドメインがあまり考えずに漏れ出てしまったケースはアンチパターンだろう、たぶん)
上にも書いたけどとにかくドメインは中に閉じ込めて外に出さない、レイヤー跨ぐ時はDTO使うということを覚えておくと幸せになれそう。もちろんケースバイケース
また、DTOを使う場合はフィールドが増えた時の修正箇所を少なくなるのでDTOのコンストラクタやファクトリメソッドにドメインオブジェクトをそのまま渡すようにすると良い。
以下はGoのアプリケーションサービスの実装イメージ
ackage user
import (
"context"
"errors"
)
type ApplicationService struct {
repository *Repository
service *Service
}
func NewApplicationService(r *Repository, s *Service) *ApplicationService {
return &ApplicationService{r, s}
}
func (a *ApplicationService) Create(ctx context.Context, firstName, lastName string) (*DTO, error) {
// 名前は値オブジェクト
name, err := NewFullName(firstName, lastName)
if err != nil {
return nil, err
}
// Userをドメインモデルで表現
u := New(name)
// ドメインサービスでユーザーの存在確認
if a.service.Exists(u) {
return nil, errors.New("already exist user " + u.String())
}
// ユーザーを永続化
return a.repository.CreateUser(ctx, u.Id().String(), u.Name().FirstName(), u.Name().LastName())
}
package main
import (
"context"
"database/sql"
"ddd-demo/user"
"fmt"
"log"
)
func main() {
ctx := context.Background()
db, err := sql.Open("sqlite3", "db/users.db")
if err != nil {
log.Fatal(err)
}
r := user.NewRepository(db)
s := user.NewService(r)
as := user.NewApplicationService(r, s)
result, err := as.Create(ctx, "yamanaka", "junichi")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Complete!! user: %v", result)
}
Command
更新系の処理をアプリケーションサービスに追加
func (a *ApplicationService) UpdateName(ctx context.Context, id, firstName, lastName string) (*DTO, error) {
// ユーザー情報を永続層から再構築
dto, err := a.repository.FindById(ctx, id)
if err != nil {
return nil, err
}
u, err := toModel(dto)
if err != nil {
return nil, err
}
// 新しい名前の値オブジェクト
newName, err := NewFullName(firstName, lastName)
if err != nil {
return nil, err
}
// ユーザー名の変更
u.ChangeName(newName)
// ドメインサービスで新しいユーザー名の存在確認
if a.service.Exists(u) {
return nil, errors.New("already exist user " + u.String())
}
// 永続化
return a.repository.UpdateName(ctx, u.Id().String(), u.Name().firstName, u.Name().lastName)
}
これで問題はないのだけどもし更新したい項目が今後増減するたびに関数のシグネチャを修正する必要が出てくる。これを解決するのにコマンドオブジェクトが使える。
func (a *ApplicationService) UpdateName(ctx context.Context, cmd *UpdateCommand) (*DTO, error) {
// ユーザー情報を永続層から再構築
dto, err := a.repository.FindById(ctx, cmd.id)
if err != nil {
return nil, err
}
u, err := toModel(dto)
if err != nil {
return nil, err
}
// 新しい名前の値オブジェクト
newName, err := NewFullName(cmd.firstName, cmd.lastName)
if err != nil {
return nil, err
}
// ユーザー名の変更
u.ChangeName(newName)
// ドメインサービスで新しいユーザー名の存在確認
if a.service.Exists(u) {
return nil, errors.New("already exist user " + u.String())
}
// 永続化
return a.repository.UpdateName(ctx, u.Id().String(), u.Name().firstName, u.Name().lastName)
}
名前の変更に機能を絞ってしまったからあれだけど他の項目もまとめて変更可能なUpdate
関数だったならばコマンドオブジェクトがもっと仕事するはず。
凝集の話
次に削除機能を実装してみる。
func (ds *DeleteService) Delete(ctx context.Context, id string) error {
_, err := ds.repository.Delete(ctx, id)
return err
}
こんな感じでいいんだけどこの関数は作成や更新と違いドメインサービスを使ってない。依存関係を全ての関数が使っているかどうかは凝集度に関わり、このような場合は凝集度が低いといえる。凝集度を上げるには処理を分割すればいい。
今回の場合はDeleteService
のような別のアプリケーションサービスを作成する感じ。
package application
import (
"context"
"ddd-demo/user"
)
type DeleteService struct {
repository *user.Repository
}
func NewDeleteService(r *user.Repository) *DeleteService {
return &DeleteService{r}
}
func (ds *DeleteService) Handle(ctx context.Context, cmd *DeleteCommand) error {
_, err := ds.repository.Delete(ctx, cmd.id)
return err
}
Goなのでパッケージの切り方しだいなところはあるけどパッケージや構造体名で十分役割を説明できているのであれば関数名をシンプルな名前にすることもできる。
7章 依存の話
依存関係の扱いについての話。これはいろんなところで語られてきた話ではある。
抽象に依存せよ
「単体テストの考え方/使い方」で早すぎるインターフェース化はやめよう的な主張だったので確かにとも思ってたけど依存の話のときのインターフェースの話は変更容易性を高めるために抽象に依存しようみたいなニュアンスが強い。
インターフェースを作るのではなく発見しようというのも理解できるが、変更に強いソフトウェアを作ろうみたいな観点だと、抽象に依存するようにしモジュール間の結合度を低くすることは大事。
特に、クリーンアーキテクチャやDDDの文脈からは大事な気がする。なのでやっぱり抽象に依存するように作ろうと思う。ただし、モックに差し替えてテストすることは偽陽性を持ち込むので極力テストはモックを使用しない。個人的な考え方。
8章 ユーザーインターフェースとテストの話
ちゃんとユーザーインターフェースとドメインを分離させておくことでGUIやCLIといったユーザーインターフェースは交換可能だよという話。現実問題ユーザーインターフェースを交換するようなことは稀だが何事も剥がしやすい作りにしておくのは現代の流れの早い開発現場では好まれそう。
あとテストはリファクタリングするのに大事だよという話。
9章 ファクトリ
ドメインオブジェクトを生成する処理がもし複雑性を伴うならばそれはファクトリクラスやファクトリメソッドとして処理を切り出すことでドメインオブジェクトの仕事をドメインの処理に集中させることができる。今回はGoで以下のような Userエンティティを作成した。
type User struct {
id uuid.UUID
name *FullName
}
func NewFromName(name *FullName) *User {
uuid := uuid.New()
return &User{id: uuid, name: name}
}
func New(id uuid.UUID, name *FullName) *User {
return &User{id, name}
}
新たに作成するときと既存のユーザーを表現するときでIDがあるときとないときがあるためファクトリ関数を2つ用意しています。もし、このID採番をsequenceテーブルを使用するようにした場合、DBアクセスを伴うためかなり複雑性が増します。このような複雑な生成処理をドメインオブジェクト内に同居させてしまうとせっかくのUserエンティティの可読性が落ちるため、そのような場合はファクトリオブジェクトとして切り出した方がいいかもしれない。
sequenceテーブルを使用するとき採番処理をリポジトリに実装するという考えもあるがリポジトリの責務は永続化と再構築なので採番処理まで同居させるとやりすぎ感がある。ので、やるならファクトリの処理に取り入れることを本書では推奨している。
10章 トランザクション
トランザクションの話。
// ドメインサービスでユーザーの存在確認
if a.service.Exists(u) {
return nil, errors.New("already exist user " + u.String())
}
// ユーザーを永続化
return a.repository.CreateUser(ctx, u.Id().String(), u.Name().FirstName(), u.Name().LastName())
この部分をロックしておかないと永続化時に重複ユーザー名で登録できてしまうことがありうる。この実装でロックをかけるには存在確認と永続化の部分を一つのトランザクション処理でまとめてロックをかける必要がある。でもそうすると、わざわざドメイン処理として存在確認のルールをわかりやすくしてたのにservice自体いらなくなるけどまあそうするしかない。
11章 機能追加
サークルという機能追加。
12章 集約
集約とは不変条件を維持する単位として切り出される。例えば、今まで出てきたUserやCircleがまさに集約の単位。Userという集約は
- Userエンティティ Aggreagaten Root
- UserId
- UserName
のような集合と考えられる。
集約外部から集約内部のオブジェクトを操作してはならない。集約内部のオブジェクトの操作はAggragate Rootが責任を持って実施する。
userName := NewUserName("user")
// NG 集約の内部のオブジェクトを外部から操作してはいけない
user.Name = userName
// OK 集約の内部の状態の変更はAR(Aggreagate Root)が責任を持って行う
user.ChangeName(userName)
集約の重要な役割としてドメインオブジェクトの不変状態の維持があげられる。このようなオブジェクトの不変状態の維持にはオブジェクト指向言語の文脈に登場するデメテルの法則もある。デメテルの法則では、メソッドを呼び出すオブジェクトは以下の4つに限定される。
- オブジェクト自身
- 引数として渡されたオブジェクト
- インスタンス変数
- 直接インスタンス化したオブジェクト
デメテルの法則に違反しないようにするにはオブジェクトのプロパティを直接参照しないことです。オブジェクトのプロパティを直接参照するようにするとドメインルールが漏れ出しルールが散らばることにもつながります。ドメインオブジェクトのプロパティを参照し何かをしたいのであればそのオブジェクト自体に問い合わせする必要があります。これは集約がやろうとしていることと同じことになります。
Goで作成したエンティティのフィールドも集約により不変状態を守るには公開すべきではなさそう。GoでDDDができないことはないけどやはりオブジェクト指向言語に比べるとやりづらいのは確か。
既に作った以下のUserエンティティは不用意にゲッターを用意していしまっているので削除する。
package user
import (
"fmt"
"github.com/google/uuid"
)
type User struct {
id uuid.UUID
name *FullName
}
func NewFromName(name *FullName) *User {
uuid := uuid.New()
return &User{id: uuid, name: name}
}
func New(id uuid.UUID, name *FullName) *User {
return &User{id, name}
}
// ドメインルールの漏洩につながるので不用意にゲッターを作るべきでない。
// func (u *User) Id() uuid.UUID {
// return u.id
// }
// func (u *User) Name() *FullName {
// return u.name
// }
ただそうすると永続化の時にエンティティから値がとりだせずに以下のコード部分がコンパイルエラーになる。
// ユーザーを永続化
result, err := a.repository.CreateUser(ctx, u.Id().String(), u.Name().FirstName(), u.Name().LastName())
if err != nil {
return nil, err
}
エンティティのフィールドを非公開にしつつ値を取得できるようにする必要がある。これは通知オブジェクトを使用することで実現することができる。
通知オブジェクトn
type UserNotification struct {
Id uuid.UUID
Name *FullName
}
エンティティにNotify()
を追加。
// ゲッターのかわりに通知オブジェクトを使う
func (u *User) Notify() *UserNotification {
return &UserNotification{u.id, u.name}
}
リポジトリを修正。
func (r *Repository) CreateUser(ctx context.Context, user *User) (*User, error) {
notification := user.Notify()
params := infrastructure.CrateUserParams{
ID: notification.Id.String(),
FirstName: notification.Name.firstName,
LastName: notification.Name.lastName,
}
u, err := r.queries.CrateUser(ctx, params)
if err != nil {
return nil, err
}
return convertEntity(u)
}
これでエンティティのフィールドを非公開にしたまま値を参照することができる。
集約をどう区切るか
基本的には変更の単位。集約の区切りを超えてドメインの状態を変更してはならない。これをやるとロジックが散らばったり、いろいろと問題になる。
集約に対する変更はその集約自身が責任を追うべき。永続化の依頼も集約ごとにするべき。なのでリポジトリの単位も集約と同じ変更の単位となる。
インスタンスを持たない
以下のようなCircleエンティティの場合、Userエンティティがフィールドを公開してしまっていた場合に集約を超えて変更できてしまう。
type Circle struct {
Id *value.CircleId
Name *value.CircleName
Owner User
Members []User
}
これはUserエンティティをフィールドとして持っていることが問題という考え方もできる。これは以下のようにUserの識別子だけ持つように変更することもできる。
type Circle struct {
Id *value.CircleId
Name *value.CircleName
OwnerId uuid.UUID
Members []uuid.UUID
}
こうすればやりたくても集約の境界をこえることはない。加えて、使わないフィールド含めオブジェクトそのものを保持していたものを識別子だけに変更したことでメモリの節約にもなる。そもそも、DBから引っ張ってきたデータでこのエンティティを作ろうとするとJoinしてUserデータも全部持ってくる必要が出てくる。JavaのSpring Dataなんかは容易に紐付けできてしまうためやりがちだが使わないのであれば無駄に全部持ってる必要はない。
識別子のゲッター
ゲッターは極力排除すべきだが、識別子に関しては話が変わる。エンティティの識別子はそれ自体が集約として使える便利なものです。識別子は一意な値であって、識別子そのものがドメインルールを持つことは少ない。識別子を公開するデメリットよりも公開するメリットのほうが上回るとき、識別子に関してはゲッターを公開してもいいでしょう。
集約の大きさについて
集約は極力小さく保つべき。あまりにも大きな集約ができあがってしまったらそれは集約の境界を見直すチャンス。集約が大きいと言うことはトランザクションの範囲も大きくなる可能性があり、範囲が広いトランザクションはロックというパフォーマンス的にも嬉しくない状況を生みます。集約を跨いだトランザクションも貼るべきでない。
どうしても、集約を跨いだトランザクションを貼りたい時、結果整合性について検討してもるのもあり。
ドメインルールを誤解を生まないように表現する
以下のようなこと
const maxMembersCount = 30
func (c *Circle) IsFull() bool {
// return len(c.members) > 29
return len(c.members) + 1 > maxMembersCount
}
これはサークルが最大30人までというドメインルールを表現しているがメンバーの数とオーナーを足したうえで考えなくてはならない。これをコメントアウトしたようにかくと30ではなく29という数字が登場することになりビジネスルールが誤解されてしまう恐れがある。あえて、オーナーの人数で+1したよというのをコードで表現したほうがこのオブジェクトが持つドメイン知識はより表現力が上がることになる。
13章 仕様
ドメインが複雑であればあるほどドメインオブジェクトを評価することが多くなる。前述したCircleエンティティのIsFull()
みたいなこと。こういった評価ロジックはしばしばアプリケーションサービスに書いてしまいがち。これをするとアプリケーションサービスにドメイン知識が漏れ出てしまっており、ドメインが何も語らないオブジェクトとなってしまう。
じゃあどうすればいいかというとIsFull()
のようにドメインオブジェクトに記載すればいいです。
しかし、オブジェクトの評価はしばしばDB問い合わせを伴う。エンティティはリポジトリを持っていないので関数の引数にリポジトリを含ませることも考えられるがリポジトリはドメインオブジェクトではなく、エンティティ内で操作すべきでない。
なので、エンティティから仕様を切り出すことで問題を解決することができる。
package specification
import "ddd-demo/circle/entity"
type CircleSpecification struct{}
const maxMembersCount = 30
func (cs *CircleSpecification) IsFull(circle *entity.Circle) bool {
return circle.CountMembers() > maxMembersCount
}
こんな感じだろうか。こうなるとこのオブジェクトが状態を持たないのでGoだったら関数でいいんじゃないかという気もする。
ただ、これはCircleエンティティがメンバー情報を持っていた場合。もし、Circleエンティティが識別子しかもっていなかったらメンバーを一度取得してくるためにリポジトリが必要になる。エンティティにリポジトリは持たせられないので仕様オブジェクトにリポジトリを持たせることになる。
こういった、オブジェクトの検証処理は大量に生成されがちで、これをエンティティに同居させると大事なドメイン情報の可読性が落ちる。そのため、リポジトリを使う使わない関わらず仕様オブジェクトとして切り出すことでドメインの可読性を保つことができる。
仕様をリポジトリに渡してフィルタリングする
ドメインオブジェクトを検証し、フィルタリングして取得したいことはよくある。これをリポジトリでやってしまうとドメイン知識がリポジトリに漏れてしまっているため仕様オブジェクトをリポジトリに渡すことで知識が漏れ出るのを防ぐことができる。
しかし、これにはパフォーマンス的な問題がついてまわる。なのでリポジトリに仕様オブジェクトを渡すことが絶対正しいということはないためよく考えて使用する必要がある。
CQSやCQRS
DBの読み込み(クエリ)と書き込み(コマンド)では読み込みの際にロジックを伴うことはあまりない。逆に書き込みの際にはドメインルールを多く適用することが多いため、DDDにおいて書き込みの際には積極的にドメインオブジェクトを使用するとよい。こういった読み込みと書き込みを分離して考えるのにCQSやCQRSといったものがある。
もともとCQSという考え方をアーキテクチャレベルに適用したものがCQRSらしい。少し脱線するがCQRSを実現するのにEvent Sourcingが必要という話をかとじゅんさんが以下でしてる。
14章 アーキテクチャ
いろんなアーキテクチャの話。DDDにおけるアーキテクチャは主役ではないということを肝に免じておく必要がある。
レイヤードアーキテクチャ
エリックエヴァンスのDDD本で語られているアーキテクチャ。プレゼンテーション、アプリケーション、ドメイン、インフラストラクチャーの4層が登場人物。依存はプレゼンテーションからインフラストラクチャーまでの一方向の依存関係を持つ。他のアーキテクチャとの違いはインターフェースの活用について言及されてないこと。ドメインレイヤーを分離し、アプリケーションレイヤーでドメインオブジェクトやリポジトリなどを繋ぎ合わせるのは他のアーキテクチャと一緒。
ヘキサゴナルアーキテクチャ
ドメインを中心に置き、外界とをポートとアダプターで繋ぎ合わせることとしたアーキテクチャ。レイヤードアーキテクチャとの相違点はインターフェースの使用について言及されている点。
クリーンアーキテクチャ
有名な同心円のやつ。他のアーキテクチャとの違いは具体的な実装方法について言及されている点。
どのアーキテクチャもドメインを隔離するということと依存関係を一方向に保つということは同じであり、インターフェースを使うかどうか、どう使うかみたいなことがそれぞれのアーキテクチャで微妙に違う感じで説明されている。けど、本質は同じだからどのアーキテクチャパターンを使うにしてもアーキテクチャを適用することが目的になってはならない。
他のアーキテクチャ
-
オニオンアーキテクチャ クリーンと似てるがアプリケーションサービスやドメインサービスといった用語が登場するのでDDDの用語と関連付けしやすいかも。
-
3層アーキテクチャ これは他のアーキテクチャと同列にはならない気がするけどアプリケーションをプレゼンテーション、ビジネスロジック、データアクセスの3層にわける考え方。MVCみたいなアーキテクチャパターンもあるけどこれはプレゼンテーション層のアーキテクチャパターンみたいな考え方ができるらしい。ビジネスロジック層がアプリケーションとドメインに分けて考えてるのがクリーンとかとも考えられる。
Controller、Service、Repositoryというレイヤーでアプリケーションを構築するのはまさに3層アーキテクチャだ。このServiceってDDDからきてんのかな?このアーキテクチャが頭から離れないからクリーンやDDDの用語や概念がすんなり入ってこなかったんじゃないかという気がしなくもない。
15章 今後の学習の手引き
これまでの内容はDDDにおける具体的な実践パターンであり、これだけではDDDとはいえない。重要なビジネスエキスパートとともに作り上げるモデリングがあって初めてDDDといえる。実践的パターンだけ活用したものは軽量DDDとしてアンチパターンともいえる。
軽量DDDについて
おそらく多くの組織でDDD採用が難しいとされているのが開発組織だけで成り立たないアーキテクチャパターンだからだと思う。上述したように実践的なパターンだけ適用してもドメインエキスパートとの密なモデリングが実践できなければそれは真のDDDとは言えず軽量DDDとしてアンチパターンだと言われることになるから。
とはいえ、現代のバックエンド開発で何の骨組みもないまま開発なんてできないのでクリーンアーキテクチャやオニオンアーキテクチャあたりが実装の指針としてやりやすいので多くの開発現場で採用されることになっているんだと思う。
DDDの目的がドメインをコードに完全に落とし込むことなのでモデリングが実施されない軽量DDDはそれは確かにアンチパターンなんでしょう。
ただ、DDDを例えばドメインロジックの隔離みたいな目的で採用するなら軽量DDDでもいんじゃないかとも思う。結局、クリーンアーキテクチャやオニオンを採用しても、クリーンであれば1番中心のEntitiesの部分をどうやって実装するかの方針がないと全部Serviceにロジック突っ込んでfat serviceになってテスト書くの辛くてみたいなことになっちゃうんじゃないかなと思う。
クリーンアーキテクチャでせっかくドメイン部分とそれに至るまでをきっちりわけたのならばドメイン部分をちゃんとDDDのテクニックを使用してドメイン知識の不変状態を保ち、きっちりカプセル化してドメイン知識の漏洩を防ぎ、変更に強い構造にすることでより良くなるんじゃないかと思う。
とはいえ、チームで合意のとれてないなかDDDゴリ押ししても逆効果なのでDDDのテクニックを活かすくらいがちょうどいいんじゃないかと個人的に思った。