avito-tech/go-transaction-manager

Transaction Manager over GORM

Flaiers opened this issue · 0 comments

Description

Gorm currently has a transaction management feature, but the code for many operations looks overloaded.

Example

We have a service (UserService) to work with the user. When debiting a user's account, we must:

  1. Get the record with the user's balance
  2. Сheck whether funds can be debited from the user's account
  3. Debit from the user's account
  4. Create a transaction that the debit was successful

If we get an error on one of these, we must roll back. Code samples:

Implementation transaction service

cat internal/service/transaction.go
package service


...


type TransactionService struct {
	repository *repository.TransactionRepository
}


...


func (s *TransactionService) Create(
	transactionCreate *dto.TransactionCreate, tx ...*gorm.DB,
) (*entity.Transaction, error) {
	return s.repository.Create(
		&entity.Transaction{
			Type:      transactionCreate.Type,
			Amount:    transactionCreate.Amount,
			UserID:    transactionCreate.UserID,
			OrderID:   transactionCreate.OrderID,
			ServiceID: transactionCreate.ServiceID,
		}, tx...,
	)
}

Implementation user service

cat internal/service/user.go
package service


...


type UserService struct {
	repository         *repository.UserRepository
	transactionService *TransactionService
}


...


func (s *UserService) WithdrawUserBalance(
	userID uuid.UUID, userWithdrawal *dto.UserWithdrawal, tx ...*gorm.DB,
) (*entity.User, error) {
	var user *entity.User
	err := s.repository.Transaction(func(tx *gorm.DB) (err error) {
		user, err = s.FindOne(userID, tx)
		if err != nil {
			return
		}
		user, err = s.repository.WithdrawUserBalance(
			user, userWithdrawal.Amount, tx,
		)
		if err != nil {
			return
		}
		_, err = s.transactionService.Create(&dto.TransactionCreate{
			Type:      "withdrawal",
			Amount:    userWithdrawal.Amount,
			UserID:    userID,
			OrderID:   userWithdrawal.OrderID,
			ServiceID: userWithdrawal.ServiceID,
		}, tx)
		if err != nil {
			return
		}

		return
	}, tx...)

	return user, err
}

Implementation transaction repository

cat internal/repository/transaction.go
package repository


...


type TransactionRepository struct {
	db *gorm.DB
}


...


func (r *TransactionRepository) txDefault(tx ...*gorm.DB) *gorm.DB {
	if len(tx) < 1 {
		return r.db
	}

	return tx[0]
}

func (r *TransactionRepository) Create(
	transaction *entity.Transaction, tx ...*gorm.DB,
) (*entity.Transaction, error) {
	err := r.txDefault(tx...).Create(transaction).Error
	if err != nil {
		return nil, err
	}

	return transaction, nil
}

Implementation user repository

cat internal/repository/user.go
package repository


...


type UserRepository struct {
	db *gorm.DB
}


...


func (r *UserRepository) Transaction(
	fn func(*gorm.DB) error, tx ...*gorm.DB,
) error {
	return r.txDefault(tx...).Transaction(fn)
}

func (r *UserRepository) txDefault(tx ...*gorm.DB) *gorm.DB {
	if len(tx) < 1 {
		return r.db
	}

	return tx[0]
}


...


func (r *UserRepository) WithdrawUserBalance(
	user *entity.User, amount uint, tx ...*gorm.DB,
) (*entity.User, error) {
	if user.Balance < amount || user.Balance == 0 {
		return nil, errors.New("user balance is not enough")
	}
	user.Balance -= amount
	err := r.txDefault(tx...).Save(user).Error
	if err != nil {
		return nil, err
	}

	return user, nil
}

Сonclusion

Using the standard gorm transaction manager forces you to add transaction management logic on both the service and the repository level, and the code nesting when opening a transaction is not good enough.