brainbaking/content/post/2024/06/database-session-management...

6.5 KiB

title date tags categories
Database Session Management In Go 2024-06-12T18:30:00+02:00
go
programming

For a Go project we're working on, the ever-increasing boilerplate code yet again started getting on my nerves. This seems to be a recurring story in enterprise software projects written in Go, but I digress. This time, the question is: how to do proper database transaction management, the idiomatic Go way? The thing we were trying to solve must have been solved more than a thousand times before, by others using Go, but also by others using classic object-oriented languages such as C# and Java.

In Java, we simply rely on Spring and JPA and Hibernate and Whatever-Its-Called-Now to conjure up a piece of middleware that automagically opens up and commits or rolls back the transaction. This is typically done using Aspect Oriented Programming. If the framework works---and that's usually the point of a frame work---all the programmer has to do is to add @Transactional on top of a method:

@Transactional
public void SaveInvoice(Invoice invoice) {
    if(!invoice.validate()) {
        throw new InvoiceInvalidException();
    }

    invoiceRepo.persist(invoice);
    loggingRepo.addNewLineFor(invoice);
}

If the method fails, the transaction is automatically rolled back. If SaveInvoice() is called upon multiple times in parallel, the behind-the-scenes transaction manager that the annotation @Transactional uses still works: it usually relies on a thread-static session that's unique for each incoming request.

This seems so simple and obvious, but let's still ask the question: how do you do this in Go? Use an unexported global variable? Create something new for each request? Leverage the current context.Context?

We rely on GORM, an ORM framework that could be compared to Hibernate except that it's much more lightweight---and as a result, leaves the transaction management up to you. The GORM way to open a transaction is perhaps weird: it returns a new pointer with the same type of *gorm.DB1:

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
tx := db.Begin()

tx.Model(&someStruct).Update("Property", 124)
tx.Commit()

That means you can't simply rely on dependency injection to pass in a GORM database pointer in your repository, as you'll likely want to have a transactional context across repositories unless you mutate the pointers or create repository structs ad-hoc. Both options are a big no-no in combination with a dependency injection system that for instance treats your repo as a singleton: mutating state means your next parallel request will be working on the wrong DB instance.

My first idea was to write a middleware system not unlike the Java AOP way, except there your transaction manager does what it says on the tin: it manages transactions across multiple requests and takes session pooling etc. into account. I'll have to write my own layer on top of GORM to do something like:

func NewMiddleWare(ctx context.Context, db *gorm.DB, txManager transaction.Manager) {
    id := commandIdBasedOnRequest(ctx)
    tx := db.Begin()
    txManager.AddContext(id, context.WithValue(ctx, infra.TxCtxKey{}, tx))
}
// ...
func Commit(ctx context.Context) {
    id := commandIdBasedOnRequest(ctx)
    tx := txManager.Context(id)
    tx.Commit()
}

The transaction manager then is an ugly stateful singleton maintaining a map[context.Context]*gorm.DB-like map that keeps on growing and can't be efficiently shrunk in size after a while. On top of that, you'll have to pass in a context.Context for each repository function.

Additionally, passing database transactions via context is an anti-pattern (seconded here). Whoops. You should not store anything in the context except for request-specific data. So what's the commonly used alternative?

After digging through these:

And inspecting various context-specific framework code snippets, I came to the conclusion that the idiomatic Go way seems to be to pass in something context-related as the first parameter---whether or not that's context.Context or *gorm.DB as the transaction doesn't really matter. The added benefit of that approach is easier testability and better enforcement of correct function usage.

The above Java snippet coming from a handler or service or whatever you call it might look like this in Go:

func (c *invoiceCmd) Save(invoice Invoice) error {
    // db is the initial GORM db pointer
    c.db.Transaction(func(tx *gorm.DB) error {
        err := invoice.Validate(); if err != nil {
            return err
        }

        err = c.invoiceRepo.persist(tx, invoice)
        if err != nil {
            return err
        }
        err = c.loggingRepo.addNewLineFor(tx, invoice)
        if err != nil {
            return err
        }
        return nil // auto-commit
    })
}

This allows us to use GORM's Transaction() convenience wrapper in true Transaction Script style as Martin Fowler likes to call it---or, if we really need to, manage transactions manually2.

Still, it just bugs me that (1) this code is much more cluttered (thank you Go error handling) and (2) you have to pass in tx, although that can be solved by creating your repositories within the Transaction() func. What about unit testability then? I suppose writing an integration test against an in-memory SQLite database is your only option then, as there's no way to inject other behaviour.

I can't remember where I once read that coding in Go is boring and dull, but it gets the job done without all the excessive fluff of other programming languages. At this point, I'd rather have my fluff, thank you very much.


  1. Proper error handling in these pseudocode blocks is omitted for brevity. ↩︎

  2. Although it is technically possible to use a thread-static variable bound to the current goroutine that was spun up with the request, there is no way to resolve the current thread using something like Java's Thread.currentThread(). Again, that's considered bad code design. ↩︎