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:

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

❌ Poor Fit

Trade-offs

Advantages

  1. Testability: Mock repositories for unit testing business logic
  2. Flexibility: Swap database implementations without changing business code
  3. Transaction management: Single commit point for multi-entity operations
  4. Separation of concerns: Domain logic knows nothing about SQL
  5. Query optimization: Repository can optimize based on usage patterns

Disadvantages

  1. Indirection: More files, interfaces, and abstractions
  2. Learning curve: Team must understand both patterns
  3. Over-abstraction risk: Can hide database realities (N+1 queries, indexes)
  4. Ceremony: More code for simple operations
  5. 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

PatternUse CaseComplexity
Repository + UoWComplex business logic, DDD, multi-entity transactionsHigh
Active RecordRails-style CRUD, simple apps, rapid prototypingLow
DAO (Data Access Object)Simple CRUD, single entities, procedural codeMedium
Query ObjectRead-heavy, complex reporting, analyticsMedium

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.