Golangのレイヤードアーキテクチャ構成下でトランザクションを管理するパターンをまとめる

2025/08/19

レイヤードアーキテクチャのルールを守りつつ、RDBのトランザクションを管理する方法は悩みの種になるポイントだと思います。 本記事では「口座の送金処理*」を題材に、Golang + GORM を使った4つのトランザクション管理パターンをまとめてみます

*送金元口座から残高を引き出し、送金先口座に入金する処理の一貫性を保証する話です。失敗が発生した場合は操作をロールバックする必要があります

前提

本記事では、レイヤードアーキテクチャの構成を以下のように定義します。特に、Application, Domain, Infrastructure層についての実装を紹介していきます

  • Presentation層: UIやAPIレイヤー、ユーザー入力や出力を扱う層
  • Application層: アプリケーション全体のビジネスロジックを統括する層
  • Domain層: エンティティや集約、ドメインサービスなど、ビジネスルールそのものを保持する層
  • infrastructure層: DBや外部サービスとのやり取りを担当する層

今回の送金処理では、口座情報Accountをエンティティとします。このエンティティではDepositWithdrawという操作を定義します

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オブジェクトtxDependency 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層のTxManagercontext.Contexttxを格納し、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を組み合わせたトランザクション処理

2025年新卒入社