Skip to content
Tech News
← Back to articles

I shipped a transaction bug, so I built a linter

read original get CodeLint for Transactions → more articles
Why This Matters

This article highlights the importance of transaction integrity in database operations and demonstrates how a simple coding mistake can lead to data corruption. By building a custom linter, the author provides a proactive solution to catch transaction leaks early, improving reliability and reducing stress for developers and consumers alike. This approach underscores the value of automated tools in maintaining robust, error-free code in the tech industry.

Key Takeaways

Some bugs compile cleanly, pass all tests, and slip through code reviews. I shipped one of those at work: a database transaction that silently leaked operations outside its boundary. I don’t like being stressed with a broken prod, so I built a custom linter to catch them at compile time. Here’s how.

The Bug: Leaking Transactions #

Database transactions are essential for data integrity. Operations wrapped in a transaction are expected to display all-or-nothing behavior: either every operation succeeds, or everything rolls back.

One pattern for managing transactions is callbacks. This style of transaction is common when using ORMs such as Gorm. However, this approach makes it easier to accidentally bypass transaction boundaries. Operations can leak outside the intended scope, leading to data corruption and race conditions. Let’s look at some code.

At my current workplace, the Go backend uses a repository pattern with explicit transactions:

func ( s * Service ) UpdateUser ( ctx context . Context , userID string ) error { return s . repo . Transaction ( ctx , func ( tx models . Repo ) error { user , err := tx . GetUser ( ctx , userID ) if err != nil { return err } user . Name = "Updated" return tx . SaveUser ( ctx , user ) }) }

The callback receives tx , a transaction-scoped repository. All database operations inside must use tx to participate in the transaction.

This pattern works, but it’s easy to mix up the two scopes:

func ( s * Service ) UpdateUser ( ctx context . Context , userID string ) error { return s . repo . Transaction ( ctx , func ( tx models . Repo ) error { user , err := s . repo . GetUser ( ctx , userID ) // Bug: uses s.repo, not tx. if err != nil { return err } user . Name = "Updated" return tx . SaveUser ( ctx , user ) // Ok: inside the transaction. }) }

The GetUser call uses s.repo (the component’s field) instead of tx (the transaction callback parameter). This operation executes outside the transaction boundary.

... continue reading