# I Shipped a Transaction Bug, So I Built a Linter

# I Shipped a Transaction Bug, So I Built a Linter

**Author:** léon h  
**Date:** 27 January 2026  
**URL:** https://leonh.fr/posts/go-transaction-linter/

## Overview

Léon h describes building a custom Go linter after shipping a database transaction bug to production. The linter, called `transactioncheck`, detects when database operations leak outside their intended transaction boundaries—a structural bug that compiles cleanly and passes tests but causes silent data corruption.

## The Problem

The bug occurs in codebases using callback-based transactions with repository patterns. When updating code:

```go
return s.repo.Transaction(ctx, func(tx models.Repo) error {
    user, err := s.repo.GetUser(ctx, userID) // Bug: uses s.repo, not tx
    return tx.SaveUser(ctx, user)
})
```

The `GetUser` call bypasses the transaction. This mistake is difficult to catch because code compiles, tests pass in isolation, and failures occur unpredictably under load with "silent data corruption" as the failure mode.

## Solution: The go/analysis Framework

The linter leverages the standard `go/analysis` framework. A basic analyzer structure includes:

- **Name:** transactioncheck
- **Dependencies:** inspect.Analyzer for optimized AST traversal
- **Entry point:** runTransactionCheck function receiving analysis.Pass

## Implementation Strategy

### 1. AST Walking
The linter filters for call expressions (`*ast.CallExpr`) to identify transaction calls without inspecting every node.

### 2. Transaction Identification
Detects repository interfaces by name and package location, then identifies `Transaction` method calls on those repositories.

### 3. Parameter Tracking
Captures the transaction parameter (e.g., `tx`) using Go's type system. "Two identifiers referring to the same variable share the same types.Object, which we can compare with simple equality check" for reliable tracking across name shadowing.

### 4. Violation Detection
Two violation types:

**Direct method calls on outer repo:**
```go
s.repo.GetUser(ctx, userID) // Should use tx.GetUser
```

**Passing outer repo to helper functions:**
```go
helperFunction(ctx, s.repo, userID) // Should pass tx
```

### 5. Recursive Analysis
The linter follows helper function chains to catch violations nested several functions deep:

```go
s.processOrder(ctx, tx, userID) // Recurses into processOrder
```

Visited functions are tracked to prevent infinite loops.

## Testing with analysistest

The `analysistest` package simplifies testing. Test cases use `// want` comments for assertions:

```go
_ = s.repo.GetUser( // want "using non-transaction repo..."
    ctx, "123",
)
```

## Deployment

Rather than integrating with golangci-lint, the standalone tool runs as part of build tasks. The team uses mise as a task runner:

```
[tasks.transactioncheck]
run = 'bin/transactioncheck ./...'
```

The linter integrates into CI, preventing violations from reaching production.

## Results

When first deployed, the linter "found multiple violations across the codebase," demonstrating real bugs rather than hypothetical ones. The two-day project provides ongoing protection with minimal maintenance overhead.

## Key Resources

- **Repository:** https://github.com/leonhfr/transactioncheck
- **Framework Documentation:** golang.org/x/tools/go/analysis
- **Task Runner:** mise.jdx.dev
