Introduction

There are many situations when you need to perform a series of actions over some data. Still, this series may make data inconsistent in case of any failure. That’s why developers need transactions, to ensure the consistency of the data.

In Go, there are a lot of different concerns that you have to keep in mind when you’re designing your work with the transactions, and some patterns can you help to work with.

In this article, I will consider a traditional way of working with the SQL transaction, it’s improvement and the functional way of handling it.

Traditional way

In case of SQL transaction you will most probably have a method like this one:

func (s *Store) TransactionalMethod(ctx context.Context) error {
    tx, err := s.db.BeginTx(ctx, sql.TxOptions{...})
    if err != nil {
        return fmt.Errorf("start transaction: %w", err)
    }

    if _, err = tx.Exec(`UPDATE ... SET ... WHERE ...`); err != nil {
        if rbErr := tx.Rollback(); rbErr != nil {
            log.Printf("failed to rollback, got error: %v", rbErr)
        }
        return err
    }

    // ... some other transactional actions

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("failed to commit the transaction: %w", err)
    }
    return nil
}

That’s a suitable working method, but with some problems:

  • you repeat a rollback on every error
  • you introduced a new object into your method - a transaction, which is not strongly related to the actions that you’re performing inside of your method
  • you always need to look over a transactional object to ensure that there will be no hanging connection to the database in case of the error

Deferred rollback

We might improve the code above by replacing the repetitive sequence of rollbacks with a deferred call:

func (s *Store) TransactionalMethod(ctx context.Context) error {
    tx, err := s.db.BeginTx(ctx, &sql.TxOptions{...})
    if err != nil {
        return fmt.Errorf("start transaction: %w", err)
    }

    defer func(){
        if err != nil {
            if rbErr := tx.Rollback(); rbErr != nil {
                log.Printf("failed to rollback, got error: %v", rbErr)
            }
        }
    }()

    if _, err = tx.Exec(`UPDATE ... SET ... WHERE ...`); err != nil {
        return err
    }

    // ... some other transactional actions

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("failed to commit the transaction: %w", err)
    }
    return nil
}

Here, we removed all the rollback occurrences in our code and replaced them with deferred check of the err variable: if it is not empty - we perform a rollback, otherwise do nothing.

Good!

But still, we have plenty of things to improve here: we need to watch that we always write our in-transactional errors in the err variable in the scope of the TransactionalMethod method, otherwise we won’t perform a rollback in case of the error, that is not written to err variable. It’s pretty easy to make a mistake by just setting the error in the different scope, in, for instance, if statement:

if _, err := tx.Exec(`UPDATE ... SET ... WHERE ...`); err != nil {
    return err
}

Here we write our error in the scope of this if statement by just writing the “:” symbol in the assignment, which leads to defining a new variable named err in the scope of this if statement. Thus, this error won’t be caught by the error check inside of the defer, which implies that the transaction won’t be rollbacked and the connection will still be acquired and held until some kind of a timeout, if it’s set.

UPD: Thanks to u/xargon7 for pointing that Go may always use the value of the outer function’s scope if the return is named, even if there was no explicit assignment to the variable, that fixes the problem with the scopes.

But still, this way keeps the transaction in the context of the method.

The soluton with named return will look like this:

func (s *Store) TransactionalMethod(ctx context.Context) (err error) {
    var tx *sql.Tx
    if tx, err = s.db.BeginTx(ctx, &sql.TxOptions{...}); err != nil {
        return fmt.Errorf("start transaction: %w", err)
    }

    defer func(){
        if err != nil {
            if rbErr := tx.Rollback(); rbErr != nil {
                log.Printf("failed to rollback, got error: %v", rbErr)
            }
        }
    }()

    if _, err = tx.Exec(`UPDATE ... SET ... WHERE ...`); err != nil {
        return err
    }

    // ... some other transactional actions

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("failed to commit the transaction: %w", err)
    }
    return nil
}

Transactional decorator

We may make another improvement: we may define a higher-order function (a decorator), which accepts a function that is supposed to be run in the transaction and has an error result in its signature, and, if this function returns an error - we rollback the transaction, otherwise, commit it:

func (r *SomeRepository) Tx(ctx context.Context, opts *sql.TxOptions, fn func(tx *sql.Tx) error) error {
    tx, err := r.db.BeginTx(ctx, opts)
    if err != nil {
        return fmt.Errorf("failed to start transaction: %w", err)
    }
    defer func(){
        if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
            log.Printf("failed to rollback, got error: %v", rbErr)
        }
    }()

    if err = fn(tx); err != nil {
        return err
    }

    if err = tx.Commit(); err != nil {
        return fmt.Errorf("failed to commit: %w", err)
    }
    
    return nil
}

Then our transactional method will look like this:

func (r *SomeRepository) TransactionalMethod(ctx context.Context) error {
    err := r.Tx(ctx, &sql.TxOptions{...}, func(tx *sql.Tx) error {
        if _, err = tx.Exec(`UPDATE ... SET ... WHERE ...`); err != nil {
            return err
        }

        // ... some other transactional actions

        return nil
    })
    if err != nil {
        return fmt.Errorf("perform transaction: %w", err)
    }

    return nil
}

Here, we use a functional approach of handling things - we pass a lambda function to the transactional decorator to perform actions over the transaction. This decorator ensures that our transaction will end with either commit or rollback. We won’t have hanging connections to the database.

This solves several problems:

  • we don’t have a repetitive series of rollback performances.
  • we removed the transaction object from our transactional method, and we don’t have it in the context of our logic.
  • we don’t have to repeat all Begin-Commit-Rollback calls in our logic. Instead, we just wrap it in the transactional decorator.
  • we’ve ensured that the transaction will end in either commit or rollback.

Conclusion

The functional approach of working with the transaction is not something new. For example, it’s the only way of organizing the work with the database in a popular key-value database library bolt and it’s fork bbolt, and is recommended for use in the documentation of entgo. This way releases you from keeping in mind the transaction handling and ensures the consistency of the transaction itself.

There’re also some pages, that refer to the decorator approach (thanks to u/mmrath):