A clean way to manage database transaction in Golang
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
- 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")
}
}
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.