Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 291 additions & 0 deletions docs/transaction-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
# Transaction Management

SRouter provides automatic transaction management for database operations, allowing you to declaratively specify which routes should run within a database transaction. When enabled, the framework automatically begins a transaction before executing the handler and commits or rolls back based on the handler's success.

## Overview

Transaction management in SRouter:
- Automatically begins transactions before handler execution
- Commits on successful responses (2xx and 3xx status codes)
- Rolls back on errors (4xx and 5xx status codes) or panics
- Works with any database that implements the `DatabaseTransaction` interface
- Follows the standard configuration hierarchy (route > subrouter > global)

## Configuration

### 1. Implement TransactionFactory

First, implement the `TransactionFactory` interface for your database:

```go
type MyTransactionFactory struct {
db *gorm.DB
}

func (f *MyTransactionFactory) BeginTransaction(ctx context.Context, options map[string]any) (scontext.DatabaseTransaction, error) {
// Extract options if needed
var txOptions *sql.TxOptions
if isolation, ok := options["isolation"].(sql.IsolationLevel); ok {
txOptions = &sql.TxOptions{
Isolation: isolation,
}
}

// Begin transaction
tx := f.db.WithContext(ctx).Begin(txOptions)
if tx.Error != nil {
return nil, tx.Error
}

// Wrap with GormTransactionWrapper
return middleware.NewGormTransactionWrapper(tx), nil
}
```

### 2. Configure the Router

Add the transaction factory to your router configuration:

```go
router := router.NewRouter[string, User](router.RouterConfig{
Logger: logger,
TransactionFactory: &MyTransactionFactory{db: db},
// Global transaction configuration (optional)
GlobalTransaction: &common.TransactionConfig{
Enabled: true,
Options: map[string]any{
"isolation": sql.LevelReadCommitted,
},
},
}, authFunc, userIDFunc)
```

### 3. Enable Transactions for Routes

#### For Individual Routes

```go
router.RegisterRoute(router.RouteConfigBase{
Path: "/users",
Methods: []router.HttpMethod{router.MethodPost},
Handler: createUserHandler,
Overrides: common.RouteOverrides{
Transaction: &common.TransactionConfig{
Enabled: true,
},
},
})
```

#### For Generic Routes

```go
router.NewGenericRouteDefinition[CreateUserReq, CreateUserResp, string, User](
router.RouteConfig[CreateUserReq, CreateUserResp]{
Path: "/users",
Methods: []router.HttpMethod{router.MethodPost},
Codec: codec.NewJSONCodec[CreateUserReq, CreateUserResp](),
Handler: createUserHandler,
Overrides: common.RouteOverrides{
Transaction: &common.TransactionConfig{
Enabled: true,
Options: map[string]any{
"isolation": sql.LevelSerializable,
},
},
},
},
)
```

#### For Subrouters

```go
router.RouterConfig{
SubRouters: []router.SubRouterConfig{
{
PathPrefix: "/api",
Overrides: common.RouteOverrides{
Transaction: &common.TransactionConfig{
Enabled: true,
},
},
Routes: []router.RouteDefinition{
// All routes here will have transactions enabled by default
},
},
},
}
```

## Using Transactions in Handlers

Access the transaction from the request context:

```go
func createUserHandler(w http.ResponseWriter, r *http.Request) {
// Get the transaction
tx, ok := scontext.GetTransactionFromRequest[string, User](r)
if !ok {
http.Error(w, "No transaction available", http.StatusInternalServerError)
return
}

// Get the underlying database connection (for GORM)
db := tx.GetDB()

// Perform database operations
var user User
if err := db.Create(&user).Error; err != nil {
// Return error response - transaction will be rolled back
http.Error(w, "Failed to create user", http.StatusInternalServerError)
return
}

// Return success - transaction will be committed
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
```

## Transaction Behavior

### Automatic Commit

Transactions are automatically committed when:
- Handler returns without error (for generic routes)
- Response status is 2xx or 3xx (for all routes)
- No panic occurs

### Automatic Rollback

Transactions are automatically rolled back when:
- Handler returns an error (for generic routes)
- Response status is 4xx or 5xx
- A panic occurs (caught by recovery middleware)
- Transaction factory fails to create a transaction

### Configuration Hierarchy

Transaction configuration follows the standard SRouter hierarchy:
1. Route-specific configuration (highest priority)
2. Subrouter configuration
3. Global router configuration (lowest priority)

Example:
```go
// Global: transactions disabled
GlobalTransaction: nil,

SubRouters: []SubRouterConfig{
{
PathPrefix: "/api",
Overrides: RouteOverrides{
// Subrouter: transactions enabled for all /api routes
Transaction: &TransactionConfig{Enabled: true},
},
Routes: []RouteDefinition{
RouteConfigBase{
Path: "/health",
// Route: transactions disabled for this specific route
Overrides: RouteOverrides{
Transaction: &TransactionConfig{Enabled: false},
},
},
},
},
}
```

## Advanced Usage

### Custom Transaction Options

Pass database-specific options through the configuration:

```go
Transaction: &common.TransactionConfig{
Enabled: true,
Options: map[string]any{
"isolation": sql.LevelSerializable,
"read_only": true,
"timeout": 30 * time.Second,
},
}
```

### Savepoints

Use savepoints for nested transaction-like behavior:

```go
tx, _ := scontext.GetTransactionFromRequest[string, User](r)

// Create a savepoint
if err := tx.SavePoint("before_risky_operation"); err != nil {
// Handle error
}

// Perform risky operation
if err := riskyOperation(tx.GetDB()); err != nil {
// Rollback to savepoint
if err := tx.RollbackTo("before_risky_operation"); err != nil {
// Handle rollback error
}
// Continue with alternative logic
} else {
// Operation succeeded, continue
}
```

### Testing with Transactions

Use the mock transaction factory for testing:

```go
import "github.com/Suhaibinator/SRouter/pkg/router/internal/mocks"

mockFactory := &mocks.MockTransactionFactory{
BeginFunc: func(ctx context.Context, options map[string]any) (scontext.DatabaseTransaction, error) {
return &mocks.MockTransaction{
CommitFunc: func() error {
// Track commits in tests
return nil
},
}, nil
},
}

router := router.NewRouter[string, User](router.RouterConfig{
TransactionFactory: mockFactory,
}, authFunc, userIDFunc)
```

## Best Practices

1. **Idempotency**: Design handlers to be idempotent when possible, as transactions may be retried
2. **Timeout Handling**: Set appropriate timeouts for long-running transactions
3. **Error Responses**: Return appropriate HTTP status codes to trigger correct commit/rollback behavior
4. **Connection Pooling**: Ensure your transaction factory properly manages database connections
5. **Isolation Levels**: Choose appropriate isolation levels based on your consistency requirements

## Performance Considerations

- Transactions are only created when explicitly enabled - no overhead for non-transactional routes
- The framework adds minimal overhead beyond the database transaction itself
- Consider connection pool limits when enabling transactions globally
- Use read-only transactions when appropriate for better performance

## Compatibility

The transaction management system works with any database that can implement the `DatabaseTransaction` interface. A GORM adapter (`GormTransactionWrapper`) is provided out of the box, but you can implement the interface for any database:

```go
type DatabaseTransaction interface {
Commit() error
Rollback() error
SavePoint(name string) error
RollbackTo(name string) error
GetDB() *gorm.DB // Or your database type
}
```
2 changes: 1 addition & 1 deletion examples/codec/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func main() {
// Register the generic route directly on the router instance 'r'
// Provide zero/nil for effective settings (timeout, body size, rate limit)
// as these are not overridden at the route level here.
router.RegisterGenericRoute(r, routeCfg, 0, 0, nil)
router.RegisterGenericRoute(r, routeCfg, 0, 0, nil, nil)

// Start the HTTP server
port := ":8080"
Expand Down
12 changes: 6 additions & 6 deletions examples/generic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,42 +312,42 @@ func main() {
Codec: codec.NewJSONCodec[CreateUserRequest, CreateUserResponse](),
Handler: CreateUserHandler,
Sanitizer: SanitizeCreateUserRequest, // Add the sanitizer function here
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

router.RegisterGenericRoute(r, router.RouteConfig[GetUserRequest, GetUserResponse]{
Path: "/users/:id",
Methods: []router.HttpMethod{router.MethodGet}, // Use string literal or http.MethodGet constant
Codec: codec.NewJSONCodec[GetUserRequest, GetUserResponse](), // Codec might not be used if ID is only from path
Handler: GetUserHandler,
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

router.RegisterGenericRoute(r, router.RouteConfig[UpdateUserRequest, UpdateUserResponse]{
Path: "/users/:id",
Methods: []router.HttpMethod{router.MethodPut}, // Use string literal or http.MethodPut constant
Codec: codec.NewJSONCodec[UpdateUserRequest, UpdateUserResponse](),
Handler: UpdateUserHandler,
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

router.RegisterGenericRoute(r, router.RouteConfig[DeleteUserRequest, DeleteUserResponse]{
Path: "/users/:id",
Methods: []router.HttpMethod{router.MethodDelete}, // Use string literal or http.MethodDelete constant
Codec: codec.NewJSONCodec[DeleteUserRequest, DeleteUserResponse](), // Codec might not be used
Handler: DeleteUserHandler,
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

router.RegisterGenericRoute(r, router.RouteConfig[ListUsersRequest, ListUsersResponse]{
Path: "/users",
Methods: []router.HttpMethod{router.MethodGet}, // Use string literal or http.MethodGet constant
Codec: codec.NewJSONCodec[ListUsersRequest, ListUsersResponse](), // Codec might not be used if params are from query
Handler: ListUsersHandler,
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

router.RegisterGenericRoute(r, router.RouteConfig[EmptyRequest, ErrorResponse]{
Path: "/error",
Methods: []router.HttpMethod{router.MethodGet}, // Use string literal or http.MethodGet constant
Codec: codec.NewJSONCodec[EmptyRequest, ErrorResponse](),
Handler: ErrorHandler,
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

// Start the server
fmt.Println("Generic Routes Example Server listening on :8080")
Expand Down
Binary file removed examples/simple/simple
Binary file not shown.
6 changes: 3 additions & 3 deletions examples/source-types/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func main() {
Handler: GetUserHandler,
// SourceType defaults to Body, but GET requests usually don't send a body.
// The handler is adapted to check path params.
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

// 2. Base64 query parameter route
router.RegisterGenericRoute[GetUserRequest, GetUserResponse, string, string](r, router.RouteConfig[GetUserRequest, GetUserResponse]{
Expand All @@ -144,7 +144,7 @@ func main() {
Handler: GetUserHandler,
SourceType: router.Base64QueryParameter,
SourceKey: "data", // Will look for ?data=base64encodedstring
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

// 3. Base64 path parameter route
router.RegisterGenericRoute[GetUserRequest, GetUserResponse, string, string](r, router.RouteConfig[GetUserRequest, GetUserResponse]{
Expand All @@ -154,7 +154,7 @@ func main() {
Handler: GetUserHandler,
SourceType: router.Base64PathParameter,
SourceKey: "data", // Will use the :data path parameter
}, time.Duration(0), int64(0), nil) // Added effective settings
}, time.Duration(0), int64(0), nil, nil) // Added effective settings

// Start the server
fmt.Println("Source Types Example Server listening on :8080")
Expand Down
Binary file removed examples/subrouters/subrouters
Binary file not shown.
Loading