The transactor pattern is a way to manage transactions seamlessly. You can inject your transactor in your services to make transactions completely transparently.
It relies mostly on the Transactor
interface:
type Transactor interface {
WithinTransaction(context.Context, func(context.Context) error) error
}
WithinTransaction
starts a new transaction and adds it to the context. Any repository method can then retrieve a transaction from the context or fallback to the initial DB handler. The transaction is committed if the provided function doesn't return an error. It's rollbacked otherwise.
go get github.com/Thiht/transactor
This example uses database/sql
with the pgx
driver.
import stdlibTransactor "github.com/Thiht/transactor/stdlib"
db, _ := sql.Open("pgx", dsn)
transactor, dbGetter := stdlibTransactor.NewTransactor(
db,
stdlibTransactor.NestedTransactionsSavepoints,
)
The currently available strategies for nested transactions are:
- NestedTransactionsSavepoints, an implementation using
SAVEPOINTS
and compatible with PostgreSQL, MySQL, MariaDB, and SQLite, - NestedTransactionsOracle, an implementation using Oracle savepoints,
- NestedTransactionsMSSQL, an implementation using Microsoft SQL Server savepoints,
- NestedTransactionsNone, an implementation that prevents using nested transactions.
Instead of injecting the *sql.DB
handler directly to your repositories, you now have to inject the dbGetter
. It will return the appropriate DB handler depending on whether the current execution is in a transaction.
type store struct {
- db *sql.DB
+ dbGetter stdlibTransactor.DBGetter
}
func (s store) GetBalance(ctx context.Context, account string) (int, error) {
var balance int
- err := s.db.QueryRowContext(
+ err := s.dbGetter(ctx).QueryRowContext(
ctx,
`SELECT balance FROM accounts WHERE account = $1`,
account,
).Scan(&balance)
return balance, err
}
type service struct {
balanceStore stores.Balance
transactor transactor.Transactor
}
func (s service) IncreaseBalance(ctx context.Context, account string, amount int) error {
return s.transactor.WithinTransaction(ctx, func(ctx context.Context) error {
balance, err := s.balanceStore.GetBalance(ctx, account)
if err != nil {
return err
}
balance += amount
err = s.balanceStore.SetBalance(ctx, account, balance)
if err != nil {
return err
}
return nil
})
}
Thanks to nested transactions support, you can even call your services within a transaction:
type service struct {
balanceStore stores.Balance
transactor transactor.Transactor
}
func (s service) TransferBalance(
ctx context.Context,
fromAccount, toAccount string,
amount int,
) error {
return s.transactor.WithinTransaction(ctx, func(ctx context.Context) error {
err := s.DecreaseBalance(ctx, fromAccount, amount)
if err != nil {
return err
}
err = s.IncreaseBalance(ctx, toAccount, amount)
if err != nil {
return err
}
return nil
})
}
func (s service) IncreaseBalance(ctx context.Context, account string, amount int) error {
return s.transactor.WithinTransaction(ctx, func(ctx context.Context) error {
// ...
})
}
func (s service) DecreaseBalance(ctx context.Context, account string, amount int) error {
return s.transactor.WithinTransaction(ctx, func(ctx context.Context) error {
// ...
})
}
Warning
Transactions are not thread safe, so make sure not to call code making concurrent database access inside WithinTransaction
In your tests, you can inject a fake transactor
and dbGetter
, using NewFakeTransactor:
transactor, dbGetter := stdlibTransactor.NewFakeTransactor(db)
The fake transactor
will simply execute its callback function, and the dbGetter
will return the provided db
handler.
This strategy works because when using this library, you don't have to worry about how transactions are made, just about returning errors appropriately in WithinTransaction
.