Managing transactions in Go web applications

While working on any (well, most) web application you will come across transactions. You know, so that your data couldn’t end in a malformed state. The idea of a transaction is easily understandable. You start it, do a bunch of stuff and then either “commit” (persist changes you did while in the transaction) or “rollback” (discard the changes) it. The concept is most commonly used when communicating with databases, but is not limited just to it. You could for example write a API client that could rollback the changes it did (like deleting a user it created and such).

So in Go a transaction could be represented as:

type Transaction interface {
Commit() error
Rollback() error
}

Introduction

Now, the place where you create a transaction in a web application should be the handler (`http.HandlerFunc` or `http.Handler`) since that’s the logical “root” of execution for each request. Okay, so you have a transaction now and are going through the logic of your handler and a error comes up. What do you do? Well you also need to rollback the open transactions, right? So more or less something like this:

if err != nil {
log.Print(err)
if err := transaction.Rollback(); err != nil {
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write("Internal server error")
return
}
switch err.(type) {
case *json.UnsupportedValueError:
w.WriteHeader(http.StatusBadRequest)
w.Write("Invalid Input")
case default:
w.WriteHeader(http.StatusInternalServerError)
w.Write("Internal server error")
}

return
}

Then after the logic you commit the transaction like:

if err := transaction.Commit(); err != nil {
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write("Internal server error")
return
}

But you should also rollback the transaction in defer so it won’t be left open after the request, for this we need to expand the Transaction interface like this:

type Transaction interface {
Commit() error
Rollback() error
IsOpen() (bool, error)
}
defer func(){
open, err := transaction.IsOpen()
if err != nil {
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write("Internal server error")
return
}
if !open {
return
}
if err := transaction.Rollback(); err != nil {
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
w.Write("Internal server error")
return
}
}()

Boilerplate hell?

Exactly. And you need to handle the error every time you are closing the transaction. That got me thinking, wouldn’t it be nice if there was a way how to handle every rollback/commit error in a single place? And while we’re at it it could also defer closing of the transaction.

Panic! No don’t panic, I mean the panic keyword which we can use to signal every transaction closing error. Kinda how the encoding/json handles internal errors. So I propose to wrap the Transaction interface into a interface like this:

type PanicTransaction interface {
Commit()
Rollback()
}

We could now call the methods willy-nilly without caring about the errors. But we will also need to catch our panics and implicitly close the underlying transaction. A function with following footprint would do:

func(Transaction, Writer)

When we defer this function it should:

  • recover transaction error, log it, write http.StatusInternalServerError
  • rollback the transaction if it’s open
  • recover the rollback error, log it, write http.StatusInternalServerError

Proposed solution

I propose a solution that would allow you to wrap a Transaction like this:

// var w http.ResponseWriter
// var trans Transaction
tx := transaction.InHandler(trans)
defer tx.HandleErrors(w)

The source code for this solution is pretty straightforward:

package transaction

import (
"log"
"net/http"
)

func InHandler(t Transaction) *transaction {
return &transaction{t}
}

type transactionError struct {
error
}

type Transaction interface {
Commit() error
Rollback() error
Open() (bool, error)
}

type transaction struct {
transaction Transaction
}

func (ht *transaction) Commit() {
if err := ht.transaction.Commit(); err != nil {
panic(transactionError{err})
}
}

func (ht *transaction) Rollback() {
if err := ht.transaction.Rollback(); err != nil {
panic(transactionError{err})
}
}

func (ht *transaction) HandleErrors(w http.ResponseWriter) {
switch e := recover().(type) {
case nil:
switch open, err := ht.transaction.Open(); {
case err != nil:
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
case open:
if err := ht.transaction.Rollback(); err != nil {
log.Print(e)
w.WriteHeader(http.StatusInternalServerError)
}
}
case transactionError:
log.Print(e)
w.WriteHeader(http.StatusInternalServerError)
default:
switch open, err := ht.transaction.Open(); {
case err != nil:
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
case open:
if err := ht.transaction.Rollback(); err != nil {
log.Print(e)
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
}
}
}
}

What do you think about this solution? Any drawbacks apparent to you? Bash it in the comments!

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store