Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 14 / 18 45 MIN

Data and persistence

Transactions and Repository Shape

BeginTx with a context, the deferred rollback whose error you ignore, isolation levels the driver may refuse, and whether a repository interface earns its keep.

A transfer endpoint debits one account and credits another. The debit succeeds, the credit fails because the receiving row violates a constraint, and the handler returns 500. The customer's money is gone, the retry will take it again, and the support ticket says the dashboard is wrong. Nothing in the handler was written carelessly. It simply ran two statements with no transaction, which is the default state of a database connection.

A transaction is the boundary that says all of these statements happen or none of them do, and in Go it is a small piece of API with a few decisions attached: what context controls it, what to do with the rollback error, which isolation level to ask for, and where the boundary lives in your package structure. Those decisions are what this lesson is about.

BeginTx takes the request context, and that context controls the transaction

The older db.Begin() still works and takes no context. In a service, use BeginTx and pass the context from the request, because that context carries the client's deadline and the client's disconnection into the database. The documentation is specific about what happens next: the context is used until the transaction is committed or rolled back, the package rolls the transaction back if the context is cancelled, and Commit returns an error if the context was cancelled first.

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback()

// ... statements on tx, all with the same ctx

return tx.Commit()

The second argument is where isolation and read-only live, and nil asks for the server's default. That is the right answer far more often than people expect, and the next section shows why the explicit choices deserve scepticism.

defer tx.Rollback() is correct, and the ignored error is the point

The defer looks like a bug the first time you read it, because the function commits on the line before it returns and then calls Rollback after a successful commit. It is not a bug. After Commit or Rollback, the transaction is done, and every later operation on it fails with sql.ErrTxDone; the rollback is a no-op that costs a mutex and a boolean. What the defer buys is the path that skips the commit: an early return, a failed statement, a panic unwinding through the function. Each of those leaves the transaction open and the connection checked out until the deferred rollback runs.

Two consequences follow. Do not return tx.Rollback()'s error from a function that already has a better error, and do not write the ignore as _ = tx.Rollback() without thinking. Check it only if you have somewhere useful to put it. errcheck flags the bare call, and this is one of the cases where the idiomatic answer is to make the ignore deliberate rather than to handle an error that carries no information.

The statement that keeps a transaction honest is commit-once. A transaction has exactly one outcome, so any code path that reaches two calls to Commit is a design error, and any code path that both commits and continues issuing statements is a bug the driver reports as ErrTxDone rather than silently accepting. When a helper function needs to be part of the transaction, pass the *sql.Tx in and let the outermost function commit.

A transfer that rolls back is visible in the rows

The repository is a struct holding the pool, and the transaction lives inside one method. The interface is small and the method names describe business operations rather than SQL:

type Accounts interface {
	Transfer(ctx context.Context, from, to int64, amount int, idem string) error
	Balance(ctx context.Context, id int64) (int, error)
}

type MySQLAccounts struct{ db *sql.DB }
func (m *MySQLAccounts) Transfer(ctx context.Context, from, to int64, amount int, idem string) error {
	tx, err := m.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin: %w", err)
	}
	defer tx.Rollback()

	var balance int
	if err := tx.QueryRowContext(ctx,
		"SELECT balance FROM accounts WHERE id = ? FOR UPDATE", from).Scan(&balance); err != nil {
		return fmt.Errorf("read balance: %w", err)
	}
	if balance < amount {
		return fmt.Errorf("account %d: %w", from, errInsufficientFunds)
	}

	if _, err := tx.ExecContext(ctx,
		"UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from); err != nil {
		return fmt.Errorf("debit: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		"UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to); err != nil {
		return fmt.Errorf("credit: %w", err)
	}
	if _, err := tx.ExecContext(ctx,
		"INSERT INTO ledger (from_id, to_id, amount, created_at, idem_key) VALUES (?, ?, ?, NOW(), ?)",
		from, to, amount, idem); err != nil {
		return fmt.Errorf("ledger: %w", err)
	}

	return tx.Commit()
}

The ledger insert carries an idempotency key with a unique index on it, so the same transfer submitted twice cannot be recorded twice. That is the second call in the transcript below, and it fails on the insert after both balances have already been changed inside the transaction:

before:
  account 1 ana    balance 100
  account 2 bo     balance 20
  ledger rows: 0

Transfer(1 -> 2, 25, txn-0001) = nil
after the first transfer:
  account 1 ana    balance 75
  account 2 bo     balance 45
  ledger rows: 1

Transfer(1 -> 2, 25, txn-0001) retry = ledger: Error 1062 (23000): Duplicate entry 'txn-0001' for key 'ledger.uq_ledger_idem'
after the retry that hits the unique key:
  account 1 ana    balance 75
  account 2 bo     balance 45
  ledger rows: 1

The retry moved 25 from the sender, added 25 to the receiver, and then failed. The final state is 75 and 45 with one ledger row, which is the state before the retry. Without the transaction, those balances would read 50 and 70 with one ledger row and no way to tell which transfer had been recorded. Two things are worth noticing: the error message names the index, which is why naming constraints rather than letting MySQL choose is worth the keystrokes, and the whole failure is one line in the log plus a database that never moved.

The FOR UPDATE on the balance read is the other half of getting this right. Reading the balance without it and then updating leaves a window where a concurrent transfer passes the funds check on the same stale value, and both of them proceed. The row lock makes the read and the update one decision.

Go defines eight isolation levels; your driver supports four

sql.TxOptions takes an Isolation value, and database/sql defines eight constants from LevelDefault to LevelLinearizable. That list is the union of what every database has ever called these levels, not a menu you can order from. The check happens inside the driver's BeginTx, and the MySQL driver in this lab maps exactly four:

LevelDefault           ok, server reports REPEATABLE-READ
LevelReadCommitted     ok, server reports REPEATABLE-READ
LevelRepeatableRead    ok, server reports REPEATABLE-READ
LevelSerializable      ok, server reports REPEATABLE-READ
LevelReadUncommitted   ok, server reports REPEATABLE-READ
LevelSnapshot          BeginTx error: mysql: unsupported isolation level: 5
LevelLinearizable      BeginTx error: mysql: unsupported isolation level: 7
LevelWriteCommitted    BeginTx error: mysql: unsupported isolation level: 3
pool after all the failures: {0 1 0 1 0 0s 0 0 0}
Ping still fine

Three of the eight fail before a statement runs, with the level's number in the message. The pool is untouched by those failures, which is worth knowing: a rejected BeginTx returns the connection to the pool, so a configuration that asks for LevelSnapshot on MySQL fails every request cleanly instead of poisoning connections.

The more interesting part is that the session variable still reads REPEATABLE-READ for the four levels that are supported. MySQL's driver issues SET TRANSACTION ISOLATION LEVEL before starting the transaction, and @@transaction_isolation is the session default rather than a live view of the transaction in progress. So the constant you pass is a request, and the only way to know the request was honoured is to test the behaviour. Reading the same row twice with a commit in between does exactly that:

read-committed: session default before BeginTx is REPEATABLE-READ
read-committed: other connection committed balance = 250
read-committed: first read 100, second read 250 -> different value (non-repeatable read)
repeatable-read: session default before BeginTx is REPEATABLE-READ
repeatable-read: first read 100, second read 100 -> same value (repeatable)

Same code, same server, two different answers, and the session variable read identically in both runs. If you set an isolation level because a particular read pattern matters, write that pattern down as a test; the constant and the log line will not tell you which one you got. LevelSerializable is the other side of the same coin: it made the second connection's UPDATE block until it hit the fifty-second lock wait timeout, because serializable reads take shared locks. An isolation level is a concurrency decision with a cost, not a safety switch.

A repository interface earns its keep only where you substitute the store

The reason to put an interface in front of a store is that something else implements it. A test that runs against SQLite, an in-memory fake for handler tests, or a second backend during a migration are all good reasons, and the interface above exists for the first of those. What does not pay for itself is an interface that mirrors the store one method at a time and is implemented in exactly one place, because the extra indirection buys nothing and the SQL type still leaks through the method signatures the moment a query needs a transaction.

The tell is the method set. Transfer is one business operation that happens to need four statements, and it hides the transaction correctly: callers cannot start a transaction that spans two calls, because they never see a *sql.Tx. An interface with GetAccount, UpdateAccount and InsertLedger pushes the transaction question back to every caller and cannot answer it, since the methods each get their own connection. If a fake for that interface passes your tests, it is because the fake is not modelling the one thing that is hard.

Two tests are worth writing against the real store rather than a fake. The first drives a transfer that must fail and asserts the balances are unchanged, which is the behaviour in the transcript above. The second fires two concurrent transfers through the real store, under the race detector, and asserts the balances still add up. The second is the one that has a reason to exist: an in-memory fake will happily pass a test suite that never exercises FOR UPDATE.

A cancelled request cancels the query, and the connection does not come back

Context propagation in a transaction is not decoration. When the client disconnects, the request context is cancelled, the driver notices, and the statement running on the connection is interrupted. What happens to the connection afterwards is the part worth knowing:

after 501ms the query returned: context deadline exceeded
errors.Is(err, context.DeadlineExceeded) = true
now the request context is already done; every further statement fails immediately
second statement on the same tx: context deadline exceeded (in 501ms)
tx.Rollback() = invalid connection

The query was interrupted about a millisecond after its 500 millisecond deadline, so the work stopped rather than running to completion. The next statement on the same transaction failed without reaching the server, because the context was already done. database/sql had rolled the transaction back on its own when the context was cancelled, and the driver had marked the connection unusable because it could not know how much of the response had arrived. That is why the deferred tx.Rollback() returned invalid connection instead of ErrTxDone: the connection was discarded rather than returned to the pool. Checking that error would tell you nothing you could act on, and the pool is smaller by one connection, which it will replace on demand.

Two habits keep this useful. Always pass the request context into every ExecContext, QueryContext and BeginTx, rather than reaching for the non-context versions. And give the request a deadline somewhere, because a transaction with no deadline holds row locks for as long as the client is willing to wait, and the client that broke is not the one paying for the lock.

Lab 45 minutes

Build the transfer operation from this lesson against your own database: an accounts table, a ledger table with a unique idempotency key, and a method that wraps a locked balance read, two updates and the ledger insert in one transaction. Add a unique constraint that a retry can violate.

Print the balances and the ledger row count before the first call, after a successful call, and after a retry with the same idempotency key. Explain why the third print matches the second rather than the first.

Then delete the defer tx.Rollback() line, run the failing call again, and describe what the next request to the same pool sees. Put the line back and add a request-scoped context with a 500 millisecond deadline around the first statement, using SELECT SLEEP(3) inside the transaction, and record what the Rollback call returns afterwards.

Show the solution and the reasoning

The third print matches the second because the transaction never committed. The unique index on the idempotency key is the last statement, so the debit and the credit have already been written inside the transaction when it fails; the rollback discards both, and the balances return to 75 and 45 with the ledger row from the first call intact. That ordering is deliberate in the exercise. A constraint checked at the first statement proves nothing about the other three, and the point of a transaction is the work that happens before the failure.

Deleting the deferred rollback is the demonstration that the defer is not ceremony. The failing call returns its error with the transaction still open and its connection checked out. The next request that needs a connection waits for one; with SetMaxOpenConns(1) it waits forever, and with a larger pool it waits until it has leaked one connection per failure, which takes as many failures as the pool has connections. db.Stats() shows InUse climbing and never falling, and the database's own counters show nothing wrong at all.

With the deadline in place, the sleeping query returns context deadline exceeded at the deadline, errors.Is matches it, and the deferred rollback returns invalid connection rather than ErrTxDone, because the driver discarded the connection when it interrupted the statement. The pool replaces it. If you want to see the difference in a live service, watch OpenConnections after a burst of cancelled requests: it dips and recovers, which is the pool doing its job rather than a problem to fix.

On the interface question, there is no single right answer and the trade-off is worth stating plainly. An interface with Transfer and Balance lets a handler test run without a database and keeps the transaction inside the implementation. A concrete struct would do the same job with less code until the first fake is needed. The version that is always wrong is the one whose methods each map to one statement, because it advertises composability it cannot deliver: two calls on that interface are two transactions, and any invariant that spans them is yours to maintain by hand.

Official reference

The Go standard library documentation on pkg.go.dev for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.