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
}
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
}
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
}
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
}
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")
}
}
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)