Unit of Work (UOW) Pattern
🏁 Overview
This document outlines the improvements made to the Unit of Work (UOW) pattern in the fintech application, focusing on maintaining transaction safety while improving type safety and developer experience.
✅ What Works Well
The current UOW pattern provides excellent transaction management:
// Current pattern - excellent transaction handling
err = s.uow.Do(context.Background(), func(uow repository.UnitOfWork) error {
// All operations use the same transaction session
repoAny, err := uow.GetRepository(reflect.TypeOf((*repository.AccountRepository)(nil)).Elem())
if err != nil {
return err
}
accountRepo := repoAny.(repository.AccountRepository)
// Business logic here...
return nil
})
Benefits:
- ✅ Automatic transaction boundaries - begin/commit/rollback handled automatically
- ✅ Repository coordination - all repositories use same transaction session
- ✅ Atomic operations - all-or-nothing semantics
- ✅ Error handling - automatic rollback on any error
- ✅ Clean architecture - business logic separated from infrastructure
❌ What Needs Improvement
The current pattern has some developer experience issues:
// Problems with current approach:
// 1. Complex reflect syntax
// 2. Type casting required
// 3. Runtime errors possible
// 4. Poor IDE support
repoAny, err := uow.GetRepository(reflect.TypeOf((*repository.AccountRepository)(nil)).Elem())
accountRepo := repoAny.(repository.AccountRepository)
📦 Implementation
We've added type-safe convenience methods to the UOW interface:
// pkg/repository/uow.go
type UnitOfWork interface {
// Existing methods
Do(ctx context.Context, fn func(uow UnitOfWork) error) error
GetRepository(repoType reflect.Type) (any, error)
// New type-safe convenience methods
AccountRepository() (AccountRepository, error)
TransactionRepository() (TransactionRepository, error)
UserRepository() (UserRepository, error)
}
Implementation in Infrastructure Layer
// infra/repository/uow.go
func (u *UoW) AccountRepository() (repository.AccountRepository, error) {
repoAny, err := u.GetRepository(reflect.TypeOf((*repository.AccountRepository)(nil)).Elem())
if err != nil {
return nil, err
}
return repoAny.(repository.AccountRepository), nil
}
func (u *UoW) TransactionRepository() (repository.TransactionRepository, error) {
repoAny, err := u.GetRepository(reflect.TypeOf((*repository.TransactionRepository)(nil)).Elem())
if err != nil {
return nil, err
}
return repoAny.(repository.TransactionRepository), nil
}
func (u *UoW) UserRepository() (repository.UserRepository, error) {
repoAny, err := u.GetRepository(reflect.TypeOf((*repository.UserRepository)(nil)).Elem())
if err != nil {
return nil, err
}
return repoAny.(repository.UserRepository), nil
}
🚀 Migration Notes
Before (Current Pattern)
func (s *AccountService) Deposit(userID, accountID uuid.UUID, amount float64, currencyCode currency.Code) error {
return s.uow.Do(context.Background(), func(uow repository.UnitOfWork) error {
// Complex reflect-based repository access
repoAny, err := uow.GetRepository(reflect.TypeOf((*repository.AccountRepository)(nil)).Elem())
if err != nil {
return err
}
accountRepo := repoAny.(repository.AccountRepository)
txRepoAny, err := uow.GetRepository(reflect.TypeOf((*repository.TransactionRepository)(nil)).Elem())
if err != nil {
return err
}
txRepo := txRepoAny.(repository.TransactionRepository)
// Business logic...
return nil
})
}
After (Improved Pattern)
func (s *AccountService) Deposit(userID, accountID uuid.UUID, amount float64, currencyCode currency.Code) error {
return s.uow.Do(context.Background(), func(uow repository.UnitOfWork) error {
// Type-safe repository access - no reflect needed!
accountRepo, err := uow.AccountRepository()
if err != nil {
return err
}
txRepo, err := uow.TransactionRepository()
if err != nil {
return err
}
// Business logic...
return nil
})
}
Alternative Approaches Considered
1. String-Based Repository Names
// Alternative: String-based approach
type StringBasedUnitOfWork interface {
GetRepository(repoName string) (any, error)
}
// Usage
accountRepoAny, err := uow.GetRepository("account")
accountRepo := accountRepoAny.(repository.AccountRepository)
Pros:
- ✅ Simpler API
- ✅ More readable
- ✅ No reflect in service code
Cons:
- ❌ Runtime errors (typos)
- ❌ No IDE support
- ❌ No compile-time safety
2. Generic Repositories
// Alternative: Generic repositories
type GenericRepository[T any] interface {
Get(ctx context.Context, id uuid.UUID) (*T, error)
Create(ctx context.Context, entity *T) error
Update(ctx context.Context, entity *T) error
Delete(ctx context.Context, id uuid.UUID) error
}
type GenericUnitOfWork interface {
AccountRepository() GenericRepository[account.Account]
TransactionRepository() GenericRepository[account.Transaction]
}
Pros:
- ✅ Full type safety
- ✅ No reflect needed
- ✅ Excellent IDE support
Cons:
- ❌ More complex implementation
- ❌ Requires significant refactoring
- ❌ May not fit existing patterns
Recommended Migration Strategy
Phase 1: Add Type-Safe Methods (✅ Complete)
- ✅ Add convenience methods to
UnitOfWork
interface - ✅ Implement methods in
UoW
struct - ✅ Maintain backward compatibility
Phase 2: Update Service Code (Recommended)
Gradually update service methods to use the new convenience methods:
// Example: Update account service
func (s *AccountService) executeOperation(req operationRequest, handler operationHandler) (result *operationResult, err error) {
err = s.uow.Do(context.Background(), func(uow repository.UnitOfWork) error {
// NEW: Use type-safe methods
accountRepo, err := uow.AccountRepository()
if err != nil {
return err
}
txRepo, err := uow.TransactionRepository()
if err != nil {
return err
}
// OLD: Remove reflect-based code
// repoAny, err := uow.GetRepository(reflect.TypeOf((*repository.AccountRepository)(nil)).Elem())
// accountRepo := repoAny.(repository.AccountRepository)
// Business logic remains the same...
return nil
})
return result, err
}
Phase 3: Consider Future Enhancements
- Generic repositories - For new services
- String-based approach - For dynamic repository loading
- Hybrid approach - Combine multiple patterns
💡 Benefits
✅ Developer Experience
- Type Safety - Compile-time error checking
- IDE Support - Autocomplete and refactoring
- Readability - Clean, self-documenting code
- Maintainability - Easier to understand and modify
✅ Transaction Safety
- All existing benefits preserved - No changes to transaction handling
- Same atomicity guarantees - All-or-nothing operations
- Same error handling - Automatic rollback on errors
- Same repository coordination - All repositories use same session
✅ Backward Compatibility
- Existing code continues to work -
GetRepository()
method still available - Gradual migration - Update services one by one
- No breaking changes - Same interface, additional methods
Code Examples
Complete Service Example
type AccountService struct {
uow repository.UnitOfWork
converter mon.CurrencyConverter
logger *slog.Logger
}
func (s *AccountService) Deposit(userID, accountID uuid.UUID, amount float64, currencyCode currency.Code) error {
logger := s.logger.With("userID", userID, "accountID", accountID, "amount", amount, "currency", currencyCode)
logger.Info("Deposit started")
var tx *account.Transaction
var convInfo *common.ConversionInfo
err := s.uow.Do(context.Background(), func(uow repository.UnitOfWork) error {
// Type-safe repository access
accountRepo, err := uow.AccountRepository()
if err != nil {
logger.Error("Failed to get account repository", "error", err)
return err
}
txRepo, err := uow.TransactionRepository()
if err != nil {
logger.Error("Failed to get transaction repository", "error", err)
return err
}
// Get account
acc, err := accountRepo.Get(accountID)
if err != nil {
logger.Error("Account not found", "error", err)
return account.ErrAccountNotFound
}
// Business logic...
tx, err = acc.Deposit(userID, money)
if err != nil {
return err
}
// Update account and create transaction
if err = accountRepo.Update(acc); err != nil {
return err
}
if err = txRepo.Create(tx); err != nil {
return err
}
return nil
})
if err != nil {
logger.Error("Deposit failed", "error", err)
return err
}
logger.Info("Deposit successful", "transactionID", tx.ID)
return nil
}
Testing Example
func TestAccountService_Deposit(t *testing.T) {
// Mock UOW with type-safe methods
mockUOW := &MockUnitOfWork{
DoFunc: func(ctx context.Context, fn func(repository.UnitOfWork) error) error {
return fn(mockUOW)
},
AccountRepositoryFunc: func() (repository.AccountRepository, error) {
return mockAccountRepo, nil
},
TransactionRepositoryFunc: func() (repository.TransactionRepository, error) {
return mockTransactionRepo, nil
},
}
service := NewAccountService(mockUOW, converter, logger)
// Test implementation...
}
Conclusion
The improved UOW pattern provides:
- ✅ All existing transaction benefits - No compromise on data integrity
- ✅ Better developer experience - Type safety and IDE support
- ✅ Backward compatibility - Existing code continues to work
- ✅ Gradual migration path - Update services incrementally
Recommendation: Use the type-safe convenience methods for new code and gradually migrate existing services. The current UOW pattern is excellent - we've just made it even better! 🎉