Goでファクトリを実装(「ドメイン駆動設計入門」Chapter9)
概要
戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter9 のファクトリ(Factory)の実装します。
実装したソースコードは以下です。
参考にしたサンプルコードは以下です。
ファクトリ
ファクトリについて説明します。
「ドメイン駆動設計入門」ではファクトリを以下のように紹介していました。
複雑な道具はその生成過程も複雑です。ともすれば生成過程がある種の知識となります。プログラムにおいてもこれは同じで、複雑なオブジェクトはその生成過程も複雑な処理になることがあります。そうした処理はモデルを表現するドメインオブジェクトの趣旨をぼやけさせます。かといって、その生成をクライアントに押し付けるのはよい方策ではありません。生成処理自体がドメインにおいて意味をもたなかったとしても、ドメインを表現する層の責務であることには変わりないのです。
求められることは複雑なオブジェクトの生成処理をオブジェクトとして定義することです。この生成の責務とするオブジェクトのことを、道具に作る工場になぞらえて「ファクトリ」といいます。ファクトリはオブジェクトの生成に関わる知識がまとめられたオブジェクトです。
複雑な生成処理をドメインオブジェクト内に書くと、責務がわかりづらくなるためほかのオブジェクトに処理を分けたのがファクトリです。
保存時はファクトリで先に生成してからリポジトリを介して永続化層に保存、取得時はリポジトリで永続化層から取得してからファクトリで生成、といった流れで使われます。
ファクトリは新しい集約の生成時には集約を生成する役割を持ち、データベースから集約を取り出すときには再構成する役割を持ちます。
実装
本記事では簡略化と書籍の説明に同意できなかった部分がありました。
そのため、UserId
(エンティティUser
の一意な識別子)を生成するフローが複雑であると仮定して、UserFactory
メソッドを実装しました。
具体的に同意できなかった部分は、考察で説明します。
User
を DB に保存する処理までを実装します。
ディレクトリ構成
同様に簡略化のため、アプリケーションサービス層、インフラ層で記述するべき処理をすべて、ドメイン層に記述しています。
本筋から逸れるため、本記事ではテスト、DB(postgresql)、docker、go-migrate の説明を省略します。
.
├── Makefile
├── README.md
├── db-migration.sh
├── docker
│ ├── go
│ │ └── Dockerfile
│ └── postgres
│ └── Dockerfile
├── docker-compose.yml
├── domain
│ └── model
│ └── user
│ ├── user.go
│ ├── user_test.go
│ ├── userapplicationservice.go
│ ├── userapplicationservice_test.go
│ ├── userfactory.go
│ ├── userfactory_test.go
│ ├── userid.go
│ ├── userid_test.go
│ ├── username.go
│ ├── username_test.go
│ ├── userrepository.go
│ ├── userrepository_test.go
│ ├── userservice.go
│ └── userservice_test.go
├── go.mod
├── go.sum
├── main.go
└── migrations
├── 000001_user.down.sql
└── 000001_user.up.sql
7 directories, 25 files
ドメインモデル図
UserFactory
まず、UserFactory
を解説します。
UserFactory
にはインタフェースUserFactorier
を実装します。
メソッドにはCreate
を記述します。
抽象に依存させ、ほかの実装やテストを容易にするためインタフェースを用意しました。詳細はApplicationService
で確認します。
UserFactorier
の実装クラスUserFactory
に実際の処理を記述します。
今回はUserId
を生成するときにUUID の生成が複雑だと仮定しています。
実際にはライブラリを使うため簡潔な処理になっています。
引数には値オブジェクトUserName
を指定して、値オブジェクトUserId
を生成した後、エンティティUser
にして返します。
呼び出した側はこれらの処理が隠蔽されているため簡潔な処理にできます。
package user
import (
"github.com/google/uuid"
)
type UserFactorier interface {
Create(UserName) (*User, error)
}
type UserFactory struct{}
func NewUserFactory() (*UserFactory, error) {
return &UserFactory{}, nil
}
func (uf *UserFactory) Create(name UserName) (*User, error) {
// uuidの生成が複雑な処理だと仮定
uuidV4, err := uuid.NewRandom()
if err != nil {
return nil, err
}
userId, err := NewUserId(uuidV4.String())
if err != nil {
return nil, err
}
user, err := NewUser(*userId, name)
if err != nil {
return nil, err
}
return user, nil
}
UserApplicationService
続いて、UserFactory
に呼び出し側である、UserApplicationService
を解説します。
メソッドRegister
でエンティティUser
の作成、保存します。
1. エンティティUserを生成
で、UserId
を渡すだけでUser
が作成され、UUID を生成する処理が隠蔽されています。このようにファクトリは複雑な処理を隠蔽できることがわかります。
2. エンティティUserを保存
で、作成したエンティティUser
を保存しています。保存時にはファクトリがエンティティを生成し、リポジトリが保存する流れになることがわかりました。
package user
import (
"fmt"
"log"
)
type UserApplicationService struct {
userFactory UserFactorier
userRepository UserRepositorier
userService UserService
}
func NewUserApplicationService(userFactory UserFactorier, userRepository UserRepositorier, userService UserService) (*UserApplicationService, error) {
return &UserApplicationService{userFactory: userFactory, userRepository: userRepository, userService: userService}, nil
}
func (uas *UserApplicationService) Register(name string) (err error) {
defer func() {
if err != nil {
err = &RegisterError{Name: name, Message: fmt.Sprintf("userapplicationservice.Register err: %s", err), Err: err}
}
}()
userName, err := NewUserName(name)
if err != nil {
return err
}
// 1. エンティティUserを生成
user, err := uas.userFactory.Create(*userName)
if err != nil {
return err
}
isUserExists, err := uas.userService.Exists(user)
if err != nil {
return err
}
if isUserExists {
return fmt.Errorf("user name of %s is already exists.", name)
}
// 2. エンティティUserを保存
if err := uas.userRepository.Save(user); err != nil {
return err
}
log.Printf("user name of %s is successfully saved", name)
return nil
}
type RegisterError struct {
Name string
Message string
Err error
}
func (err *RegisterError) Error() string {
return err.Message
}
そのほかのドメインオブジェクト UserRepository、UserService、User、UserId、UserName
実装したソースコードにはほかにもドメインオブジェクトを用意しています。
詳しくは説明しませんが、もし興味があれば自分の GitHub のリポジトリと過去記事を参考にしてみてください。
ドメインオブジェクト | ドメインオブジェクト名 | 役割 | 過去記事 |
---|---|---|---|
UserRepository |
リポジトリ | DB とUser のやりとり(保存、検索、etc...)を隠蔽する。 |
Go でリポジトリを実装(「ドメイン駆動設計入門」Chapter5) |
UserService |
ドメインサービス |
User が振る舞いとして持つべきでないステートレスな処理を持つ。 |
Go でドメインサービスを実装(「ドメイン駆動設計入門」Chapter4) |
User |
エンティティ | 集約ルートになる可能性がある。一意な識別子を持ち、ほかの属性を可変にしても良い | Go でエンティティを実装(「ドメイン駆動設計入門」Chapter3) |
UserId UserName
|
値オブジェクト | 属性が不変。エンティティの一意な識別子になることがある。 | Go で値オブジェクトを実装(「ドメイン駆動設計入門」Chapter2) |
動作確認
今までの実装から動作確認を行います。
docker-compose を使用します。
main.go
main.go
を動作確認のためだけに作成しました。
DB との接続を main 関数の中で実装し、ApplicationService のメソッドであるRegister
で実装します。
ユーザー名user-name
を保存する処理になります。ユーザー ID は UUID が生成されます。
package main
import (
"database/sql"
"fmt"
"log"
"os"
"github.com/msksgm/go-itddd-09-factory/domain/model/user"
)
func main() {
uri := fmt.Sprintf("postgres://%s/%s?sslmode=disable&user=%s&password=%s&port=%s&timezone=Asia/Tokyo",
os.Getenv("DB_HOST"), os.Getenv("DB_NAME"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_PORT"))
db, err := sql.Open("postgres", uri)
if err != nil {
panic(err)
}
if err := db.Ping(); err != nil {
panic(err)
}
log.Println("successfully connected to database")
userFactory, err := user.NewUserFactory()
if err != nil {
panic(err)
}
userRepository, err := user.NewUserRepository(db)
if err != nil {
panic(err)
}
userService, err := user.NewUserService(userRepository)
if err != nil {
panic(err)
}
userApplicationService, err := user.NewUserApplicationService(userFactory, userRepository, *userService)
if err != nil {
panic(err)
}
if err := userApplicationService.Register("test-user"); err != nil {
log.Fatal(err)
}
}
コンテナを起動・マイグレーション
実行準備をします。
> make up
docker compose up -d
# 完了までまつ
> make run-migration
docker compose exec app bash db-migration.sh
1/u user (13.817ms)
実行
動作確認をします。
最初の実行では、test-user
が登録されていないため、登録されます。
> make run
docker compose exec app go run main.go
2022/04/14 21:36:19 successfully connected to database
2022/04/14 21:36:19 user name of test-user is successfully saved
2 回目では、test-user
がすでに登録されているため、エラーがログに出力されます。
> make run
docker compose exec app go run main.go
2022/04/14 21:36:30 successfully connected to database
2022/04/14 21:36:30 userapplicationservice.Register err: user name of test-user is already exists.
exit status 1
make: *** [run] Error 1
適切に動作することを確認しました。
考察
今回実装するにあたっての考察を記述していきます。
個人的な考察なので飛ばしてもらってかまわないです。
命名規則について
ファクトリはドメイン層にあるドメインオブジェクトの 1 つです。
そのため、ドメインの意図を反映させるために名前にはユビキタス言語を採用したほうが良いです。
今回は、サンプルコードに従いUserFactory
という名前にしましたが、Factory
という言葉はユビキタス言語に含まれない限り避けた方が良いです。
なぜなら、UserFactory
という名前では、UserFactory
の中に多くのファクトリメソッドを実装してしまい、責務が不明瞭になります。
たとえば、管理者ユーザーと一般ユーザーがいる場合は、UserFactory
の中にCreateAdmin
、CreateUser
という名前のファクトリメソッドを作ってはいけません。
その場合は、AdminUser
、CommonUser
という名前のオブジェクトを作成します。
その後はコンストラクタを使うか、ファクトリメソッドを実装するか、という方針を決めます。
ドメイン層にある限りユビキタス言語から命名し、ドメインの意図を明確にした名前をつけるべきです。機会的に命名するべきではないというのが自分の意見です。
UserId の生成方法について
今回、UUID を生成させる処理は複雑な責務として分離させました。
「ドメイン駆動設計入門」では、ファクトリ内で DB にアクセスをして、シーケンスを使ってテーブルが userId を生成する処理をしていました。
つまり、DB との接続の役割をもつリポジトリを介さずに直接ドメイン層からアクセスしていました。これは、ドメイン駆動設計では普通は実装しないパターンです。
リポジトリを介さなかった理由について、「ドメイン駆動設計入門」では以下のように記述されていました。
ファクトリとは少し外れますが、リポジトリに採番処理を行うメソッドを用意するパターンもあります。
(中略)
このパターンはその手軽さからして受け入れられやすいものであることも確かです。開発チームでの合意が取れているのであればこのパターンを採用することは問題ではありません。
筆者の個人的な感覚では、そもそもリポジトリはデータの永続化と再構築を行うオブジェクトです。採番処理にまで手を伸ばすのは少し責務を広げ過ぎているように感じるため推奨していません。
私は、この方法について反対です。
筆者の個人的な感覚では、そもそもリポジトリはデータの永続化と再構築を行うオブジェクトです。採番処理にまで手を伸ばすのは少し責務を広げ過ぎているように感じるため推奨していません。
の部分については理由を理解できますが、それでも反対する理由は 2 つあります。
1 つめは、ドメインオブジェクトの 1 つであるはずのファクトリが DB という技術的な詳細を知ることになるからです。これはドメイン駆動設計の思想に反しています。
2 つめは、リポジトリの実装を置くインフラ層はアーキテクチャにおける外部に位置しています。つまりインフラ層におくことで、腐敗防止層のような役割を持ちドメイン層に影響を与えなくなります。ドメインに影響を与えなくなることはドメイン駆動設計において良い方針だと考えます。
以上の理由から同意ができなかったため、今回はシーケンスによる採番処理を実装せず、UUID の生成を複雑な処理と仮定して実装しました。
まとめ
サンプルコードを参考しながら、Go で DDD の戦術的パターンの 1 つである、ファクトリを実装しました。
複雑な処理を隠蔽することで、ソースコードの意図を明確にすることがファクトリの役割です。
これらを実装しながら確認できました。
サンプルコードと書籍に書かれた、命名規則と DB の接続についての観点から反対意見を持ちました。
そのため、少し修正を加えた実装になっています。この点について意見がありましたら、コメントのほどお願いします。
Discussion