「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」をGoでやる
ドメイン駆動の勉強
ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 を読んで勉強してた。メモ
前に仕事で入ったプロジェクトで使ってたので概要はなんとなく理解してる。。概念から入れていく
書籍だとC#で説明あるけど、勉強のため別言語(Golang)で例も書いてみる
Value Object
プリミティブの値でなく、(オブジェクト指向なら)クラスとかで値クラスを設定する
type VO string
func newVO(val string) (*Name, error) {
vo := VO(val)
return &vo, nil
}
特徴
-
- 値は不変である
- 値は交換可能(
a = new VO("aaa")
->a = new VO("bbb)"
) - 等価性によって比較可能
ふるまいを定義できる
例えば、通貨というValue Objectを定義するとしたら、以下のような定義が可能です。
type Money struct {
amount int
currency string
}
func newMoney(amount int, currency string) (*Money, error) {
if currency == "" {
return nil, errors.New("currency is need")
}
return &Money{
amount: amount,
currency: currency,
}, nil
}
func (baseMoney *Money) add(addMoney *Money) (*Money, error) {
if baseMoney.currency != addMoney.currency {
return nil, errors.New("currency should be same")
}
return &Money{
amount: baseMoney.amount + addMoney.amount,
currency: baseMoney.currency,
}, nil
}
func main() {
m1, _ := newMoney(100, "USD")
m2, _ := newMoney(300, "USD")
m3, _ := m1.add(m2)
fmt.Println(m3) // &{400 USD}
}
ここではadd
というメソッドで、通貨を加算するというふるまいを定義しています。
通貨単位が異なる場合にエラーを返すといったドメイン固有のエラーを定義が可能になります
VOを利用せずプリミティブの場合は、通貨の加算だけでなく、これもやらないといけないので考慮点が増えてしまいます。
またふるまいを定義するということは、定義された以外のことはできないということになります。
例えば上のコードは加算しか定義していないため、減算処理なども実行できないです(ドメインで必要ならメソッド定義を行う
値オブジェクトを導入するメリット
プリミティブ型でなく、わざわざVOを定義する理由
- 表現力がます
- 不正な値を存在させない
- 謝った代入を防げる
- ロジックの散財を防げる
表現力がます
ドメインがたっぷり含まれるような値をプリミティブ型で定義すると、ソースコードだけから情報が読み取れなくなる。
例えば書籍のID的なISBNはプリミティブ型にすると、単純な文字列になってしまうが実際は以下のようにハイフン区切りごとに意味がある。
// 978-4-7981-2196-3
type ISBN struct {
prefix string // 978
countryCode string // 4
publisherCode string // 7981
bookNameCode string // 2196
checkDigit string // 3
}
コードで定義することで、コードからドメインの情報が読み取りやすくする
不正な値を代入させない
パスワードを用意するとして、ドメインロジックとして、8文字未満は許容しないといった場合に以下のように、VOにその設定を入れることで弾くことが可能
type Password string
func newPassword(password string) (*Password, error) {
if len(password) < 8 {
return nil, errors.New("パスワードは8文字以上を指定してください")
}
retPass := Password(password)
return &retPass, nil
}
謝った代入を防ぐ
VOでクラスとして定義することで、型が使えるのでコンパイルエラーを出しやすくなる
プリミティブ型だと、にた値で間違えることがある
例えばユーザーIDを入力に受け取る関数用意するとして、値としてユーザーID, ユーザー名がある場合
どちらもプリミティブ型で定義していると、間違ってユーザー名渡してしまってもエラーにならない(VOにしておくと型エラーが出るようになる
ロジックの散在を防ぐ
上の不正な値代入などはVOの中に、判定ロジックを入れている。
VOを利用しない場合、この値を新しく生成する箇所、使う箇所至る所にこの判定ロジックを突っ込まないと行けない
エンティティ
VOとの違いは同一性をもつかどうか
ユーザーというオブジェクトは属性があって、その属性を変更してもシステムとしてはユーザーが変わったとは判定しない(ユーザーIDが変わってないみたいな)こういうのがエンティティ
- 可変である
- 同じ属性でも区別される
- 同一性により区別される
何をエンティティにして何をVOにするか
ライフサイクルがあるようなものはエンティティにすべき
ドメインサービス
VOやエンティティに書ききれないふるまいをまとめるもの
例えば重複したユーザーは許容しないというふるまいはエンティティやVOに持たせるべきではない
やろうと思えば、ドメインサービスにふるまい全部を突っ込めるけど、それやるとVOやエンティティがセッターゲッターだけになるので、ほどほどに(ドメイン貧血症)
可能な限りドメインオブジェクト(VO, エンティティ)を利用して、なるべくサービスは利用しないこと
またドメインサービスには、データの保存詳細(SQL処理など)は記載しないこと(それはRepoistoryの役目)
ドメインサービス、エンティティ、VOで物流拠点のユースケースを表現してみる
Entity, VOとして、荷物(baggage)と物流拠点(PhysicalDistributionBase)を表現する
type Baggage struct {
Name string
Option []string
}
func NewBaggage(name string, option ...string) *Baggage {
return &Baggage{
Name: name,
Option: option,
}
}
type PhysicalDistributionBase struct {
baggages []*Baggage
}
func NewPhysicalDistributionBase(baggages []*Baggage) *PhysicalDistributionBase {
return &PhysicalDistributionBase{
baggages: baggages,
}
}
func (p *PhysicalDistributionBase) Ship(baggage *Baggage) {
result := []*Baggage{}
for _, v := range p.baggages {
if v != baggage {
result = append(result, v)
}
}
p.baggages = result
}
func (p *PhysicalDistributionBase) Receive(baggage *Baggage) {
p.baggages = append(p.baggages, baggage)
}
拠点Aからの出荷と、拠点Bへの入庫は同時に行われないといけない。
これらはエンティティに持たせるべきではないのでサービスを定義する
type Service struct {}
func NewService() *Service {
return &Service{}
}
func (s Service) Transport(from, to *PhysicalDistributionBase, baggege *Baggage) {
from.Ship(baggege)
to.Receive(baggege)
}
これらを実際に使ってみる
func main() {
baggageA := NewBaggage("AAA", "割れ物")
baggageB := NewBaggage("BBB", "割れ物", "冷蔵")
baggageC := NewBaggage("CCC")
baggageD := NewBaggage("AAA", "電気機器")
baggages := []*Baggage{
baggageA,baggageB, baggageC, baggageD,
}
BaseA := NewPhysicalDistributionBase(baggages)
BaseB := NewPhysicalDistributionBase([]*Baggage{})
service := NewService()
service.Transport(BaseA, BaseB, baggageC)
service.Transport(BaseA, BaseB, baggageD)
fmt.Println("BaseA has following baggages")
for _, v := range BaseA.baggages {
fmt.Println(v.Name, v.Option)
}
fmt.Println("BaseB has following baggages")
for _, v := range BaseB.baggages {
fmt.Println(v.Name, v.Option)
}
// BaseA has following baggages
// AAA [割れ物]
// BBB [割れ物 冷蔵]
// BaseB has following baggages
// CCC []
// AAA [電気機器]
}
5章リポジトリ
リポジトリとはデータの保管庫
エンティティなどをプログラムが終了しても繰り返し利用できるように、永続化
, 再構築
処理を抽象的に扱うオブジェクト
- 直接エンティティからデータストアに保存でなく、リポジトリを経由させることで柔軟性を与える
- データストアの保存処理など技術的要素の強い部分をリポジトリに隔離することでドメインやサービスのドメイン的な部分が明快になる
リポジトリのインターフェース
インタフェースを挟むことで、例えばmysqlからpostgresqlに途中で変えたりしても
呼び出し側は同じメソッドで呼び出すことが可能
type UserRepository interface {
Save(user User)
Find(name UserName) *User
Exists(user User) bool
}
リポジトリの処理はあくまでオブジェクトの永続化にするべき
上記だとExists
処理はnameを元に同一性判定をするという情報がserviceやentityからリポジトリに移ってしまっている。次のようになるべくドメイン知識はentity, serviceに残してあげる
type UserRepository interface {
Save(user User)
Find(name UserName) *User
- Exists(user User) bool
+ Exists(name UserName) bool
}
インタフェースの実装を作る
これをSQLを利用するRepositoryとして実装してみる
goだとインタフェースの実装は下記のように
import "database/sql"
// Interfaceを実装する実態
type sqlUserRepository struct {
conn *sql.DB
}
// Interfaceを満たすようにレシーバーを実装する, とりあえず仮置き
func (repo *sqlUserRepository) Save(user User) {}
func (repo *sqlUserRepository) Find(name UserName) *User {}
func (repo *sqlUserRepository) Exists(name UserName) bool {}
// Repository実態の生成
// 返り値の型がInterfaceになっている
func NewUserRepository() UserRepository {
conn, err := sql.Open("mysql", "user:password@tcp(host:port)/database")
if err != nil {
return nil
}
return &sqlUserRepository{
conn: conn,
}
}
さらに具体的にメソッドの中身をつくると下記のような
func (repo *sqlUserRepository) Find(name UserName) *User {
var user *User
if err := repo.conn.QueryRow("SELECT * FROM user WHERE name = ? LIMIT 1", name).Scan(&user); err != nil {
return nil
}
return user
}
テストについて
ある程度経験を積んだエンジニアは、おそらく動くだろうという判定をしだす
おそらく動くコードに対して、労力をかけてテストの準備をするのを無駄と判断してテストをせずにプロダクトに投入し出す。
こういったエンジニアが次にするのは、祈りをすること「どうか問題が発生しませんように」
もしこれで成功すると歪な成功体験としてよりテストをしなくなってしまう。
こうなるの避けるためにもテストの効率をあげよう
- 例えばDBの準備は大変、DBを用意せずにテストできるようにしたりとか
- RepositoryでInmemory Repositoryを用意することで、これができたりする。
例えば下のような
// Interfaceを実装する実態
type inMemoryUserRepository struct {
Store map[UserName]*User
}
func (repo *inMemoryUserRepository) Save(user User) {
repo.Store[user.GetUserName()] = &user
}
func (repo *inMemoryUserRepository) Find(name UserName) *User {
return repo.Store[name]
}
func (repo *inMemoryUserRepository) Exists(name UserName) bool {
_, exists := repo.Store[name]
return exists
}
リポジトリはオブジェクトの永続化と再構築に関するふるまいが定義される
永続化処理はエンティティのアトリビュートの一部を変更するようなメソッドは持つべきでなく
エンティティを丸ごと保存するような処理だけ持たせるべき
// これがあるべき姿
func Save(user User) {}
// 特定の属性だけ更新するような処理はエンティティなどに持たせるべき
func UpdateUserName(userID UserID, newName UserName) {}
逆に再構築時は、特定の属性で検索できると良い
func FindByID(id UserID) *User {}
func FindByName(name UserName) *User {}
まとめ
- 特定のインフラストラクチャ/技術など(DBのSQLなど)に依存する処理はなるべくドメインに混ぜずにリポジトリ内で定義する
- さらに開発の初期でまだストアが決まってない場合にインメモリのフェイクリポジトリなどを利用することで開発が進めれたりする(後でインタフェースは同じ別ストア実装を行う)
6章 アプリケーションサービス
ドメインオブジェクトを公開しないという選択肢もある
typescriptでやろうと思ったのでこっちとじ