レイヤードアーキテクチャのルールを守りつつ、RDBのトランザクションを管理する方法は悩みの種になるポイントだと思います。 本記事では「口座の送金処理*」を題材に、Golang + GORM を使った4つのトランザクション管理パターンをまとめてみます
*送金元口座から残高を引き出し、送金先口座に入金する処理の一貫性を保証する話です。失敗が発生した場合は操作をロールバックする必要があります
前提
本記事では、レイヤードアーキテクチャの構成を以下のように定義します。特に、Application, Domain, Infrastructure層についての実装を紹介していきます
- Presentation層: UIやAPIレイヤー、ユーザー入力や出力を扱う層
- Application層: アプリケーション全体のビジネスロジックを統括する層
- Domain層: エンティティや集約、ドメインサービスなど、ビジネスルールそのものを保持する層
- infrastructure層: DBや外部サービスとのやり取りを担当する層
今回の送金処理では、口座情報Account
をエンティティとします。このエンティティではDeposit
とWithdraw
という操作を定義します
type Account struct {
ID int
Balance int
}
// 口座に入金する
func (a *Account) Deposit(amount int) {
a.Balance += amount
}
// 口座から引き出す
func (a *Account) Withdraw(amount int) error {
if a.Balance < amount {
return errors.New("残高不足")
}
a.Balance -= amount
return nil
}
1. Repository内で管理する
素直なやり方は、Repository内でトランザクションを直接扱うパターンです
このパターンでは、Repositoryにユースケース固有の処理を定義し、内部でトランザクションを開始します。 全体的にシンプルな実装になりますが、Repositoryがユースケース固有の処理を抱えることで肥大化し、Application層が薄くなる傾向があります。 また、Repositoryがデータの永続化・取得以外の責務を持つことになるため、責務の分離が難しくなります
- メリット: 実装がシンプル
- デメリット: Repositoryがユースケース固有の処理を抱えて肥大化し、Application層が薄くなる
送金処理の実装
(以降、一部省略したコードを記載します)
// ----------------------------------------------------------------
// domain
// ----------------------------------------------------------------
// repository.go
type AccountRepository interface {
FindByID(ctx context.Context, id int) (*Account, error)
Save(ctx context.Context, account Account) error
Transfer(ctx context.Context, fromID, toID, amount int) error // Repositoryで専用のメソッドが必要
}
// ----------------------------------------------------------------
// infrastructure
// ----------------------------------------------------------------
// repository.go
func (a *accountRepositoryImpl) Transfer(ctx context.Context, fromID, toID, amount int) error {
return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 口座情報を取得
fromAccount, err := a.WithTx(tx).FindByID(ctx, fromID)
if err != nil {
return err
}
toAccount, err := a.WithTx(tx).FindByID(ctx, toID)
if err != nil {
return err
}
// 送金処理
if err := fromAccount.Withdraw(amount); err != nil {
return err
}
toAccount.Deposit(amount)
// 口座情報を保存
if err := a.WithTx(tx).Save(ctx, *fromAccount); err != nil {
return err
}
if err := a.WithTx(tx).Save(ctx, *toAccount); err != nil {
return err
}
return nil
})
}
// ----------------------------------------------------------------
// application
// ----------------------------------------------------------------
// usecase.go
type AccountUsecase struct {
ar domain.AccountRepository
}
func (a *AccountUsecase) Transfer(ctx context.Context, fromID, toID, amount int) error {
// 必要なロジックがrepositoryに含まれているため、application層が薄くなる
return a.ar.Transfer(ctx, fromID, toID, amount)
}
2. DIで管理する
次に、Transactionオブジェクトtx
を Dependency Injection (DI) で渡すパターンです
このパターンでは、トランザクションをRepositoryの外 (今回はApplication層) で管理し、Repositoryではtx
オブジェクトを受け取るようにします。
トランザクションの開始はApplication層のTxManager
が制御し、DoInTx
メソッド内にロジックを記述します。
すると、Application層にロジックを切り出せるようになり、Repositoryの責務をデータの永続化・取得に集中させられます。
一方、tx
オブジェクトを受け取るWithTx
系のメソッドが必要になるため、Repositoryのインターフェースが肥大化する傾向があります
- メリット: Repositoryの責務をデータの永続化・取得に集中できる
- デメリット: Repositoryのインターフェースが肥大化
TxManagerの実装
// ----------------------------------------------------------------
// domain
// ----------------------------------------------------------------
// transaction.go
type Tx any
// ----------------------------------------------------------------
// infrastructure
// ----------------------------------------------------------------
// transaction_manager.go
type txManager struct {
db *gorm.DB
}
func (tm *txManager) DoInTx(ctx context.Context, fn application.TxFunction) error {
return tm.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := fn(ctx, tx); err != nil {
return err
}
return nil
})
}
func ExtractTx(_tx domain.Tx) (*gorm.DB, error) {
tx, ok := _tx.(*gorm.DB)
if !ok {
return nil, errors.New("mysql Tx is invalid")
}
return tx, nil
}
// ----------------------------------------------------------------
// application
// ----------------------------------------------------------------
// transaction_manager.go
type TxFunction func(ctx context.Context, tx domain.Tx) error
type TxManager interface {
DoInTx(ctx context.Context, fn TxFunction) error
}
送金処理の実装
// ----------------------------------------------------------------
// domain
// ----------------------------------------------------------------
// repository.go
type AccountRepository interface {
FindByID(ctx context.Context, id int) (*Account, error)
FindByIDWithTx(ctx context.Context, id int, tx Tx) (*Account, error) // txをDIしたメソッドを定義
Save(ctx context.Context, account Account) error
SaveWithTx(ctx context.Context, account Account, tx Tx) error // txをDIしたメソッドを定義
}
// ----------------------------------------------------------------
// infrastructure
// ----------------------------------------------------------------
// repository.go
func (r *accountRepositoryImpl) FindByIDWithTx(ctx context.Context, id int, _tx domain.Tx) (*domain.Account, error) {
tx, err := ExtractTx(_tx)
if err != nil {
return nil, err
}
var account domain.Account
if err := tx.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
func (r *accountRepositoryImpl) SaveWithTx(ctx context.Context, account domain.Account, _tx domain.Tx) error {
tx, err := ExtractTx(_tx)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Save(&account).Error; err != nil {
return err
}
return nil
}
// ----------------------------------------------------------------
// application
// ----------------------------------------------------------------
// usecase.go
type AccountUsecase struct {
ar domain.AccountRepository
txManager TxManager
}
func (a *AccountUsecase) Transfer(ctx context.Context, fromID, toID, amount int) error {
return a.txManager.DoInTx(ctx, func(ctx context.Context, tx domain.Tx) error {
// 口座情報を取得
fromAcc, err := a.ar.FindByIDWithTx(ctx, fromID, tx)
if err != nil {
return err
}
toAcc, err := a.ar.FindByIDWithTx(ctx, toID, tx)
if err != nil {
return err
}
// 送金処理
if err = fromAcc.Withdraw(amount); err != nil {
return err
}
toAcc.Deposit(amount)
// 口座情報を保存
if err = a.ar.SaveWithTx(ctx, *fromAcc, tx); err != nil {
return err
}
if err = a.ar.SaveWithTx(ctx, *toAcc, tx); err != nil {
return err
}
return nil
})
}
3. Contextで管理する
次はcontext.Context
を使ってトランザクションを管理するパターンです
このパターンでは、Application層のTxManager
でcontext.Context
にtx
を格納し、Repositoryで取り出して使用します。
すると、Application層にロジックを切り出しつつ、Repositoryのインターフェースを肥大化させずに済みます。
ただし、context.Context
をabuseしているとの批判もあるため、注意が必要です
- メリット: Repositoryインターフェースがシンプル、Repositoryの責務をデータの永続化・取得に集中できる
- デメリット: context.Contextをabuseしているとの批判もある
TxManagerの実装
// ----------------------------------------------------------------
// infrastructure
// ----------------------------------------------------------------
// context.go
type txKeyType struct{}
var txKey = txKeyType{}
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
return context.WithValue(ctx, txKey, tx)
}
func GetTx(ctx context.Context) (*gorm.DB, bool) {
tx, ok := ctx.Value(txKey).(*gorm.DB)
return tx, ok
}
// transaction_manager.go
type txManager struct {
db *gorm.DB
}
func (tm *txManager) DoInTx(ctx context.Context, fn application.TxFunction) error {
return tm.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ctxWithTx := WithTx(ctx, tx)
if err := fn(ctxWithTx); err != nil {
return err
}
return nil
})
}
// ----------------------------------------------------------------
// application
// ----------------------------------------------------------------
// transaction_manager.go
type TxFunction func(ctx context.Context) error
type TxManager interface {
DoInTx(ctx context.Context, fn TxFunction) error
}
送金処理の実装
// ----------------------------------------------------------------
// domain
// ----------------------------------------------------------------
// repository.go
type AccountRepository interface {
FindByID(ctx context.Context, id int) (*Account, error)
Save(ctx context.Context, account Account) error
}
// ----------------------------------------------------------------
// infrastructure
// ----------------------------------------------------------------
// base_repository.go
type baseRepository struct {
_db *gorm.DB // dbメソッドでラップする
}
func (b *baseRepository) db(ctx context.Context) *gorm.DB {
// ctxにトランザクションが含まれている場合はそれを使用し、そうでなければ通常のDBを返す
if tx, ok := GetTx(ctx); ok {
return tx
}
return b._db.WithContext(ctx)
}
// repository.go
type accountRepositoryImpl struct {
baseRepository
}
func (r *accountRepositoryImpl) FindByID(ctx context.Context, id int) (*domain.Account, error) {
// (IDで口座を検索する)
var account domain.Account
if err := r.db(ctx).Where("id = ?", id).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
func (r *accountRepositoryImpl) Save(ctx context.Context, account domain.Account) error {
// (永続化する)
if err := r.db(ctx).Save(&account).Error; err != nil {
return err
}
return nil
}
// ----------------------------------------------------------------
// application
// ----------------------------------------------------------------
// usecase.go
type AccountUsecase struct {
ar domain.AccountRepository
txManager TxManager
}
func (a *AccountUsecase) Transfer(ctx context.Context, fromID, toID, amount int) error {
return a.txManager.DoInTx(ctx, func(ctx context.Context) error {
// 口座情報を取得
fromAcc, err := a.ar.FindByID(ctx, fromID)
if err != nil {
return err
}
toAcc, err := a.ar.FindByID(ctx, toID)
if err != nil {
return err
}
// 送金処理
if err = fromAcc.Withdraw(amount); err != nil {
return err
}
toAcc.Deposit(amount)
// 口座情報を保存
if err = a.ar.Save(ctx, *fromAcc); err != nil {
return err
}
if err = a.ar.Save(ctx, *toAcc); err != nil {
return err
}
return nil
})
}
4. RepositoryManagerで管理する
最後はRepositoryManager
を使ってトランザクションを管理するパターンです。
こちらの記事 ではUnit of Workパターンと呼んでいます
このパターンでは、トランザクション内で使用するRepositoryを一括で管理するRepositoryManager
を定義します。
トランザクション内でtx
オブジェクトを渡したRepositoryManager
を作成することで、トランザクションを管理します。
これにより、トランザクション境界とRepository群を一括で扱えるようになりますが、Repositoryの数が増えると管理が難しくなる傾向にあります
- メリット: トランザクション境界とRepository群を一括で扱える
- デメリット: 実装が複雑になりがち
RepositoryManagerの実装
// ----------------------------------------------------------------
// application
// ----------------------------------------------------------------
// uow.go
type AccountRepoManager interface {
AccountRepository() domain.AccountRepository
}
type UnitOfWork[T any] interface {
DoInTx(ctx context.Context, fn func(ctx context.Context, repoManager T) error) error
}
// ----------------------------------------------------------------
// infrastructure
// ----------------------------------------------------------------
// uow.go
type unitOfWork[T any] struct {
db *gorm.DB
factory func(db *gorm.DB) T
}
func NewUnitOfWork[T any](db *gorm.DB, factory func(db *gorm.DB) T) application.UnitOfWork[T] {
return &unitOfWork[T]{db: db, factory: factory}
}
func (u *unitOfWork[T]) DoInTx(ctx context.Context, fn func(ctx context.Context, repoManager T) error) error {
return u.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repoManager := u.factory(tx)
if err := fn(ctx, repoManager); err != nil {
return err
}
return nil
})
}
// repository_manager.go
type accountRepoManager struct {
ar domain.AccountRepository
}
func (r *accountRepoManager) AccountRepository() domain.AccountRepository {
return r.ar
}
func AccountRepoManagerFactory() func(db *gorm.DB) application.AccountRepoManager {
return func(db *gorm.DB) application.AccountRepoManager {
ar := NewAccountRepository(db)
return &accountRepoManager{ar: ar}
}
}
送金処理の実装
// ----------------------------------------------------------------
// application
// ----------------------------------------------------------------
// usecase.go
type AccountUsecase struct {
ar domain.AccountRepository
uow UnitOfWork[AccountRepoManager]
}
func (a *AccountUsecase) Transfer(ctx context.Context, fromID, toID, amount int) error {
return a.uow.DoInTx(ctx, func(ctx context.Context, repoManager AccountRepoManager) error {
// 口座情報を取得
fromAcc, err := repoManager.AccountRepository().FindByID(ctx, fromID)
if err != nil {
return err
}
toAcc, err := repoManager.AccountRepository().FindByID(ctx, toID)
if err != nil {
return err
}
// 送金処理
if err = fromAcc.Withdraw(amount); err != nil {
return err
}
toAcc.Deposit(amount)
// 口座情報を保存
if err = repoManager.AccountRepository().Save(ctx, *fromAcc); err != nil {
return err
}
if err = repoManager.AccountRepository().Save(ctx, *toAcc); err != nil {
return err
}
return nil
},
)
}
まとめ
今回は、Golang + GORM を使った4つのトランザクション管理パターンを紹介しました。 それぞれのパターンにはメリットとデメリットがあり、プロジェクトの規模や要件に応じて使い分けることが重要です
何かの参考になれば幸いです
参考
レイヤードアーキテクチャでトランザクションをエレガントに抽象化する方法2選
GoによるRepositoryパターンとUnit of Workを組み合わせたトランザクション処理