Go repository pattern transactions
By Audren Bouëssel du Bourg
You can check a GitHub using a similar implementation here: https://github.com/rafael-piovesan/go-rocket-ride
Basic idea of the repository pattern is to isolate data persistence through an interface that allows multiple implementations (sql / no sql / in mem / etc…).
Repository implementation should be stripped of business logic.
Consider this typical use case: a customer purchase an order. Its account should be debited and the order be saved in an atomic way.
The repository may look like this:
type Repo interface {
SaveOrder(context.Context, Order) error
SaveAccount(context.Context, Account) error
}
And a psql implementation may look like this:
type psqlRepo struct {
PsqlConn
}
type PsqlConn interface {
Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error)
}
func (r *psqlRepo) SaveOrder(ctx context.Context, order Order) error {
_, err := r.Exec(ctx, "INSERT INTO orders(order_id, customer_email, price, date) VALUE($1, $2, $3, $4)",
order.ID, order.CustomerEmail, order.Price, order.Date)
return err
}
func (r *psqlRepo) SaveAccount(ctx context.Context, account Account) error {
_, err := r.Exec(ctx, "INSERT INTO accounts(customer_email, balance) VALUE($1, $2) "+
"ON CONFLICT DO UPDATE SET customer_email = $1, balance = $2 WHERE customer_email = $1",
account.CustomerEmail, account.Balance)
return err
}
You may want to implement your use case like this:
type service struct {
Repo
}
func (s *service) PurchaseOrder(ctx context.Context, acc Account, order Order) error {
acc.Balance -= order.Price
if acc.Balance < 0 {
return errors.New("insufficient funds")
}
if err := repo.SaveOrder(ctx, order); err != nil {
return err
}
return repo.SaveAccount(ctx, acc)
}
Unfortunately, it is not atomic: one could save order and its account may not be debited.
A solution is to modify the repository with the help of a higher order function. Check below:
type Repo interface {
+ // Do execute the given function in an atomic way
+ Do(context.Context, func(Repo) error) error
SaveOrder(context.Context, Order) error
SaveAccount(context.Context, Account) error
}
type PsqlConn interface {
+ Begin(ctx context.Context) (pgx.Tx, error)
Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error)
}
Its PSQL implementation would be like this:
func (r *psqlRepo) Do(ctx context.Context, fn func(Repo) error) error {
tx, err := r.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
err = fn(&psqlRepo{tx})
if err != nil {
return err
}
return tx.Commit(ctx)
}
And the PurchaseOrder
could be updated to wrap operations in an atomic way such as:
func (s *service) PurchaseOrder(ctx context.Context, acc Account, order Order) error {
acc.Balance -= order.Price
if acc.Balance < 0 {
return errors.New("insufficient funds")
}
err := s.Do(ctx, func(repo Repo) error {
if err := repo.SaveOrder(ctx, order); err != nil {
return err
}
return repo.SaveAccount(ctx, acc)
})
if err != nil {
return fmt.Errorf("unable to purchase order: %w", err)
}
return nil
}