概要
こんにちは。サーバーサイドエンジニアの窪田です。 これまでの戦術的DDDについて以下のような記事で紹介してきました。
戦術的DDDをGoで実現する【entity編】 - Yappli Tech Blog
戦術的DDDをGoで実現する【Value Object編】 - Yappli Tech Blog
Deep Moduleという観点から戦術的DDDのRepositoryの設計を考えてみた - Yappli Tech Blog
今回は戦術的DDDにおけるトランザクションの扱いについて注目します。 トランザクションは一見インフラ層の関心ごとなのでインフラ層で完結するように思えますが、DDDの本にある例では、ユースケース層で張っているソースコードの例が紹介されています。 なぜ、そのような設計になるのかを考えていきます。
DDDとトランザクションの関係
DDDとトランザクションは実は深い関係にあります。 『実践ドメイン駆動設計』 10章「集約」で述べられているように集約はトランザクションの範囲によって定義されています。
同じ集約についてはトランザクション整合性(強い整合性)をとり、別集約とは結果整合性(弱い整合性)を取るような設計をするように書かれています。
例えば、ユーザーがアイテムを複数所持するというドメインを考えます。 まず、以下のドメインモデル図のように、ユーザー集約とアイテム集約が存在しているようなモデリングをしたとします。(点線が集約境界を示します)。 この時実装は以下のようになります。 user集約とitem集約が存在するのでそれぞれのrepositoryが定義されることになります。 本筋とは関係ないですが、『実践ドメイン駆動設計』に書かれている通り、別集約の紐付きは参照(ID)をインスタンスが保持することで表します。
// user entity type User struct { ID int UserName string ItemIDs []int } // item entity type Item struct { ID int ItemName string } // user repository type UserRepository interface { Get() Save() } // item repository type ItemRepository interface { Get() Save() }
一方、user entity, item entityが同じ集約であるならば
実装は以下のようになります。
// user entity type User struct { ID int UserName string Items []*Item } // item entity type Item struct { ID int ItemName string } // user repository type UserRepository interface { Get() Save() }
user集約はuser entityが集約ルートになり、user entity, item entityは同じトランザクションでUserRepositoryによって永続化されます。
なぜユースケース層でトランザクションを張るのか
一見repository内でトランザクションを張れば良いように見える
前述した実装例を見ると、集約自体がトランザクション範囲を示しているのでそれぞれのrepositoryでトランザクションを張れば良いように見えます。 sqlのクライアントライブラリであるsqlxをヤプリでは使っているので、これを例にします。
type userRepositoryImpl struct { db *sqlx.DB } func NewUserRepository(db *sqlx.DB) UserRepository { return &userRepositoryImpl{ db: db, } } func (u *userRepositoryImpl) Save(ctx context.Context, e *entity.User) error { tx, err := u.db.BeginTxx(ctx, nil) _, err = tx.Exec("UPDATE users SET name = ? WHERE id = ?;", e.Name, e.ID) if err != nil { tx.Rollback() return err } err = tx.Commit() if err != nil { return err } return nil }
のようにrepositoryの実装を定義すると、永続化時にSaveメソッド内でトランザクションを張ることができます。 useCase層からは
e := entity.NewUser() userRepository.Save(ctx, e)
と呼べば良さそうです。(よくない場合があるのを後述します)。
repositoryでトランザクションの扱いを完結すると困ること
上で紹介した方法のままだと実は取得->更新の一連の流れを1個のuseCaseで行う場合に困ることになります。
// repositoryのインスタンス化 var userRepository = NewUserRepository(db) // userの名前変更のuseCase func ChangeName(ctx context.Context, userID int, newName string) error { // user entityのインスタンスを再構築 u, err := userRepository.Get(ctx, userID) if err != nil { return err } // ドメインモデルで定義した振る舞いの実行 err = u.ChangeName(newName) if err != nil { return err } // 永続化 return userRepository.Save(ctx, e) }
以下のようなSQLが発行されます。(id等は例です)。
SELECT * FROM users WHERE id = 1; START TRANSACTION; UPDATE users SET name = "newName" WHERE id = 1; COMMIT;
これで問題ない場合もありますが、プロダクトによっては頻繁にユーザーネームの変更が行われる場合があります。 その場合、
START TRANSACTION; SELECT * FROM users WHERE id = 1 FOR UPDATE; UPDATE users SET name = "newName" WHERE id = 1; COMMIT;
のようにSQLを発行して行ロックをかける必要が出てくるかもしれません。 この時、先ほどのSaveメソッドの中でトランザクションを張る実装では実現できません。
ユースケース層でトランザクションを張る設計例
useCaseでトランザクションを張る一例を示します。 『実践ドメイン駆動設計』『ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本』等では、@Transactionalアノテーションをメソッドに当てる例が示されています。 しかし、Goではそんなことはできないので、自前でトランザクションを開く必要があります。
domain層で以下のようにインターフェイスを定義し、
// domain層 interface type TransactionRepository interface { Transaction(ctx context.Context, f func(tx *sqlx.Tx) error) error }
infrastructure層で以下のようにインターフェイスを満たす実装をします。
type transactionRepositoryImpl struct { db *sqlx.DB } func NewTransactionRepository(db *sqlx.DB) TransactionRepository { return &transactionRepositoryImpl{ db: db, } } func (t *transactionRepositoryImpl) Transaction(ctx context.Context, f func(tx *sqlx.Tx) error) error { tx, err := t.db.BeginTxx(ctx, nil) if err != nil { return err } err = f(tx) if err != nil { tx.Rollback() return err } err = tx.Commit() if err != nil { return err } return nil }
これによって、useCaseは以下のようにかけます。
// repositoryのインスタンス化 var userRepository = NewUserRepository(db) var transactionRepository = NewTransactionRepository(db) func ChangeName(ctx context.Context, userID int, newName string) error { err := transactionRepository.Transaction(ctx, func(tx *sqlx.Tx) error { // repositoryに外からtransactionを与える u, err := userRepository.Get(ctx, tx, userID) if err != nil { return err } err = u.ChangeName(newName) if err != nil { return err } // repositoryに外からtransactionを与える return userRepository.Save(ctx, tx, e) }) if err != nil { return err } return nil }
useCase層でtransactionを張る様子がわかると思います。 この場合、repositoryの各メソッドにtransaction構造体を渡すことになります。 (これはこれでテストが書きやすいというメリットがあったり、インターフェイスが複雑になるというデメリットがあったりしますがここでは深掘りません)。 何はともあれ実現できました。DDDの本に書いてある形に近づきました。
DBのクライアントライブラリがドメイン層に登場するという課題
Goの実装でDDDの本に書いてあるようなuseCase層でトランザクションを張る方法を示しましたが、課題もあります。
// domain層 interface type TransactionRepository interface { Transaction(ctx context.Context, f func(tx *sqlx.Tx) error) error }
というinterfaceの書き方を見るとわかりますが、domain層がsqlxパッケージに依存しています。 sqlxはDBのクライアントライブラリなので、本来domainに関係なさそうなものがdomain層に侵食してくることになります。 例えば、sqlxの使用をやめ、他のライブラリに変える場合もこのinterfaceも変更することになりdomain層に影響が出ます。 本で紹介されているようなKotlinの@Transactionlのような仕組みも本質的には同じなのですが、仕組み上、気にならなかったり誤魔化されたりしています。
この課題に対しては
- 外部ライブラリにはそもそも依存している前提でプロダクトは成り立っているので気にしない
- うまくinterfaceを作り、sqlxの存在を隠蔽する
が思いつきます。 後者については、結局sqlxをラップしたinterfaceを定義する必要がどこかではあるので、個人的には外部ライブラリ依存については許容して良いのではないかと思っています。
まとめ
- DDDとトランザクションの関係について確認しました。
- UseCase層でトランザクションを張る理由を示しました。
- 戦術的DDDの実装ではインフラのトランザクションの機構に依存した記述になる部分も出てくることを確認しました。
実はドメイン駆動と言いながら、集約境界についてはインフラの都合が大きく関わってきて、切り離せないのが味噌であることを感じられる例でした。 ヤプリのエンジニアは日々戦術的DDDを実現する上でGoならではの悩みについて考えていたりします。
この辺りに興味があれば、是非一度カジュアル面談でお話できればと思います!