Handling Transactions in Go

So, you've got your single-method stores working beautifully. But what happens when you need to touch two tables at once? Say a player buys a sword in your game's shop: you need to take their gold and put the sword in their inventory at the exact same time. You definitely can't have one succeed and the other fail—that's how players lose their gold for nothing, or walk away with a free legendary sword! You need a transaction.

Here is how we keep our clean, one-method architecture while wrapping everything up safely in a SQL transaction.

Swapping Context for a Transaction

Our store interfaces keep their exact same shape—one generic method, using the row as the query. The only difference? Instead of passing a context.Context, we pass a *sql.Tx.

Why? Because a transaction object already carries its own context and holds onto the database connection.

type TxFinder[T any] interface {
    RetrieveTx(*sql.Tx, T) (T, error)
}

type TxLocker[T any] interface {
    LockTx(*sql.Tx, T) (T, error) // select ... for update
}

type TxUpdater[T any] interface {
    UpdateTx(*sql.Tx, T) error
}

type TxCreator[T any] interface {
    CreateTx(*sql.Tx, T) (T, error)
}

Your database package implements these by using the transaction to run the queries. Meanwhile, an in-memory fake can just ignore the transaction argument entirely and update its internal map. Easy!

The Business Rule is Just a Struct

Let's build that shop. To sell an item, we need to interact with the database itself, plus three specific store actions: locking a player, updating a player, and adding an item to their inventory.

Instead of scattering this logic, we create a single struct (our "rule") to hold exactly what we need:

type Shop struct {
    DB        *sql.DB
    Players   data.TxLocker[data.Player]
    Update    data.TxUpdater[data.Player]
    Inventory data.TxCreator[data.Loot]
}

The "Do Everything" Method

Now, we expose exactly one method on this struct. This method's job is to open the transaction, orchestrate the steps, and then safely commit (or roll back if things go sideways).

func (s *Shop) Buy(ctx context.Context, playerID, itemID uint64, price int64) (data.Loot, error) {
    // 1. Open the transaction
    tx, err := s.DB.BeginTx(ctx, nil)
    if err != nil {
        return data.Loot{}, err
    }

    // 2. Defer the rollback! If the commit at the bottom succeeds, this does nothing.
    // If we hit an error and return early, this safely undoes any changes.
    defer tx.Rollback()

    // 3. Lock the player (SELECT FOR UPDATE), so a second purchase can't spend the same gold
    player, err := s.Players.LockTx(tx, data.Player{
        ID: playerID,
    })
    if err != nil {
        return data.Loot{}, err
    }

    // 4. Check the business logic
    if player.Gold < price {
        return data.Loot{}, ErrNotEnoughGold
    }

    // 5. Spend the gold
    err = s.Update.UpdateTx(tx, data.Player{
        ID:   playerID,
        Gold: player.Gold - price,
    })
    if err != nil {
        return data.Loot{}, err
    }

    // 6. Put the item in the inventory
    loot, err := s.Inventory.CreateTx(tx, data.Loot{
        PlayerID: playerID,
        ItemID:   itemID,
    })
    if err != nil {
        return data.Loot{}, err
    }

    // 7. Commit the transaction!
    return loot, tx.Commit()
}

The Caller's View

The best part about this setup? The caller is completely shielded from the transaction mechanics. When it's time to wire this up, you just assemble the struct:

shop := &Shop{
    DB:        db,
    Players:   &database.Players{},
    Update:    &database.Players{},
    Inventory: &database.Inventory{},
}

sword, err := shop.Buy(ctx, playerID, swordID, 300)

The struct holds your stores, exposes one clean action, and manages its own state safely. People usually call this a "service layer," but in our world, it's just another wrapper. It composes a few stores together and does one specific thing, rather than turning into a bloated, monolithic mirror of your database!