How to Implement the Unit of Work Pattern in Go

Implement the Unit of Work pattern in Go by creating an interface to track changes and a struct to manage a single database transaction for atomic commits.

The checkout that breaks the database

You're building an e-commerce checkout. The user clicks "Buy". Your code needs to deduct the item from inventory and create the order record. If the inventory update succeeds but the order creation crashes, you have a phantom sale: stock is gone, but no order exists. The database is in an inconsistent state. You need a way to group these operations so they either all succeed or all fail together.

The Unit of Work pattern solves this by tracking changes and committing them as a single atomic unit. It acts as a buffer between your domain logic and the database. You collect all the modifications, then push them through a transaction. If anything goes wrong, the transaction rolls back and the database looks exactly like it did before you started.

Think of a Unit of Work like a sticky note on a desk. You write down every change you want to make. "Move file A to B", "Update record C". You don't execute them immediately. You keep writing. When you're done, you hand the note to the database and say "Do all of these now." If the database can't do one, it does none. The note is the transaction. The Unit of Work is the manager of that note.

The interface and the struct

Go doesn't have a built-in Unit of Work. You define the interface and implement the struct. The interface exposes Commit, Rollback, and Save. The struct holds the database connection, the transaction, and a list of pending changes.

Here's the core definition. The interface sets the contract. The struct implements the behavior.

// UnitOfWork defines the contract for grouping database operations.
type UnitOfWork interface {
	// Commit persists all changes atomically.
	Commit(ctx context.Context) error
	// Rollback discards changes if an error occurs.
	Rollback() error
	// Save registers an entity to be persisted.
	Save(entity Entity) error
}

// Entity is a domain object capable of saving itself within a transaction.
type Entity interface {
	Save(tx *sql.Tx) error
}

// unitOfWork implements UnitOfWork by buffering changes until commit.
type unitOfWork struct {
	db      *sql.DB
	tx      *sql.Tx
	changes []Entity
}

The Entity interface is the key abstraction. Every domain object that needs persistence implements Save(tx *sql.Tx). This keeps the transaction handling out of the entity's business logic. The entity just knows how to write itself given a transaction handle.

The unitOfWork struct is private. It starts with no transaction. The transaction begins only when you call Commit. This lazy initialization avoids opening transactions for read-only operations or cases where no changes are made.

Building the methods

The methods handle the lifecycle. Save appends to the list. Commit starts the transaction, runs all saves, and finalizes. Rollback cancels the work.

// NewUnitOfWork creates a fresh unit of work bound to a database.
func NewUnitOfWork(db *sql.DB) UnitOfWork {
	return &unitOfWork{db: db}
}

// Save adds an entity to the list of pending changes.
func (u *unitOfWork) Save(entity Entity) error {
	u.changes = append(u.changes, entity)
	return nil
}

// Commit begins a transaction, executes all saves, and commits.
func (u *unitOfWork) Commit(ctx context.Context) error {
	// Lazily begin the transaction only when committing.
	if u.tx == nil {
		var err error
		u.tx, err = u.db.BeginTx(ctx, nil)
		if err != nil {
			return err
		}
	}
	// Execute saves in order; stop immediately on the first failure.
	for _, e := range u.changes {
		if err := e.Save(u.tx); err != nil {
			return err
		}
	}
	return u.tx.Commit()
}

// Rollback aborts the transaction if one is active.
func (u *unitOfWork) Rollback() error {
	if u.tx != nil {
		return u.tx.Rollback()
	}
	return nil
}

The receiver name u matches the type unitOfWork. Go convention favors short receiver names, usually one or two letters. u is clear and concise.

The Commit method takes context.Context as the first parameter. Context is plumbing. Run it through every long-lived call site. The context allows the transaction to respect deadlines and cancellation signals. If the request times out, the database can abort the transaction early.

BeginTx is preferred over Begin. BeginTx accepts a context. Begin does not. Always use BeginTx so you can propagate cancellation.

Unit of Work batches changes. Transactions guarantee atomicity. Combine them to keep your data sane.

How the compiler and runtime handle it

Go checks interface satisfaction at compile time. There is no implements keyword. The compiler verifies that *unitOfWork has all the methods defined in UnitOfWork. If you rename Save to Persist, the compiler rejects the assignment with cannot use &unitOfWork{} as UnitOfWork in return argument: *unitOfWork does not implement UnitOfWork (wrong type for method Save).

This implicit satisfaction keeps interfaces flexible. You can add methods to the struct without touching the interface. You can satisfy multiple interfaces with one struct. The compiler enforces the contract silently.

At runtime, Save just appends to a slice. No database work happens yet. The slice grows in memory. When Commit runs, BeginTx creates a transaction. The loop calls Save on each entity, passing the transaction. Each entity executes its SQL. If any Save returns an error, the loop stops and Commit returns the error. The transaction remains open.

The caller must call Rollback if Commit fails. If Commit succeeds, the transaction is finalized. The database writes are durable.

Implicit interfaces make mocking trivial. You can test the logic without a database by providing a fake UnitOfWork that records calls. This is a core Go advantage.

Mock the interface. Test the logic. Skip the database.

A realistic service layer

In production code, the unit of work lives in the service layer. The handler creates the unit of work, defers the rollback, saves entities, and commits.

Here's how a checkout handler uses the pattern. The defer ensures cleanup on any exit path. Error wrapping adds context for debugging.

// ProcessCheckout runs inventory deduction and order creation atomically.
func ProcessCheckout(ctx context.Context, db *sql.DB, req CheckoutRequest) error {
	uow := NewUnitOfWork(db)
	// Defer rollback to guarantee cleanup on any exit path.
	defer func() {
		if err := uow.Rollback(); err != nil {
			log.Printf("rollback error: %v", err)
		}
	}()

	// Prepare entities that implement the Entity interface.
	stock := &StockUpdate{ItemID: req.ItemID, Delta: -req.Quantity}
	order := &Order{UserID: req.UserID, Total: req.Total}

	if err := uow.Save(stock); err != nil {
		return fmt.Errorf("save stock: %w", err)
	}
	if err := uow.Save(order); err != nil {
		return fmt.Errorf("save order: %w", err)
	}

	// Commit executes all saves within a single transaction.
	return uow.Commit(ctx)
}

The defer calls Rollback when the function returns. If Commit succeeds, Rollback might return an error because the transaction is already done. The log captures that error for visibility. In many cases, you can ignore the rollback error after a successful commit, but logging it prevents silent failures.

The if err != nil checks are verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Every error is handled explicitly. There are no hidden panics.

Error wrapping with %w preserves the error chain. You can unwrap the error later to check for specific types. This is standard Go error handling.

Always defer the rollback. If the commit succeeds, the rollback is a no-op. If it fails, the data stays clean.

The rollback trap

Rolling back a committed transaction returns an error in Go's database/sql. If you call Rollback after Commit, you get sql: transaction has already been committed.

The defer pattern triggers this. Commit succeeds. The function returns. The deferred Rollback runs. The database complains.

You have two options. First, clear the transaction reference after a successful commit. Update Commit to set u.tx = nil before returning. Then Rollback sees nil and returns early.

Second, ignore the specific error. Check if the error is sql.ErrTxDone. If it is, the transaction is already closed. Log it or drop it.

The first option is cleaner. It keeps the state consistent. The Rollback method doesn't need special error handling.

Update Commit to clear the transaction:

// Commit begins a transaction, executes all saves, and commits.
func (u *unitOfWork) Commit(ctx context.Context) error {
	if u.tx == nil {
		var err error
		u.tx, err = u.db.BeginTx(ctx, nil)
		if err != nil {
			return err
		}
	}
	for _, e := range u.changes {
		if err := e.Save(u.tx); err != nil {
			return err
		}
	}
	err := u.tx.Commit()
	// Clear the transaction so Rollback becomes a no-op.
	u.tx = nil
	return err
}

Now Rollback checks u.tx != nil. After commit, u.tx is nil. Rollback returns nil. No error. No trap.

Transactions are not idempotent. Clear the state after success.

Pitfalls and conventions

Unit of Work introduces a few risks. The struct is not safe for concurrent use. Save appends to a slice. Slice append is not thread-safe. If you call Save from multiple goroutines, you get a race condition. The compiler might catch this with the race detector, or you might see fatal error: concurrent map writes if the slice reallocates while another goroutine reads it.

Keep the unit of work local to the request scope. Create it in the handler. Use it in the service. Destroy it when the request ends. Never share a UnitOfWork across goroutines.

Another pitfall is holding the transaction open too long. The transaction locks rows. If your code does slow work like HTTP calls or file I/O inside the transaction, you block the database. Keep transactions short. Do the database work, commit, then do the side effects.

If you need to send an email after creating an order, send the email after Commit succeeds. Don't send the email inside the transaction. If the email server is down, you don't want to roll back the order.

The receiver name convention applies here too. (u *unitOfWork) is correct. (this *unitOfWork) or (self *unitOfWork) breaks convention. Go code uses short receiver names. Stick to one or two letters.

Public names start with a capital letter. UnitOfWork is public. unitOfWork is private. This controls visibility. The interface is exported. The implementation is hidden. Callers depend on the interface. The package controls the implementation.

Accept interfaces, return structs. This mantra guides Go design. Functions accept UnitOfWork. They return *unitOfWork internally. This keeps the API flexible.

Transactions are not goroutine-safe. Keep the unit of work local to the request scope.

When to use Unit of Work

Use a Unit of Work when you need to group multiple domain operations into a single atomic boundary and want to decouple the transaction lifecycle from the individual saves.

Use a raw sql.Tx when the transaction scope is tiny and explicit, such as a single stored procedure call or a simple read-modify-write loop where buffering adds no value.

Use separate transactions when operations are independent and can succeed or fail without affecting each other, such as logging an audit event after updating a record.

Use an ORM's built-in transaction manager when you are already using an ORM and prefer its abstraction over manual sql.Tx handling, provided the ORM supports context propagation and deferred rollback.

Where to go next