A clean way to manage database transaction in Golang

Nator Verinumbe
4 min readJun 14, 2024

--

Efficient database transactions are essential for data integrity. Using GORM in Go, here’s a quick, clean approach to handle transactions without mixing business logic in the repository layer.

Example Usage

func (s *Service) PerformBusinessLogic(ctx context.Context) error {
return s.repo.Transaction(ctx, func(repo Repository) error {
if err := repo.Create(ctx, &Entity{Name: "Example"}); err != nil {
return err
}
return repo.Update(ctx, &Entity{ID: 1, Name: "Updated Example"})
})
}

This approach keeps database operations and business logic separate, ensuring clean and maintainable code.

For a complete example of this implementation, you can check out the Gist here.

Implementation

  1. Define the Repository Interface

Define methods for database operations and transaction handling:

type Repository interface {
Create(ctx context.Context, entity *Entity) error
Update(ctx context.Context, entity *Entity) error
Transaction(ctx context.Context, fn func(repo Repository) error) error
}

2. Implement the Repository Interface

Implement the Repository interface in your struct:

type repository struct {
db *gorm.DB
}


func (r *repository) Create(ctx context.Context, entity *Entity) error {
...
}

func (r *repository) Update(ctx context.Context, entity *Entity) error {
...
}

func (r *repository) Transaction(ctx context.Context, fn func(repo Repository) error) error {
-> check below
}

3. Handle Transactions

Use withTx to manage transaction context:

func (r *repository) withTx(tx *gorm.DB) Repository {
return &repository{
db: tx,
}
}

Implement the Transaction method to manage the lifecycle:

func (r *repository) Transaction(ctx context.Context, fn func(repo Repository) error) error {
tx := r.db.Begin()
if tx.Error != nil {
return tx.Error
}
repo := r.withTx(tx)
err := fn(repo)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}

Sample Code For The Repository Layer

package main

import (
"context"
"fmt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

// Entity represents a database entity
type Entity struct {
ID uint
Name string
}

// Repository defines the methods for database operations and transaction handling
type Repository interface {
Create(ctx context.Context, entity *Entity) error
Update(ctx context.Context, entity *Entity) error
Transaction(ctx context.Context, fn func(repo Repository) error) error
}

// repository implements the Repository interface and holds the GORM DB instance
type repository struct {
db *gorm.DB
}

// withTx creates a new repository instance with the given transaction
func (r *repository) withTx(tx *gorm.DB) Repository {
return &repository{
db: tx,
}
}

// Transaction manages the transaction lifecycle
func (r *repository) Transaction(ctx context.Context, fn func(repo Repository) error) error {
tx := r.db.Begin()
if tx.Error != nil {
return tx.Error
}
repo := r.withTx(tx)
err := fn(repo)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}

// Create adds a new entity to the database
func (r *repository) Create(ctx context.Context, entity *Entity) error {
return r.db.Create(entity).Error
}

// Update modifies an existing entity in the database
func (r *repository) Update(ctx context.Context, entity *Entity) error {
return r.db.Save(entity).Error
}

Sample Code For The Service Layer

// Service handles the business logic
type Service struct {
repo Repository
}

// PerformBusinessLogic performs multiple database operations within a transaction
func (s *Service) PerformBusinessLogic(ctx context.Context) error {
return s.repo.Transaction(ctx, func(repo Repository) error {

if err := repo.Create(ctx, &Entity{Name: "Example"}); err != nil {
return err
}

return repo.Update(ctx, &Entity{ID: 1, Name: "Updated Example"})
})
}

The Main Function

 func main() {
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}

// Migrate the schema
db.AutoMigrate(&Entity{})

repo := &repository{db: db}
service := &Service{repo: repo}

ctx := context.Background()
if err := service.PerformBusinessLogic(ctx); err != nil {
fmt.Printf("Transaction failed: %v\n", err)
} else {
fmt.Println("Transaction succeeded")
}
}

GITHUB GIST

Why Transactions?

Transactions ensure:

  • Atomicity: All or nothing operations.
  • Consistency: Valid state transitions.
  • Isolation: No intermediate states visible.
  • Durability: Committed changes persist.

By using this approach, you can handle database transactions in Go cleanly and effectively, maintaining data integrity without mixing business logic in the repository layer.

--

--

Nator Verinumbe

Not afraid of hard work, taking risks and getting broke (maybe a little nowadays). Constantly trying, learning and trying again.