Transaction Management
Handling transactions in Spine.
Overview
Spine manages transactions based on Interceptors.
Request
│
├─→ TxInterceptor.PreHandle // Start Transaction
│
├─→ Controller → Service → Repository
│
└─→ TxInterceptor.AfterCompletion // Commit or Rollback
Response- On success → Auto Commit
- On error → Auto Rollback
TxInterceptor Implementation
go
// interceptor/tx_interceptor.go
package interceptor
import (
"errors"
"github.com/NARUBROWN/spine/core"
"github.com/uptrace/bun"
)
type TxInterceptor struct {
db *bun.DB
}
// Constructor — DB Dependency Injection
func NewTxInterceptor(db *bun.DB) *TxInterceptor {
return &TxInterceptor{db: db}
}
// PreHandle — Start Transaction
func (i *TxInterceptor) PreHandle(ctx core.ExecutionContext, meta core.HandlerMeta) error {
reqCtx := ctx.Context()
if reqCtx == nil {
return errors.New("execution context has no request context")
}
// Start Transaction
tx, err := i.db.BeginTx(reqCtx, nil)
if err != nil {
return err
}
// Store in ExecutionContext
ctx.Set("tx", tx)
return nil
}
// PostHandle — Do nothing
func (i *TxInterceptor) PostHandle(ctx core.ExecutionContext, meta core.HandlerMeta) {}
// AfterCompletion — Commit or Rollback
func (i *TxInterceptor) AfterCompletion(ctx core.ExecutionContext, meta core.HandlerMeta, err error) {
v, ok := ctx.Get("tx")
if !ok {
return
}
tx, ok := v.(*bun.Tx)
if !ok {
return
}
// Rollback/Commit based on error
if err != nil {
_ = tx.Rollback()
} else {
_ = tx.Commit()
}
}Registration
go
// main.go
func main() {
app := spine.New()
// 1. Register Constructors
app.Constructor(
NewDB,
interceptor.NewTxInterceptor,
repository.NewUserRepository,
service.NewUserService,
controller.NewUserController,
)
// 2. Register Interceptor (Type reference)
app.Interceptor(
(*interceptor.TxInterceptor)(nil),
)
routes.RegisterUserRoutes(app)
app.Run(":8080")
}bun.IDB Interface
The bun.IDB interface is key to using transactions in Repositories.
Problem
go
// ❌ Cannot use transaction if it receives only *bun.DB
type UserRepository struct {
db *bun.DB
}Solution
go
// ✅ bun.IDB implements both *bun.DB and *bun.Tx
type UserRepository struct {
db bun.IDB
}
func NewUserRepository(db bun.IDB) *UserRepository {
return &UserRepository{db: db}
}What is bun.IDB?
| Type | bun.IDB Implementation |
|---|---|
*bun.DB | ✅ |
*bun.Tx | ✅ |
You can use the same methods (NewSelect, NewInsert, etc.).
Transaction Flow
On Success
1. TxInterceptor.PreHandle
└─→ tx := db.BeginTx()
└─→ ctx.Set("tx", tx)
2. Controller.CreateUser
└─→ Service.Create
└─→ Repository.Save // Uses tx
└─→ return user, nil ✓
3. TxInterceptor.AfterCompletion
└─→ err == nil
└─→ tx.Commit() ✓On Failure
1. TxInterceptor.PreHandle
└─→ tx := db.BeginTx()
└─→ ctx.Set("tx", tx)
2. Controller.CreateUser
└─→ Service.Create
└─→ Repository.Save // Uses tx
└─→ return error ✗
3. TxInterceptor.AfterCompletion
└─→ err != nil
└─→ tx.Rollback() ✓Using Transactions in Repository
In the current structure, Repository receives bun.IDB at creation time.
go
// repository/user_repository.go
type UserRepository struct {
db bun.IDB
}
func NewUserRepository(db bun.IDB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Save(ctx context.Context, user *entity.User) error {
_, err := r.db.NewInsert().
Model(user).
Exec(ctx)
return err
}Using in Transaction Context
You can create helper functions to retrieve the transaction stored by the interceptor.
go
// db/context.go
package db
import (
"context"
"github.com/uptrace/bun"
)
type ctxKey string
const txKey ctxKey = "tx"
// Get transaction from context
func GetTx(ctx context.Context) bun.IDB {
if tx, ok := ctx.Value(txKey).(bun.IDB); ok {
return tx
}
return nil
}
// Store transaction in context
func WithTx(ctx context.Context, tx bun.IDB) context.Context {
return context.WithValue(ctx, txKey, tx)
}Using Multiple Repositories
Using multiple repositories within a single transaction.
Service Example
go
// service/order_service.go
type OrderService struct {
orderRepo *repository.OrderRepository
userRepo *repository.UserRepository
}
func NewOrderService(
orderRepo *repository.OrderRepository,
userRepo *repository.UserRepository,
) *OrderService {
return &OrderService{
orderRepo: orderRepo,
userRepo: userRepo,
}
}
func (s *OrderService) CreateOrder(ctx context.Context, userID int, items []Item) error {
// 1. Find User
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return err // Rolled back
}
// 2. Create Order
order := &entity.Order{UserID: user.ID, Items: items}
if err := s.orderRepo.Save(ctx, order); err != nil {
return err // Rolled back
}
// 3. Deduct User Points
user.Points -= calculateTotal(items)
if err := s.userRepo.Update(ctx, user); err != nil {
return err // Rolled back
}
return nil // All success → Commit
}All operations execute within the same transaction.
Transaction Options
Read-Only Transaction
go
func (i *TxInterceptor) PreHandle(ctx core.ExecutionContext, meta core.HandlerMeta) error {
reqCtx := ctx.Context()
// Read-only transaction
tx, err := i.db.BeginTx(reqCtx, &sql.TxOptions{
ReadOnly: true,
})
if err != nil {
return err
}
ctx.Set("tx", tx)
return nil
}Isolation Level
go
import "database/sql"
tx, err := i.db.BeginTx(reqCtx, &sql.TxOptions{
Isolation: sql.LevelSerializable, // Serializable
})| Isolation Level | Constant |
|---|---|
| Read Uncommitted | sql.LevelReadUncommitted |
| Read Committed | sql.LevelReadCommitted |
| Repeatable Read | sql.LevelRepeatableRead |
| Serializable | sql.LevelSerializable |
Optional Transaction
Transactions may not be needed for all requests.
Method 1: Distinguish by Method Name
go
func (i *TxInterceptor) PreHandle(ctx core.ExecutionContext, meta core.HandlerMeta) error {
methodName := meta.Method.Name
// Skip transaction for methods starting with Get
if strings.HasPrefix(methodName, "Get") || strings.HasPrefix(methodName, "List") {
return nil
}
// Start transaction
tx, err := i.db.BeginTx(ctx.Context(), nil)
if err != nil {
return err
}
ctx.Set("tx", tx)
return nil
}
func (i *TxInterceptor) AfterCompletion(ctx core.ExecutionContext, meta core.HandlerMeta, err error) {
v, ok := ctx.Get("tx")
if !ok {
return // Skip if no transaction
}
tx := v.(*bun.Tx)
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}Method 2: Distinguish by HTTP Method
go
func (i *TxInterceptor) PreHandle(ctx core.ExecutionContext, meta core.HandlerMeta) error {
// Skip transaction for GET requests
if ctx.Method() == "GET" {
return nil
}
tx, err := i.db.BeginTx(ctx.Context(), nil)
if err != nil {
return err
}
ctx.Set("tx", tx)
return nil
}Error Logging
You can add logging upon transaction rollback.
go
func (i *TxInterceptor) AfterCompletion(ctx core.ExecutionContext, meta core.HandlerMeta, err error) {
v, ok := ctx.Get("tx")
if !ok {
return
}
tx := v.(*bun.Tx)
if err != nil {
log.Printf("[TX] Rollback: %s %s - %v", ctx.Method(), ctx.Path(), err)
_ = tx.Rollback()
} else {
log.Printf("[TX] Commit: %s %s", ctx.Method(), ctx.Path())
_ = tx.Commit()
}
}Complete Example
go
// main.go
func main() {
app := spine.New()
app.Constructor(
NewDB,
interceptor.NewTxInterceptor,
repository.NewUserRepository,
repository.NewOrderRepository,
service.NewUserService,
service.NewOrderService,
controller.NewUserController,
controller.NewOrderController,
)
app.Interceptor(
(*interceptor.TxInterceptor)(nil),
&interceptor.LoggingInterceptor{},
)
routes.RegisterUserRoutes(app)
routes.RegisterOrderRoutes(app)
app.Run(":8080")
}go
// interceptor/tx_interceptor.go
type TxInterceptor struct {
db *bun.DB
}
func NewTxInterceptor(db *bun.DB) *TxInterceptor {
return &TxInterceptor{db: db}
}
func (i *TxInterceptor) PreHandle(ctx core.ExecutionContext, meta core.HandlerMeta) error {
tx, err := i.db.BeginTx(ctx.Context(), nil)
if err != nil {
return err
}
ctx.Set("tx", tx)
return nil
}
func (i *TxInterceptor) PostHandle(ctx core.ExecutionContext, meta core.HandlerMeta) {}
func (i *TxInterceptor) AfterCompletion(ctx core.ExecutionContext, meta core.HandlerMeta, err error) {
v, ok := ctx.Get("tx")
if !ok {
return
}
tx := v.(*bun.Tx)
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}Key Takeaways
| Concept | Description |
|---|---|
| Interceptor-Based | Start in PreHandle, End in AfterCompletion |
| Auto Commit/Rollback | Processed automatically based on error |
| bun.IDB | Interface accepting both DB and Tx |
| Context Sharing | Pass via ctx.Set("tx", tx) |
Next Steps
- Tutorial: Error Handling — httperr usage
- Reference: API — Spine API Docs
