Skip to Content

Transactions

Transactions in Goofer ORM ensure data integrity for complex operations. They allow you to group multiple database operations into a single atomic unit, ensuring that either all operations succeed or none of them are applied.

Overview

The Transactions system offers the following capabilities:

  • First-class transaction support
  • Automatic rollback on error
  • Nested transaction support
  • Transaction-aware hooks
  • Context support for cancellation and timeouts

Why Use Transactions?

Transactions are essential for maintaining data integrity in scenarios where multiple related operations need to succeed or fail together. For example:

  • Creating a user and their profile
  • Transferring money between accounts
  • Processing an order with multiple items
  • Updating related entities

Without transactions, if one operation fails, you might end up with inconsistent data in your database.

Basic Transaction Usage

To use transactions in Goofer ORM, use the Transaction method on a repository:

err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user user := &User{ Name: "John Doe", Email: "john@example.com", } // Save the user in the transaction if err := txRepo.Save(user); err != nil { return err } // Create a profile for the user profile := &Profile{ UserID: user.ID, Bio: "Software developer", } // Save the profile in the transaction profileRepo := repository.NewRepository[Profile](txRepo.DB(), txRepo.Dialect()) if err := profileRepo.Save(profile); err != nil { return err } // If we return nil, the transaction will be committed return nil }) if err != nil { log.Fatalf("Transaction failed: %v", err) }

The Transaction method takes a function that receives a transaction-specific repository. Inside this function, you perform your database operations using the transaction repository. If the function returns an error, the transaction is automatically rolled back. If it returns nil, the transaction is committed.

Error Handling and Rollback

If any operation inside the transaction function returns an error, the transaction is automatically rolled back:

err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user user := &User{ Name: "John Doe", Email: "john@example.com", } // Save the user in the transaction if err := txRepo.Save(user); err != nil { return err // This will cause the transaction to roll back } // Simulate an error return errors.New("something went wrong") // This will cause the transaction to roll back }) if err != nil { log.Printf("Transaction failed: %v", err) // This will print the error }

You can also explicitly return an error to roll back the transaction if a business rule is violated:

err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user user := &User{ Name: "John Doe", Email: "john@example.com", } // Save the user in the transaction if err := txRepo.Save(user); err != nil { return err } // Check if the user already has a profile existingProfile, err := profileRepo.Find().Where("user_id = ?", user.ID).One() if err == nil { // User already has a profile return errors.New("user already has a profile") } else if err != sql.ErrNoRows { // Some other error occurred return err } // Create a profile for the user profile := &Profile{ UserID: user.ID, Bio: "Software developer", } // Save the profile in the transaction if err := profileRepo.Save(profile); err != nil { return err } return nil })

Panic Recovery

Transactions also handle panics, automatically rolling back if a panic occurs:

err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user user := &User{ Name: "John Doe", Email: "john@example.com", } // Save the user in the transaction if err := txRepo.Save(user); err != nil { return err } // Simulate a panic panic("something went wrong") // This will cause the transaction to roll back // This code will never be reached return nil }) // The panic will be recovered, and err will contain the panic message if err != nil { log.Printf("Transaction failed: %v", err) }

Working with Multiple Repositories

You can use multiple repositories within a single transaction:

err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user user := &User{ Name: "John Doe", Email: "john@example.com", } // Save the user in the transaction if err := txRepo.Save(user); err != nil { return err } // Create a profile repository using the same transaction profileRepo := repository.NewRepository[Profile](txRepo.DB(), txRepo.Dialect()) // Create a profile for the user profile := &Profile{ UserID: user.ID, Bio: "Software developer", } // Save the profile in the transaction if err := profileRepo.Save(profile); err != nil { return err } // Create a post repository using the same transaction postRepo := repository.NewRepository[Post](txRepo.DB(), txRepo.Dialect()) // Create a post for the user post := &Post{ UserID: user.ID, Title: "My First Post", Content: "Hello, world!", } // Save the post in the transaction if err := postRepo.Save(post); err != nil { return err } return nil })

Hooks in Transactions

Hooks are transaction-aware, meaning they are executed within the transaction:

// User entity with hooks type User struct { ID uint `orm:"primaryKey;autoIncrement"` Name string `orm:"type:varchar(255);notnull"` Email string `orm:"unique;type:varchar(255);notnull"` CreatedAt time.Time `orm:"type:timestamp"` UpdatedAt time.Time `orm:"type:timestamp"` } // BeforeSave is called before the entity is saved func (u *User) BeforeSave() error { now := time.Now() if u.ID == 0 { u.CreatedAt = now } u.UpdatedAt = now return nil } // Transaction with hooks err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user user := &User{ Name: "John Doe", Email: "john@example.com", } // Save the user in the transaction (BeforeSave hook will be executed) if err := txRepo.Save(user); err != nil { return err } return nil })

If a hook returns an error, the transaction is rolled back:

// BeforeSave with validation func (u *User) BeforeSave() error { if u.Name == "" { return errors.New("name cannot be empty") } now := time.Now() if u.ID == 0 { u.CreatedAt = now } u.UpdatedAt = now return nil } // Transaction with hooks err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user with an empty name user := &User{ Name: "", // This will cause the BeforeSave hook to return an error Email: "john@example.com", } // Save the user in the transaction (BeforeSave hook will return an error) if err := txRepo.Save(user); err != nil { return err // This will cause the transaction to roll back } return nil })

Context Support

Transactions support context for cancellation and timeouts:

// Create a context with a timeout ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Use the context with the repository userRepoWithCtx := userRepo.WithContext(ctx) // Transaction with context err := userRepoWithCtx.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user user := &User{ Name: "John Doe", Email: "john@example.com", } // Save the user in the transaction if err := txRepo.Save(user); err != nil { return err } // Simulate a long-running operation time.Sleep(10 * time.Second) // This will exceed the context timeout return nil }) if err != nil { log.Printf("Transaction failed: %v", err) // This will print a context deadline exceeded error }

Best Practices

Keep Transactions Focused

Each transaction should have a single responsibility:

// Good: Focused transaction err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a user and their profile return nil }) // Bad: Transaction doing too much err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create multiple users // Process payments // Send emails // Update inventory return nil })

Minimize Transaction Duration

Keep transactions as short as possible to reduce lock contention:

// Good: Short transaction err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Only perform database operations return nil }) // Bad: Long-running transaction err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Perform database operations // Make HTTP requests // Process files return nil })

Handle Errors Properly

Always check errors returned by repository methods:

err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a new user user := &User{ Name: "John Doe", Email: "john@example.com", } // Save the user in the transaction if err := txRepo.Save(user); err != nil { return fmt.Errorf("failed to save user: %w", err) } return nil }) if err != nil { log.Printf("Transaction failed: %v", err) }

Use transactions when operations are related and need to succeed or fail together:

// Good: Related operations in a transaction err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a user and their profile return nil }) // Bad: Unrelated operations in a transaction err := userRepo.Transaction(func(txRepo *repository.Repository[User]) error { // Create a user // Update a product (unrelated to the user) return nil })

Next Steps

  • Learn about Hooks to understand how hooks work in transactions
  • Explore the Repository Pattern to see how transactions integrate with repositories
  • Check out the Examples section for more examples of using transactions
Last updated on