DEV Community

Cover image for Stop Passing *sql.Tx Through Your Go Service Layer
Gabriel Anhaia
Gabriel Anhaia

Posted on

Stop Passing *sql.Tx Through Your Go Service Layer

Your service needs to save an order and reserve inventory. Both must succeed or neither should. The instinct:

func (s *Service) PlaceOrder(ctx context.Context, tx *sql.Tx, req Request) error {
    if err := s.orderRepo.SaveWithTx(ctx, tx, order); err != nil {
        return err
    }
    if err := s.inventoryRepo.ReserveWithTx(ctx, tx, order.Items); err != nil {
        return err
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This compiles. It works. And it destroys your architecture.

What's Wrong

Your domain now imports database/sql. The service knows what a SQL transaction is. Every port needs a "WithTx" variant. Your in-memory test doubles are useless โ€” they can't accept *sql.Tx. And if you ever move to DynamoDB? Its transactional model is completely different. You're locked in.

The domain should express what needs to happen atomically. The adapter should decide how.

The Unit of Work Pattern

Define a port that says "execute these operations as one atomic unit" without specifying the mechanism:

type OrderSaver interface {
    SaveOrder(ctx context.Context, order Order) error
}

type InventoryReserver interface {
    ReserveInventory(ctx context.Context, productID string, qty int) error
}

type UnitOfWorkTx interface {
    OrderSaver
    InventoryReserver
}

type UnitOfWork interface {
    Execute(ctx context.Context, fn func(tx UnitOfWorkTx) error) error
}
Enter fullscreen mode Exit fullscreen mode

The domain calls Execute with a function. Inside, it uses the tx to save and reserve. If the function returns nil, everything commits. If it returns an error, everything rolls back.

The Service

type PlaceOrderService struct {
    uow   UnitOfWork
    idGen IDGenerator
}

func (s *PlaceOrderService) PlaceOrder(ctx context.Context, customerID string, items []LineItem) (Order, error) {
    order := Order{
        ID:         s.idGen.NewID(),
        CustomerID: customerID,
        Items:      items,
        Status:     "pending",
    }

    err := s.uow.Execute(ctx, func(tx UnitOfWorkTx) error {
        if err := tx.SaveOrder(ctx, order); err != nil {
            return fmt.Errorf("saving order: %w", err)
        }
        for _, item := range items {
            if err := tx.ReserveInventory(ctx, item.ProductID, item.Quantity); err != nil {
                return fmt.Errorf("reserving inventory: %w", err)
            }
        }
        return nil
    })
    if err != nil {
        return Order{}, err
    }

    return order, nil
}
Enter fullscreen mode Exit fullscreen mode

Count the imports. context, fmt. No database/sql. The service has no idea whether "atomically" means a SQL transaction, a DynamoDB transact-write, or an in-memory buffer.

The In-Memory Adapter (For Tests)

type InMemoryUoW struct {
    mu        sync.Mutex
    orders    map[string]Order
    inventory map[string]int
}

func (u *InMemoryUoW) Execute(_ context.Context, fn func(tx UnitOfWorkTx) error) error {
    u.mu.Lock()
    defer u.mu.Unlock()

    // Buffer changes
    tx := &inMemoryTx{
        orders:    make(map[string]Order),
        inventory: make(map[string]int),
    }

    if err := fn(tx); err != nil {
        return err // discard buffer = rollback
    }

    // Commit: apply buffered changes to real state
    for id, order := range tx.orders {
        u.orders[id] = order
    }
    for product, qty := range tx.inventory {
        u.inventory[product] += qty
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

If the function fails, the buffer is discarded. The real state never changes. That's rollback โ€” in pure Go, with zero SQL.

The Rollback Test

func TestPlaceOrder_RollbackOnFailure(t *testing.T) {
    uow := NewInMemoryUoW()
    svc := NewPlaceOrderService(uow, &seqIDGen{})

    _, err := svc.PlaceOrder(ctx, "cust-1", []LineItem{
        {ProductID: "prod-a", Quantity: 2},
        {ProductID: "prod-b", Quantity: -1}, // invalid โ€” will fail
    })

    if err == nil {
        t.Fatal("expected error")
    }

    // Nothing committed
    if len(uow.Orders()) != 0 {
        t.Error("orders should be empty after rollback")
    }
    if len(uow.Inventory()) != 0 {
        t.Error("inventory should be empty after rollback")
    }
}
Enter fullscreen mode Exit fullscreen mode

The order was saved inside the function, but the reservation failed. Both were rolled back. The test proves it โ€” in microseconds, with no database.

When You Don't Need This

Not every service needs Unit of Work. If your operation makes one database call, a simple repository port is enough. UoW adds complexity. Use it when you genuinely need atomic operations across multiple repositories.


๐Ÿ“– This is Chapter 16 of Hexagonal Architecture in Go: Ports, Adapters, and Services That Last.

The book covers the full journey: from the spaghetti service that breaks every sprint, through domain modeling, port design, adapter building, and production patterns like this one โ€” to knowing when hexagonal architecture is overkill and skipping it confidently.

22 chapters. Every example tested. Companion repo included.

Book 2 in the Thinking in Go series.

Part 5 of 5. Thanks for reading the series. If it helped, the book goes 10x deeper on every topic.

Top comments (0)