Repository Pattern with Unit of Work in Go: Clean Data Access Architecture
Repository Pattern with Unit of Work in Go
Overview
The Repository Pattern provides an abstraction layer between business logic and data access, while the Unit of Work Pattern coordinates multiple repository operations within a single transaction. Together, they create a clean, testable architecture for data persistence that decouples domain logic from database implementation details.
The Problem
Modern applications often suffer from data access code scattered throughout business logic:
// Anti-pattern: Business logic tightly coupled to database
func TransferFunds(db *sql.DB, fromID, toID int64, amount float64) error {
tx, _ := db.Begin()
// Direct SQL in business logic
_, err := tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
Problems:
- Business logic knows about SQL, transactions, database connections
- Impossible to unit test without a real database
- Changing database technology requires rewriting business logic
- No clear separation of concerns
The Solution: Repository + Unit of Work
Repository Pattern
Provides a collection-like interface for accessing domain entities:
// Domain entity - no database knowledge
type Account struct {
ID int64
UserID int64
Balance float64
Version int64 // For optimistic locking
}
// Repository interface - describes what operations are possible
type AccountRepository interface {
FindByID(ctx context.Context, id int64) (*Account, error)
FindByUserID(ctx context.Context, userID int64) ([]*Account, error)
Save(ctx context.Context, account *Account) error
Delete(ctx context.Context, id int64) error
}
Unit of Work Pattern
Coordinates changes across multiple repositories in a single transaction:
// UnitOfWork manages transaction boundaries
type UnitOfWork interface {
AccountRepo() AccountRepository
TransactionRepo() TransactionRepository
// Commit finalizes all changes
Commit(ctx context.Context) error
// Rollback discards all changes
Rollback(ctx context.Context) error
}
Clean Business Logic
type TransferService struct {
uowFactory func(ctx context.Context) (UnitOfWork, error)
}
func (s *TransferService) TransferFunds(ctx context.Context, fromID, toID int64, amount float64) error {
uow, err := s.uowFactory(ctx)
if err != nil {
return err
}
defer uow.Rollback(ctx) // Cleanup on error
// Pure business logic - no SQL
fromAccount, err := uow.AccountRepo().FindByID(ctx, fromID)
if err != nil {
return fmt.Errorf("find source account: %w", err)
}
toAccount, err := uow.AccountRepo().FindByID(ctx, toID)
if err != nil {
return fmt.Errorf("find destination account: %w", err)
}
// Domain validation
if fromAccount.Balance < amount {
return ErrInsufficientFunds
}
// Domain logic
fromAccount.Balance -= amount
toAccount.Balance += amount
// Persist changes
if err := uow.AccountRepo().Save(ctx, fromAccount); err != nil {
return err
}
if err := uow.AccountRepo().Save(ctx, toAccount); err != nil {
return err
}
// Atomic commit
return uow.Commit(ctx)
}
Implementation in Go
SQL-based Repository Implementation
type sqlAccountRepository struct {
tx *sql.Tx
}
func NewSQLAccountRepository(tx *sql.Tx) AccountRepository {
return &sqlAccountRepository{tx: tx}
}
func (r *sqlAccountRepository) FindByID(ctx context.Context, id int64) (*Account, error) {
var acc Account
err := r.tx.QueryRowContext(ctx, `
SELECT id, user_id, balance, version
FROM accounts
WHERE id = ?
`, id).Scan(&acc.ID, &acc.UserID, &acc.Balance, &acc.Version)
if err == sql.ErrNoRows {
return nil, ErrAccountNotFound
}
return &acc, err
}
func (r *sqlAccountRepository) Save(ctx context.Context, acc *Account) error {
// Optimistic locking
result, err := r.tx.ExecContext(ctx, `
UPDATE accounts
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
`, acc.Balance, acc.ID, acc.Version)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrConcurrentModification
}
acc.Version++ // Update in-memory version
return nil
}
Unit of Work Implementation
type sqlUnitOfWork struct {
db *sql.DB
tx *sql.Tx
accountRepo AccountRepository
transactionRepo TransactionRepository
}
func NewSQLUnitOfWork(db *sql.DB) (UnitOfWork, error) {
tx, err := db.Begin()
if err != nil {
return nil, err
}
return &sqlUnitOfWork{
db: db,
tx: tx,
}, nil
}
func (u *sqlUnitOfWork) AccountRepo() AccountRepository {
if u.accountRepo == nil {
u.accountRepo = NewSQLAccountRepository(u.tx)
}
return u.accountRepo
}
func (u *sqlUnitOfWork) TransactionRepo() TransactionRepository {
if u.transactionRepo == nil {
u.transactionRepo = NewSQLTransactionRepository(u.tx)
}
return u.transactionRepo
}
func (u *sqlUnitOfWork) Commit(ctx context.Context) error {
return u.tx.Commit()
}
func (u *sqlUnitOfWork) Rollback(ctx context.Context) error {
return u.tx.Rollback()
}
When to Use
✅ Good Fit
- Complex business logic requiring multiple database operations in a transaction
- Multiple data sources (SQL, NoSQL, APIs) that need consistent abstraction
- Testing is critical — need to unit test business logic without databases
- Domain-driven design — separating domain models from persistence
- Team size > 3 — clear boundaries reduce merge conflicts
❌ Poor Fit
- CRUD applications with minimal business logic (use query builders directly)
- Read-heavy systems with simple queries (overhead not justified)
- Prototypes and MVPs (adds complexity without proven value)
- Single-table operations (use simpler DAO pattern)
Trade-offs
Advantages
- Testability: Mock repositories for unit testing business logic
- Flexibility: Swap database implementations without changing business code
- Transaction management: Single commit point for multi-entity operations
- Separation of concerns: Domain logic knows nothing about SQL
- Query optimization: Repository can optimize based on usage patterns
Disadvantages
- Indirection: More files, interfaces, and abstractions
- Learning curve: Team must understand both patterns
- Over-abstraction risk: Can hide database realities (N+1 queries, indexes)
- Ceremony: More code for simple operations
- Performance: Extra layer can make optimization less obvious
Best Practices
1. Keep Repositories Focused
// Good - focused interface
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
Save(ctx context.Context, user *User) error
}
// Bad - kitchen sink repository
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
FindWithPostsAndComments(ctx context.Context, id int64) (*User, error)
FindActiveUsersInRegionWithPremiumPlan(ctx context.Context, region string) ([]*User, error)
// 20 more methods...
}
2. Return Domain Entities, Not DTOs
Repositories should return rich domain objects, not anemic data structures:
// Good - domain entity with behavior
type Order struct {
ID int64
Items []OrderItem
Status OrderStatus
}
func (o *Order) CanBeCancelled() bool {
return o.Status == StatusPending || o.Status == StatusConfirmed
}
// Bad - anemic DTO
type OrderDTO struct {
ID int64
Items []OrderItemDTO
Status string
}
3. Use Specification Pattern for Complex Queries
type Specification interface {
ToSQL() (query string, args []interface{})
}
type ActiveUsersSpec struct {
MinLastLogin time.Time
}
func (s ActiveUsersSpec) ToSQL() (string, []interface{}) {
return "WHERE last_login > ? AND status = 'active'", []interface{}{s.MinLastLogin}
}
func (r *sqlUserRepository) FindBySpec(ctx context.Context, spec Specification) ([]*User, error) {
query, args := spec.ToSQL()
// Execute query...
}
4. Handle Transactions at Service Layer
// Service orchestrates transaction
func (s *OrderService) PlaceOrder(ctx context.Context, userID int64, items []OrderItem) error {
uow, _ := s.uowFactory(ctx)
defer uow.Rollback(ctx)
// Multiple repository calls in one transaction
user, _ := uow.UserRepo().FindByID(ctx, userID)
order := NewOrder(user, items)
uow.OrderRepo().Save(ctx, order)
uow.InventoryRepo().ReserveItems(ctx, items)
uow.PaymentRepo().ChargeUser(ctx, user, order.Total())
return uow.Commit(ctx)
}
Comparison with Other Patterns
| Pattern | Use Case | Complexity |
|---|---|---|
| Repository + UoW | Complex business logic, DDD, multi-entity transactions | High |
| Active Record | Rails-style CRUD, simple apps, rapid prototyping | Low |
| DAO (Data Access Object) | Simple CRUD, single entities, procedural code | Medium |
| Query Object | Read-heavy, complex reporting, analytics | Medium |
Testing Example
// Mock repository for unit tests
type mockAccountRepo struct {
accounts map[int64]*Account
}
func (m *mockAccountRepo) FindByID(ctx context.Context, id int64) (*Account, error) {
acc, ok := m.accounts[id]
if !ok {
return nil, ErrAccountNotFound
}
return acc, nil
}
// Unit test with no database
func TestTransferFunds(t *testing.T) {
mockUoW := &mockUnitOfWork{
accountRepo: &mockAccountRepo{
accounts: map[int64]*Account{
1: {ID: 1, Balance: 1000},
2: {ID: 2, Balance: 500},
},
},
}
service := &TransferService{
uowFactory: func(ctx context.Context) (UnitOfWork, error) {
return mockUoW, nil
},
}
err := service.TransferFunds(context.Background(), 1, 2, 300)
assert.NoError(t, err)
// Verify state changes
from, _ := mockUoW.AccountRepo().FindByID(context.Background(), 1)
assert.Equal(t, 700.0, from.Balance)
}
Conclusion
The Repository and Unit of Work patterns create a clean separation between business logic and data access. While they add complexity, they pay dividends in testability, maintainability, and flexibility for applications with complex domain logic. For simple CRUD applications, simpler patterns may be more appropriate.
Key Takeaway: Use these patterns when your business logic complexity justifies the abstraction overhead. Don’t apply them dogmatically — match the pattern to the problem.