Goで集約を実装(「ドメイン駆動設計入門」Chapter12)
概要
戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter12 の集約(Aggregate)の実装します。
サンプルコードを参考に作成しましたが、集約を正しく分けられているのか自信がないので、あくまで実装してみたの解説になります。
実装したソースコードは以下です。
参考にしたサンプルコードは以下です。
集約
まず、集約について説明します。
「ドメイン駆動設計入門」では集約を以下のように紹介していました。
オブジェクト指向プログラミングでは複数のオブジェクトがまとめられ、ひとつの意味をもったオブジェクトが構築されます。こうしたオブジェクトのグループには維持されるべき不変条件が存在します。
不変条件は常に維持されることが求められますが、オブジェクトのデータを変更しようとする操作を無制限に受け入れてしまうと、それはむずかしくなります。オブジェクトの操作には秩序が必要です。
集約は不変条件を維持する単位として切り出され、オブジェクトの操作に秩序をもたらします。
集約には境界とルートが存在します。集約の境界は集約に何が含まれるのかを定義するための境界です。集約のルートは集約に含まれる特定のオブジェクトです。
外部からの集約に対する操作はすべて集約ルートを経由して行われます。集約の境界内に存在するオブジェクトを外部にさらけ出さないことで、集約内の不変条件を維持できるようにしているのです。
ドメイン駆動設計では、トランザクションを実行する際には、集約単位で実行します。実行する際には集約ルートに対して実行します。
また、ここでいう不変性とは「値オブジェクトは不変である」の不変ではなく、整合性の意味で使われます。
集約ルートはエンティティが担います。
サンプルコードでは、エンティティであるUser
とCircle
がそれぞれ担当していました。
集約をどのように分けるかは、モデリングが必要だと考えています。
ユースケースを適切に判断しないと、整合性を明確にできず、適切な集約にはならないと考えているからです。
逆に、UI やクエリが複雑になることを回避するために、あえて巨大な集約を作ることもあります。
実装
本記事では、2 つの集約ルートCircle
とUser
を実装して、集約の動きについて確認します。
ドメインモデル図
ディレクトリ構成
ディレクトリ構成は以下にしました。
domain/model
配下にドメインごとのオブジェクトを配置します。
リポジトリのインタフェースをドメイン層に、実装した構造体をインフラ層に配置します。
.
├── Makefile
├── application
│ └── circleapplicationservice.go
├── db-migration.sh
├── docker
│ ├── go
│ │ └── Dockerfile
│ └── postgres
│ └── Dockerfile
├── docker-compose.yml
├── domain
│ └── model
│ ├── circle
│ │ ├── circle.go
│ │ ├── circle_test.go
│ │ ├── circleid.go
│ │ ├── circleid_test.go
│ │ ├── circlename.go
│ │ ├── circlename_test.go
│ │ ├── circlerepository.go
│ │ ├── circleservice.go
│ │ └── circleservice_test.go
│ └── user
│ ├── user.go
│ ├── user_test.go
│ ├── userid.go
│ ├── userid_test.go
│ ├── username.go
│ └── username_test.go
├── go.mod
├── go.sum
├── infrastructure
│ └── persistence
│ ├── circlerepository.go
│ └── circlerepository_test.go
├── main.go
└── migrations
├── 000001_user.down.sql
├── 000001_user.up.sql
├── 000002_userCircles.down.sql
├── 000002_userCircles.up.sql
├── 000003_circles.down.sql
└── 000003_circles.up.sql
11 directories, 32 files
Circle
まず、Circle
を見ていきます。
Circle
の属性には、CircleId
、CircleName
、User
があります。
値オブジェクトCircleId
、CircleName
は自身の集約(Circle 集約)から、User
(user.User
)はほかの集約(User 集約)の集約ルートから取得しています。
集約ルートCircle
が、属性をまとめ、整合性の担保をします。
Circle
に命令をするのが、CircleApplicationService
になります。
package circle
import "github.com/msksgm/go-itddd-12-aggregate/domain/model/user"
type Circle struct {
Id CircleId
Name CircleName
Owner user.User
Members []user.User
}
func NewCircle(id CircleId, name CircleName, owner user.User, members []user.User) (*Circle, error) {
return &Circle{Id: id, Name: name, Owner: owner, Members: members}, nil
}
func (circle *Circle) IsFull() bool {
return len(circle.Members) >= 29
}
type CircleIsFullError struct {
CircleId CircleId
Message string
}
func (cife *CircleIsFullError) Error() string {
return cife.Message
}
func (circle *Circle) Join(newMember *user.User) error {
if circle.IsFull() {
return &CircleIsFullError{CircleId: circle.Id, Message: "cannnot join member because the circle is full"}
}
circle.Members = append(circle.Members, *newMember)
return nil
}
type MemberIsNotFoundError struct {
MemberId user.UserId
Message string
}
func (minfe *MemberIsNotFoundError) Error() string {
return minfe.Message
}
func (circle *Circle) ChangeMemberName(memberId *user.UserId, changedUserName *user.UserName) error {
for i, member := range circle.Members {
if member.Id().Equals(memberId) {
circle.Members[i].ChangeName(*changedUserName)
return nil
}
}
return &MemberIsNotFoundError{MemberId: *memberId, Message: "member is not found"}
}
CircleApplicationService
CircleApplicationService
をみます。簡略化のため、CircleId
を固定にしています。
CircleApplicationService
は集約ルートを操作するメソッドを外部(ポート&アダプタ)に提供します。
ここでは、集約ルートはCircleApplicationService
に該当し、操作するメソッドはRegister
、Get
が該当します。
集約がほかの集約を直接操作しないため、アプリケーションサービスが操作します。
package application
import (
"fmt"
"log"
"github.com/msksgm/go-itddd-12-aggregate/domain/model/circle"
"github.com/msksgm/go-itddd-12-aggregate/domain/model/user"
)
type CircleApplicationService struct {
circleRepository circle.CircleRepositorier
circleService circle.CircleService
}
func NewCircleApplicationService(circleRepository circle.CircleRepositorier, circleService circle.CircleService) (*CircleApplicationService, error) {
return &CircleApplicationService{circleRepository: circleRepository, circleService: circleService}, nil
}
func (cas *CircleApplicationService) Register(circleName string) (err error) {
defer func() {
if err != nil {
err = &RegisterError{Name: circleName, Message: fmt.Sprintf("circleapplicationservice.Register err: %s", err), Err: err}
}
}()
newCircleId, err := circle.NewCircleId("test-circle-id")
if err != nil {
return nil
}
newCircleName, err := circle.NewCircleName(circleName)
if err != nil {
return nil
}
ownerId, err := user.NewUserId("ownerId")
if err != nil {
return nil
}
ownerName, err := user.NewUserName("ownerName")
if err != nil {
return nil
}
owner, err := user.NewUser(*ownerId, *ownerName)
if err != nil {
return nil
}
memberId, err := user.NewUserId("memberId")
if err != nil {
return nil
}
memberName, err := user.NewUserName("memberName")
if err != nil {
return nil
}
member, err := user.NewUser(*memberId, *memberName)
if err != nil {
return nil
}
members := []user.User{*owner, *member}
newCircle, err := circle.NewCircle(*newCircleId, *newCircleName, *owner, members)
if err != nil {
return nil
}
isCircleExists, err := cas.circleService.Exists(newCircle)
if err != nil {
return err
}
if isCircleExists {
return fmt.Errorf("circleName of %s is already exists.", circleName)
}
if err := cas.circleRepository.Save(newCircle); err != nil {
return err
}
log.Println("success fully saved")
return nil
}
type RegisterError struct {
Name string
Message string
Err error
}
func (err *RegisterError) Error() string {
return err.Message
}
type CircleData struct {
Id circle.CircleId
Name circle.CircleName
Owner user.User
Members []user.User
}
func (cas *CircleApplicationService) Get(circleName string) (_ *CircleData, err error) {
targetName, err := circle.NewCircleName(circleName)
if err != nil {
return nil, err
}
circle, err := cas.circleRepository.FindByCircleName(targetName)
if err != nil {
return nil, err
}
return &CircleData{Id: circle.Id, Name: circle.Name, Owner: circle.Owner, Members: circle.Members}, nil
}
他のドメインオブジェクトについて
実装したソースコードにはほかにもドメインオブジェクトを用意しています。
アプリケーションサービスはこれらを組み合わせて振る舞いを実現しています。
詳細は説明しませんが、もし興味があれば自分の GitHub のリポジトリと過去記事を参考にしてみてください。
ドメインオブジェクト | ドメインオブジェクト名 | 役割 | 過去記事 |
---|---|---|---|
CircleApplicationService |
アプリケーションサービス | アプリケーションの振る舞いになるオブジェクト。集約をまとめ、メソッドをポート&アダプタに公開する。 | Go でアプリケーションサービスを実装(「ドメイン駆動設計入門」Chapter6) |
UserRepository |
リポジトリ | DB とのやりとり(保存、検索、etc...)を隠蔽する。 | Go でリポジトリを実装(「ドメイン駆動設計入門」Chapter5) |
CircleService UserService
|
ドメインサービス | ドメインオブジェクトが振る舞いとして持つべきでないステートレスな処理を持つ。 | Go でドメインサービスを実装(「ドメイン駆動設計入門」Chapter4) |
Circle User
|
エンティティ | 集約ルートになる可能性がある。一意な識別子を持ち、ほかの属性を可変にしても良い | Go でエンティティを実装(「ドメイン駆動設計入門」Chapter3) |
CircleId CircleName UserId UserName
|
値オブジェクト | 属性が不変。エンティティの一意な識別子になることがある。 | Go で値オブジェクトを実装(「ドメイン駆動設計入門」Chapter2) |
動作確認
実装から動作確認を行います。
docker-compose を使用します。
main.go
アプリケーションサービスを呼び出さす処理をmain.go
に記述します。
実装の順番は、DB の接続、アプリケーションサービスにドメインサービスとリポジトリの挿入、アプリケーションの振る舞いを実行、です。
コマンドライン引数から、ユースケースを切り替えられるように実装しました。
実行できるユースケースはregister
(登録)、get
(取得)です。
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"os"
_ "github.com/lib/pq"
"github.com/msksgm/go-itddd-12-aggregate/application"
"github.com/msksgm/go-itddd-12-aggregate/domain/model/circle"
"github.com/msksgm/go-itddd-12-aggregate/infrastructure/persistence"
)
var command = flag.String("usecase", "", "usercase of application")
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")
circleRepository, err := persistence.NewCircleRepository(db)
if err != nil {
panic(err)
}
circleService, err := circle.NewCircleService(circleRepository)
if err != nil {
panic(err)
}
circleApplicationService, err := application.NewCircleApplicationService(circleRepository, *circleService)
if err != nil {
panic(err)
}
flag.Parse()
log.Println(*command)
switch *command {
case "register":
if err := circleApplicationService.Register("test-circle-name"); err != nil {
log.Println(err)
}
case "get":
circleData, err := circleApplicationService.Get("test-circle-name")
if err != nil {
log.Println(err)
}
log.Println(circleData)
default:
log.Printf("%s is not command. choose in ('register', 'get', 'update', 'delete')", *command)
}
}
コンテナを起動・マイグレーション
実行準備をします。
> make up
docker compose up -d
# 完了までまつ
マイグレーションにはuser
テーブル、circles
> make run-migration
docker compose exec app bash db-migration.sh
1/u user (15.619ms)
2/u userCircles (18.026ms)
3/u circles (23.721ms)
無理やり動作確認をするために、users
に初期値をいれてマイグレーションをします。
BEGIN;
CREATE TABLE IF NOT EXISTS users(
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
INSERT INTO users(id, name) VALUES ('ownerId', 'ownerName');
INSERT INTO users(id, name) VALUES ('userId', 'userName');
COMMIT;
実行
動作確認します。
実行の順番は Register(1 回目)、Register(2 回目)、Get を実施します。
登録 1 回目は成功します。
> make run usecase=register
docker compose exec app go run main.go -usecase=register
2022/04/27 22:34:46 successfully connected to database
2022/04/27 22:34:46 register
2022/04/27 22:34:46 success fully saved
登録 2 回目はすでに登録されているので、失敗します。
> make run usecase=register
docker compose exec app go run main.go -usecase=register
2022/04/27 22:35:18 successfully connected to database
2022/04/27 22:35:18 register
2022/04/27 22:35:18 circleapplicationservice.Register err: circleName of test-circle-name is already exists.
取得処理では、1 回目に登録したオブジェクトが返されていることがわかります。
> make run usecase=get
docker compose exec app go run main.go -usecase=get
2022/04/27 22:38:56 successfully connected to database
2022/04/27 22:38:56 get
2022/04/27 22:38:56 &{{test-circle-id} {test-circle-name} {{ownerId} {ownerName}} [{{ownerId} {ownerName}}]}
考察
今回実装するにあたっての考察を記述してきます。
個人的な考察なので飛ばしてもらってかまわないです。
他の集約の参照の方法について
「ドメイン駆動設計入門」では、ほかの集約を参照する際に、直接集約を保持するやり方を解説します。
しかし、私はエンティティの一意な識別子を参照するべきだと考えます。
識別子のみを参照するのならば、集約どうしが密結合になるのを防ぎ、複雑なオブジェクトになるのを防ぐことができるからです。
一応、「ドメイン駆動設計入門」では以下のことが記述していました。
しかし、今までの説明がアンチパターンをとして解説するには、あまりに長すぎる前置きだと感じました。
これまで何度か説いてきたように、そもそもできてしまうことを問題視する考えもあります。つまり Circle オブジェクトは User インスタンスをコレクションで保持していて、プロパティを経由してそのメソッドを呼び出すことが可能であることこそが問題であると見做す考えです。
変更しないことを不文律として課すよりももっと有効な手段はないでしょうか。
もちろんあります。それはとても単純なもので、つまりインスタンスをもたない選択肢です。インスタンスをまたんければメソッドを呼び出しようがありません。インスタンスをもたなきけれど、それを保持しているように見せかける、そんな便利なものがエンティティにありました。そう、識別子です。
Go で実装するときに悩んだこと
ここからは Go で実装するときに悩んだこと、できなかったこと、反省点です。
構造体の属性の公開範囲について
リポジトリで、DB から取得した値をマッピングするには、エンティティを属性を公開しなければいけませんでした。
そのため、すべての値を可変になってしまいました。
すべてのオブジェクトを、同じ package に配置すれば解決できますが、ディレクトリ構成に意味がなくなったり、依存性が分離できているのかわからなくなってしまいます。
現状解決策が思いつかないので、Go で実装する上で不向きな点に感じられました。
rows, err := tx.Query("SELECT c.id, c.circlename, c.owner_id, u.id, u.name from circles c JOIN userCircles uc ON c.id = uc.circle_id JOIN users u ON u.id = uc.user_id WHERE c.circlename = $1", circleName.Value)
if err != nil {
return nil, &FindByCircleNameQueryError{CircleName: circleName.Value, Message: "error is occured in circlerepository.FindByCircleName", Err: err}
}
defer rows.Close()
// DB から取得した値を構造体の属性にマッピングするには、構造体の属性を公開する必要があった
findCircleId := &circle.CircleId{}
findCircleName := &circle.CircleName{}
ownerId := &user.UserId{}
memberId := &user.UserId{}
memberName := &user.UserName{}
members := []user.User{}
for rows.Next() {
err := rows.Scan(&findCircleId.Value, &findCircleName.Value, &ownerId.Value, &memberId.Value, &memberName.Value)
if err != nil {
return nil, err
}
members = append(members, user.User{UserId: *memberId, Name: *memberName})
}
err = rows.Err()
if err != nil {
return nil, err
}
デメテルの法則について
「ドメイン駆動設計入門」と「実践ドメイン駆動設計」では、集約を実装するさいにデメテルの法則に従うように推奨されています。
「ドメイン駆動設計入門」では、以下のように紹介されています。
オブジェクト同士が無秩序にメソッドを呼び出し合うと、不変条件を維持することは難しくなります。「デメテルの法則」はオブジェクト同士のメソッド呼び出しに秩序をもたらすガイドラインです。
デメテルの法則によると、メソッドを呼び出すオブジェクトは次の 4 つに限定されます。
- オブジェクト自身
- 引数として渡されたオブジェクト
- インスタンス変数
- 直接インスタンス化したオブジェクト
たとえば車を運転するときタイヤに対して直接命令しないのと同じように、オブジェクトのフィールドに直接命令をするのではなく、それを保持するオブジェクトに対して命令を行い、フィールドは保持しているオブジェクト自身が管理すべきだということです。
本来であれば、これらを守るべきなのですが、本記事の実装では守れていない部分が多くあります。
これは、完全に自分の実力不足です。
まとめ
サンプルコードを参考しながら、Go で DDD の戦術的パターンの 1 つである、集約を実装しました。
集約はほかの集約を参照するときには集約ルートを参照し、更新は集約単位で実施します。
これらの動作を(一応)サンプルコードと実装で確認できました。
とりあえず、実装してみましたが実力不足でうまくまとまらなくなってしまいました。
Go の集約のまとめ方について良い方法を知っている方がいましたらコメントのほどお願いします。
Discussion