Hooks and Callbacks

Learning Objectives: By the end of this lesson, you will be able to:

  • Understand GORM’s lifecycle hooks and when they execute
  • Implement before and after hooks for CRUD operations
  • Use hooks for validation, auditing, and business logic
  • Handle errors in hooks to prevent operations
  • Implement soft deletes with custom behavior
  • Create audit trails and change tracking
  • Use hooks for data transformation and encryption

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Imagine you’re building an e-commerce platform. Every time a user creates an order, you need to validate inventory, calculate totals, send confirmation emails, update analytics, and log the activity. Writing this logic in every place orders are created would be repetitive and error-prone. Today, we’ll learn how hooks and callbacks let you centralize this logic, ensuring it runs consistently every time a database operation occurs.

Lesson Overview

  • Duration: 120 minutes
  • Prerequisites: Lessons 1-6 completed, understanding of CRUD operations
  • Materials Needed: Working GORM setup with existing models

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. Hook Types - All available hooks and their execution order
  2. Before Hooks - Validation, transformation, and blocking operations
  3. After Hooks - Side effects, notifications, and logging
  4. Error Handling - Preventing operations and handling failures
  5. Practical Patterns - Auditing, soft deletes, encryption
  6. Hands-on practice with real-world scenarios

Success Criteria:

By the end of this session, you should be able to implement hooks for validation, auditing, and business logic in your GORM applications.

3. Foundation Knowledge (15-20 minutes)

Core Concepts

What are Hooks?

Hooks (also called callbacks) are functions that execute automatically at specific points in a model’s lifecycle. They allow you to inject custom logic before or after CRUD operations without modifying the calling code.

Real-World Analogy: Think of hooks like automated checkpoints at an airport. Before boarding (BeforeCreate), you go through security checks. After landing (AfterCreate), your luggage is automatically routed to the carousel. You don’t have to think about these processes - they happen automatically based on where you are in your journey.

Why This Matters: Hooks enable:

  • Consistent validation across all entry points
  • Automatic data transformation
  • Audit trails without code duplication
  • Clean separation of concerns
  • Easier testing and maintenance

Available Hooks

┌────────────────────────────────────────────────────────────────┐
│                    GORM Hook Lifecycle                          │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  CREATE                          UPDATE                        │
│  ┌─────────────────────┐        ┌─────────────────────┐       │
│  │ BeforeSave          │        │ BeforeSave          │       │
│  │ BeforeCreate        │        │ BeforeUpdate        │       │
│  │ [Insert to DB]      │        │ [Update in DB]      │       │
│  │ AfterCreate         │        │ AfterUpdate         │       │
│  │ AfterSave           │        │ AfterSave           │       │
│  └─────────────────────┘        └─────────────────────┘       │
│                                                                │
│  DELETE                          FIND                          │
│  ┌─────────────────────┐        ┌─────────────────────┐       │
│  │ BeforeDelete        │        │ [Query from DB]     │       │
│  │ [Delete from DB]    │        │ AfterFind           │       │
│  │ AfterDelete         │        │                     │       │
│  └─────────────────────┘        └─────────────────────┘       │
│                                                                │
└────────────────────────────────────────────────────────────────┘

Key Terminology

  • BeforeSave: Runs before both Create and Update
  • AfterSave: Runs after both Create and Update
  • Transaction Scope: Hooks run within the same transaction
  • Hook Chain: Multiple hooks execute in defined order
  • Skipping Hooks: Using methods that bypass hooks

4. Detailed Explanation and Examples (25-30 minutes)

Hook Execution Details

Hook Method Signatures

// All hooks receive *gorm.DB and return error
func (m *Model) BeforeCreate(tx *gorm.DB) error
func (m *Model) AfterCreate(tx *gorm.DB) error
func (m *Model) BeforeSave(tx *gorm.DB) error
func (m *Model) AfterSave(tx *gorm.DB) error
func (m *Model) BeforeUpdate(tx *gorm.DB) error
func (m *Model) AfterUpdate(tx *gorm.DB) error
func (m *Model) BeforeDelete(tx *gorm.DB) error
func (m *Model) AfterDelete(tx *gorm.DB) error
func (m *Model) AfterFind(tx *gorm.DB) error

Hook Execution Order

For Create:

  1. BeforeSave
  2. BeforeCreate
  3. [Database INSERT]
  4. AfterCreate
  5. AfterSave

For Update:

  1. BeforeSave
  2. BeforeUpdate
  3. [Database UPDATE]
  4. AfterUpdate
  5. AfterSave

Common Misconceptions

  1. Misconception: “Hooks always run” Reality: Methods like UpdateColumn, Delete with raw conditions skip hooks.

  2. Misconception: “Hook errors are ignored” Reality: Returning error from hook aborts the operation and rolls back transaction.

  3. Misconception: “AfterFind runs once per query” Reality: AfterFind runs for each record loaded.

5. Live Coding Demonstration (20-25 minutes)

Example 1: Basic Hooks

package main

import (
    "errors"
    "fmt"
    "strings"
    "time"

    "golang.org/x/crypto/bcrypt"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type User struct {
    ID           uint           `gorm:"primaryKey"`
    Username     string         `gorm:"size:50;uniqueIndex"`
    Email        string         `gorm:"size:255;uniqueIndex"`
    Password     string         `gorm:"size:255" json:"-"` // Never serialize the hash.
    DisplayName  string         `gorm:"size:100"`
    Role         string         `gorm:"size:20;default:'user'"`
    Status       string         `gorm:"size:20;default:'active'"`
    LoginCount   int            `gorm:"default:0"`
    LastLoginAt  *time.Time
    CreatedAt    time.Time
    UpdatedAt    time.Time
    DeletedAt    gorm.DeletedAt `gorm:"index"`
}

// ===== BEFORE HOOKS =====

// BeforeSave runs before both Create and Update
func (u *User) BeforeSave(tx *gorm.DB) error {
    fmt.Println("Hook: BeforeSave called")
    
    // Normalize email to lowercase
    u.Email = strings.ToLower(strings.TrimSpace(u.Email))
    
    // Normalize username
    u.Username = strings.ToLower(strings.TrimSpace(u.Username))
    
    // Set default display name if empty
    if u.DisplayName == "" {
        u.DisplayName = u.Username
    }
    
    return nil
}

// BeforeCreate runs only before Create
func (u *User) BeforeCreate(tx *gorm.DB) error {
    fmt.Println("Hook: BeforeCreate called")
    
    // Validate required fields
    if u.Username == "" {
        return errors.New("username is required")
    }
    if u.Email == "" {
        return errors.New("email is required")
    }
    if u.Password == "" {
        return errors.New("password is required")
    }
    
    // Validate email format (simple check)
    if !strings.Contains(u.Email, "@") {
        return errors.New("invalid email format")
    }
    
    // Validate password strength
    if len(u.Password) < 8 {
        return errors.New("password must be at least 8 characters")
    }
    
    // Hash password
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
    if err != nil {
        return fmt.Errorf("failed to hash password: %w", err)
    }
    u.Password = string(hashedPassword)
    
    // Set default role
    if u.Role == "" {
        u.Role = "user"
    }
    
    return nil
}

// BeforeUpdate runs only before Update
func (u *User) BeforeUpdate(tx *gorm.DB) error {
    fmt.Println("Hook: BeforeUpdate called")
    
    // Reject direct password updates. The model value may still contain the
    // OLD hash when Updates(map) supplies a new password, so hashing u.Password
    // here can hash the wrong value (or permit a plaintext write).
    if tx.Statement.Changed("Password") {
        return errors.New("use ChangePassword for password updates")
    }
    
    return nil
}

// BeforeDelete runs before Delete
func (u *User) BeforeDelete(tx *gorm.DB) error {
    fmt.Println("Hook: BeforeDelete called")
    
    // A condition-only Delete(&User{}, id) has no loaded role; fail closed.
    if u.ID == 0 { return errors.New("delete requires a loaded user") }
    var current User
    if err := tx.Session(&gorm.Session{NewDB: true}).Select("id", "role").
        First(&current, u.ID).Error; err != nil { return err }
    if current.Role == "admin" {
        return errors.New("cannot delete admin users")
    }
    
    return nil
}

// ChangePassword hashes the new plaintext explicitly. UpdateColumn skips model
// hooks (including BeforeUpdate), so use it only for this dedicated operation.
// Authorize the caller before invoking this function; model hooks are not auth.
func ChangePassword(db *gorm.DB, id uint, plaintext string) error {
    if id == 0 || len(plaintext) < 8 { return errors.New("invalid password update") }
    hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost)
    if err != nil { return err }
    result := db.Model(&User{}).Where("id = ?", id).UpdateColumn("password", string(hash))
    if result.Error != nil { return result.Error }
    if result.RowsAffected == 0 { return gorm.ErrRecordNotFound }
    return nil
}

// ===== AFTER HOOKS =====

// AfterCreate runs after successful Create
func (u *User) AfterCreate(tx *gorm.DB) error {
    fmt.Println("Hook: AfterCreate called")
    fmt.Printf("User %s created with ID %d\n", u.Username, u.ID)
    
    // Here you could:
    // - Enqueue a welcome email using an outbox; sending it here before commit
    //   risks emailing a user whose transaction is later rolled back.
    // - Create default settings
    // - Log activity
    // - Trigger analytics event
    
    return nil
}

// AfterUpdate runs after successful Update
func (u *User) AfterUpdate(tx *gorm.DB) error {
    fmt.Println("Hook: AfterUpdate called")
    
    // Log what changed
    if tx.Statement.Changed() {
        fmt.Printf("User %d updated\n", u.ID)
    }
    
    return nil
}

// AfterDelete runs after successful Delete
func (u *User) AfterDelete(tx *gorm.DB) error {
    fmt.Println("Hook: AfterDelete called")
    fmt.Printf("User %d deleted\n", u.ID)
    
    // Here you could:
    // - Send account deletion confirmation
    // - Archive user data
    // - Clean up related records
    
    return nil
}

// AfterFind runs after each record is loaded
func (u *User) AfterFind(tx *gorm.DB) error {
    fmt.Println("Hook: AfterFind called")
    
    // Here you could:
    // - Decrypt sensitive data
    // - Load computed fields
    // - Track access
    
    return nil
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&User{})

    // Test Create - hooks will run
    fmt.Println("\n=== Creating User ===")
    user := User{
        Username: "JohnDoe",
        Email:    "JOHN@EXAMPLE.COM",
        Password: "securepassword123",
    }
    result := db.Create(&user)
    if result.Error != nil {
        fmt.Printf("Create error: %v\n", result.Error)
    }

    // Test Create with validation error
    fmt.Println("\n=== Creating User with Short Password ===")
    badUser := User{
        Username: "baduser",
        Email:    "bad@example.com",
        Password: "short",
    }
    result = db.Create(&badUser)
    if result.Error != nil {
        fmt.Printf("Expected error: %v\n", result.Error)
    }

    // Test Find
    fmt.Println("\n=== Finding User ===")
    var foundUser User
    db.First(&foundUser, user.ID)

    // Test Update
    fmt.Println("\n=== Updating User ===")
    db.Model(&user).Update("DisplayName", "John D.")

    // Test Delete
    fmt.Println("\n=== Deleting User ===")
    db.Delete(&user)
}

Example 2: Auditing with Hooks

package main

import (
    "encoding/json"
    "fmt"
    "time"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// AuditLog records all changes to audited models
type AuditLog struct {
    ID         uint      `gorm:"primaryKey"`
    TableName  string    `gorm:"size:100;index"`
    RecordID   uint      `gorm:"index"`
    Action     string    `gorm:"size:20"` // CREATE, UPDATE, DELETE
    OldValues  string    `gorm:"type:text"`
    NewValues  string    `gorm:"type:text"`
    ChangedBy  uint      `gorm:"index"`
    ChangedAt  time.Time `gorm:"autoCreateTime"`
    IPAddress  string    `gorm:"size:50"`
    UserAgent  string    `gorm:"size:255"`
}

// Auditable interface for models that need audit logging
type Auditable interface {
    GetID() uint
    TableName() string
}

// BaseAuditModel provides common audit fields
type BaseAuditModel struct {
    ID        uint           `gorm:"primaryKey"`
    CreatedBy uint           `gorm:"index"`
    UpdatedBy uint           `gorm:"index"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

// Product is an audited model
type Product struct {
    BaseAuditModel
    Name        string  `gorm:"size:200;not null"`
    SKU         string  `gorm:"size:50;uniqueIndex"`
    Price       float64 `gorm:"type:decimal(10,2)"`
    Stock       int     `gorm:"default:0"`
    Description string  `gorm:"type:text"`
    IsActive    bool    `gorm:"default:true"`
    
    // For tracking changes
    oldValues map[string]interface{} `gorm:"-"`
}

func (p Product) GetID() uint        { return p.ID }
func (Product) TableName() string    { return "products" }

// BeforeCreate captures creation context
func (p *Product) BeforeCreate(tx *gorm.DB) error {
    // Get current user from context (in real app, use proper context)
    // userID := GetCurrentUserID(ctx)
    p.CreatedBy = 1 // Placeholder
    p.UpdatedBy = 1
    return nil
}

// BeforeUpdate captures old values for audit
func (p *Product) BeforeUpdate(tx *gorm.DB) error {
    // Load current values before update
    var current Product
    if err := tx.Unscoped().First(&current, p.ID).Error; err != nil {
        return err
    }
    
    p.oldValues = map[string]interface{}{
        "name":        current.Name,
        "sku":         current.SKU,
        "price":       current.Price,
        "stock":       current.Stock,
        "description": current.Description,
        "is_active":   current.IsActive,
    }
    
    p.UpdatedBy = 1 // Get from context
    return nil
}

// AfterCreate logs the creation
func (p *Product) AfterCreate(tx *gorm.DB) error {
    newValues, _ := json.Marshal(map[string]interface{}{
        "name":        p.Name,
        "sku":         p.SKU,
        "price":       p.Price,
        "stock":       p.Stock,
        "description": p.Description,
        "is_active":   p.IsActive,
    })
    
    return tx.Create(&AuditLog{
        TableName: p.TableName(),
        RecordID:  p.ID,
        Action:    "CREATE",
        NewValues: string(newValues),
        ChangedBy: p.CreatedBy,
    }).Error
}

// AfterUpdate logs the changes
func (p *Product) AfterUpdate(tx *gorm.DB) error {
    if p.oldValues == nil {
        return nil
    }
    
    oldJSON, _ := json.Marshal(p.oldValues)
    newValues, _ := json.Marshal(map[string]interface{}{
        "name":        p.Name,
        "sku":         p.SKU,
        "price":       p.Price,
        "stock":       p.Stock,
        "description": p.Description,
        "is_active":   p.IsActive,
    })
    
    return tx.Create(&AuditLog{
        TableName: p.TableName(),
        RecordID:  p.ID,
        Action:    "UPDATE",
        OldValues: string(oldJSON),
        NewValues: string(newValues),
        ChangedBy: p.UpdatedBy,
    }).Error
}

// AfterDelete logs the deletion
func (p *Product) AfterDelete(tx *gorm.DB) error {
    oldValues, _ := json.Marshal(map[string]interface{}{
        "name":        p.Name,
        "sku":         p.SKU,
        "price":       p.Price,
        "stock":       p.Stock,
        "description": p.Description,
        "is_active":   p.IsActive,
    })
    
    return tx.Create(&AuditLog{
        TableName: p.TableName(),
        RecordID:  p.ID,
        Action:    "DELETE",
        OldValues: string(oldValues),
        ChangedBy: 1, // Get from context
    }).Error
}

func main() {
    db, _ := gorm.Open(sqlite.Open("audit.db"), &gorm.Config{})
    db.AutoMigrate(&Product{}, &AuditLog{})

    // Create product
    product := Product{
        Name:  "Laptop",
        SKU:   "LAP-001",
        Price: 999.99,
        Stock: 50,
    }
    db.Create(&product)

    // Update product
    db.Model(&product).Updates(map[string]interface{}{
        "price": 899.99,
        "stock": 45,
    })

    // Delete product
    db.Delete(&product)

    // View audit log
    var logs []AuditLog
    db.Order("changed_at DESC").Find(&logs)
    
    fmt.Println("Audit Log:")
    for _, log := range logs {
        fmt.Printf("[%s] %s ID=%d at %s\n", 
            log.Action, log.TableName, log.RecordID, log.ChangedAt)
    }
}

Example 3: Encryption and Data Transformation

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "encoding/base64"
    "errors"
    "io"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// Encryption key (in production, use secure key management)
var encryptionKey = []byte("32-byte-long-key-for-aes-256!!")

// SensitiveData model with encrypted fields
type SensitiveData struct {
    ID              uint   `gorm:"primaryKey"`
    UserID          uint   `gorm:"index"`
    
    // Stored encrypted in database
    SSN             string `gorm:"size:255"` // Encrypted
    CreditCardLast4 string `gorm:"size:10"`  // Not encrypted (safe to store)
    CreditCardFull  string `gorm:"-"`        // Never stored, used for processing only
    
    // Plaintext versions for application use (not stored)
    ssnPlain        string `gorm:"-"`
}

func encrypt(plaintext string) (string, error) {
    block, err := aes.NewCipher(encryptionKey)
    if err != nil {
        return "", err
    }

    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return "", err
    }

    nonce := make([]byte, gcm.NonceSize())
    if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
        return "", err
    }

    ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
    return base64.StdEncoding.EncodeToString(ciphertext), nil
}

func decrypt(ciphertext string) (string, error) {
    data, err := base64.StdEncoding.DecodeString(ciphertext)
    if err != nil {
        return "", err
    }

    block, err := aes.NewCipher(encryptionKey)
    if err != nil {
        return "", err
    }

    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return "", err
    }

    nonceSize := gcm.NonceSize()
    if len(data) < nonceSize {
        return "", errors.New("ciphertext too short")
    }

    nonce, ciphertext := data[:nonceSize], string(data[nonceSize:])
    plaintext, err := gcm.Open(nil, nonce, []byte(ciphertext), nil)
    if err != nil {
        return "", err
    }

    return string(plaintext), nil
}

// BeforeSave encrypts sensitive fields
func (s *SensitiveData) BeforeSave(tx *gorm.DB) error {
    // Encrypt SSN if plain version is set
    if s.ssnPlain != "" {
        encrypted, err := encrypt(s.ssnPlain)
        if err != nil {
            return err
        }
        s.SSN = encrypted
    }
    
    // Extract last 4 digits from credit card
    if s.CreditCardFull != "" {
        if len(s.CreditCardFull) >= 4 {
            s.CreditCardLast4 = s.CreditCardFull[len(s.CreditCardFull)-4:]
        }
        // Don't store full credit card
    }
    
    return nil
}

// AfterFind decrypts sensitive fields
func (s *SensitiveData) AfterFind(tx *gorm.DB) error {
    // Decrypt SSN
    if s.SSN != "" {
        plaintext, err := decrypt(s.SSN)
        if err != nil {
            return err
        }
        s.ssnPlain = plaintext
    }
    
    return nil
}

// GetSSN returns decrypted SSN
func (s *SensitiveData) GetSSN() string {
    return s.ssnPlain
}

// SetSSN sets SSN for encryption
func (s *SensitiveData) SetSSN(ssn string) {
    s.ssnPlain = ssn
}

func main() {
    db, _ := gorm.Open(sqlite.Open("secure.db"), &gorm.Config{})
    db.AutoMigrate(&SensitiveData{})

    // Create with sensitive data
    data := SensitiveData{
        UserID:         1,
        CreditCardFull: "4111111111111111",
    }
    data.SetSSN("123-45-6789")
    db.Create(&data)

    // Retrieve and decrypt
    var loaded SensitiveData
    db.First(&loaded, data.ID)
    
    fmt.Printf("Stored SSN (encrypted): %s\n", loaded.SSN)
    fmt.Printf("Decrypted SSN: %s\n", loaded.GetSSN())
    fmt.Printf("Last 4 digits: %s\n", loaded.CreditCardLast4)
}

Example 4: Custom Soft Delete Behavior

package main

import (
    "fmt"
    "time"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// Document with custom soft delete and recovery
type Document struct {
    ID          uint           `gorm:"primaryKey"`
    Title       string         `gorm:"size:255"`
    Content     string         `gorm:"type:text"`
    Status      string         `gorm:"size:20;default:'draft'"` // draft, published, archived
    Version     int            `gorm:"default:1"`
    
    // Soft delete fields
    DeletedAt   gorm.DeletedAt `gorm:"index"`
    DeletedBy   *uint
    DeleteReason string        `gorm:"size:255"`
    
    // Recovery tracking
    RecoveredAt *time.Time
    RecoveredBy *uint
    
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

// BeforeDelete captures deletion metadata
func (d *Document) BeforeDelete(tx *gorm.DB) error {
    // Prevent deletion of published documents
    if d.Status == "published" {
        // Archive instead of delete
        d.Status = "archived"
        return tx.Model(d).Update("status", "archived").Error
    }
    
    // Set deletion metadata
    // In real app, get from context
    userID := uint(1)
    d.DeletedBy = &userID
    d.DeleteReason = "User requested deletion"
    
    // Update the record with deletion info before soft delete
    tx.Model(d).Updates(map[string]interface{}{
        "deleted_by":    d.DeletedBy,
        "delete_reason": d.DeleteReason,
    })
    
    return nil
}

// AfterDelete logs deletion
func (d *Document) AfterDelete(tx *gorm.DB) error {
    fmt.Printf("Document '%s' (ID: %d) soft deleted\n", d.Title, d.ID)
    return nil
}

// Recover restores a soft-deleted document
func (d *Document) Recover(tx *gorm.DB) error {
    now := time.Now()
    userID := uint(1) // Get from context
    
    return tx.Unscoped().Model(d).Updates(map[string]interface{}{
        "deleted_at":   nil,
        "recovered_at": now,
        "recovered_by": userID,
    }).Error
}

// BeforeUpdate increments version
func (d *Document) BeforeUpdate(tx *gorm.DB) error {
    if tx.Statement.Changed("Content", "Title") {
        d.Version++
    }
    return nil
}

func main() {
    db, _ := gorm.Open(sqlite.Open("docs.db"), &gorm.Config{})
    db.AutoMigrate(&Document{})

    // Create document
    doc := Document{
        Title:   "Important Document",
        Content: "Original content",
        Status:  "draft",
    }
    db.Create(&doc)

    // Update content (version increments)
    db.Model(&doc).Update("Content", "Updated content")
    fmt.Printf("Version after update: %d\n", doc.Version)

    // Soft delete
    db.Delete(&doc)

    // Verify soft deleted
    var count int64
    db.Model(&Document{}).Count(&count)
    fmt.Printf("Visible documents: %d\n", count)

    // Find including soft deleted
    db.Unscoped().Model(&Document{}).Count(&count)
    fmt.Printf("Total documents: %d\n", count)

    // Recover document
    var deleted Document
    db.Unscoped().First(&deleted, doc.ID)
    deleted.Recover(db)

    // Verify recovered
    db.Model(&Document{}).Count(&count)
    fmt.Printf("After recovery: %d\n", count)
}

Example 5: Plugin-Based Hooks

package main

import (
    "fmt"
    "time"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// Timestamps plugin - adds automatic timestamp management
type Timestamps struct {
    CreatedAt time.Time
    UpdatedAt time.Time
}

// BeforeCreate sets created timestamp
func (t *Timestamps) BeforeCreate(tx *gorm.DB) error {
    now := time.Now()
    t.CreatedAt = now
    t.UpdatedAt = now
    return nil
}

// BeforeUpdate sets updated timestamp
func (t *Timestamps) BeforeUpdate(tx *gorm.DB) error {
    t.UpdatedAt = time.Now()
    return nil
}

// Sluggable plugin - auto-generates URL slugs
type Sluggable struct {
    Slug string `gorm:"size:255;uniqueIndex"`
}

func generateSlug(s string) string {
    // Simplified slug generation
    slug := strings.ToLower(s)
    slug = strings.ReplaceAll(slug, " ", "-")
    slug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(slug, "")
    return slug
}

// Publishable plugin - adds publishing workflow
type Publishable struct {
    Status      string     `gorm:"size:20;default:'draft'"`
    PublishedAt *time.Time
    PublishedBy *uint
}

func (p *Publishable) Publish(tx *gorm.DB, userID uint) error {
    now := time.Now()
    p.Status = "published"
    p.PublishedAt = &now
    p.PublishedBy = &userID
    return nil
}

func (p *Publishable) Unpublish(tx *gorm.DB) error {
    p.Status = "draft"
    p.PublishedAt = nil
    p.PublishedBy = nil
    return nil
}

// Article uses all plugins
type Article struct {
    ID      uint   `gorm:"primaryKey"`
    Title   string `gorm:"size:255;not null"`
    Content string `gorm:"type:text"`
    
    // Embed plugins
    Timestamps
    Sluggable
    Publishable
}

// BeforeCreate chains plugin hooks
func (a *Article) BeforeCreate(tx *gorm.DB) error {
    // Generate slug from title
    a.Slug = generateSlug(a.Title)
    
    // Call embedded hooks
    if err := a.Timestamps.BeforeCreate(tx); err != nil {
        return err
    }
    
    return nil
}

// BeforeUpdate chains plugin hooks
func (a *Article) BeforeUpdate(tx *gorm.DB) error {
    // Regenerate slug if title changed
    if tx.Statement.Changed("Title") {
        a.Slug = generateSlug(a.Title)
    }
    
    return a.Timestamps.BeforeUpdate(tx)
}

func main() {
    db, _ := gorm.Open(sqlite.Open("blog.db"), &gorm.Config{})
    db.AutoMigrate(&Article{})

    article := Article{
        Title:   "My First Article!",
        Content: "Article content here...",
    }
    db.Create(&article)

    fmt.Printf("Slug: %s\n", article.Slug)
    fmt.Printf("Status: %s\n", article.Status)
    fmt.Printf("Created: %s\n", article.CreatedAt)
}

6. Hands-On Practice (30-40 minutes)

Exercise 1: Validation Hooks (15 minutes)

Objective: Implement comprehensive validation

Solution:

package main

import (
    "errors"
    "regexp"
    "strings"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Product struct {
    ID          uint    `gorm:"primaryKey"`
    Name        string  `gorm:"size:200"`
    SKU         string  `gorm:"size:50;uniqueIndex"`
    Price       float64 `gorm:"type:decimal(10,2)"`
    Stock       int
    Description string  `gorm:"type:text"`
    Category    string  `gorm:"size:50"`
}

func (p *Product) BeforeCreate(tx *gorm.DB) error {
    return p.validate()
}

func (p *Product) BeforeUpdate(tx *gorm.DB) error {
    return p.validate()
}

func (p *Product) validate() error {
    // Name validation
    if strings.TrimSpace(p.Name) == "" {
        return errors.New("name is required")
    }
    if len(p.Name) > 200 {
        return errors.New("name must be 200 characters or less")
    }

    // SKU validation
    if p.SKU == "" {
        return errors.New("SKU is required")
    }
    skuPattern := regexp.MustCompile(`^[A-Z]{2,4}-\d{3,6}$`)
    if !skuPattern.MatchString(p.SKU) {
        return errors.New("SKU must match pattern XX-000 (e.g., PROD-001)")
    }

    // Price validation
    if p.Price < 0 {
        return errors.New("price cannot be negative")
    }
    if p.Price > 1000000 {
        return errors.New("price exceeds maximum allowed")
    }

    // Stock validation
    if p.Stock < 0 {
        return errors.New("stock cannot be negative")
    }

    return nil
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    db.AutoMigrate(&Product{})

    // Test invalid product
    invalid := Product{Name: "", SKU: "invalid", Price: -10}
    if err := db.Create(&invalid).Error; err != nil {
        fmt.Printf("Validation error: %v\n", err)
    }

    // Test valid product
    valid := Product{
        Name:  "Test Product",
        SKU:   "PROD-001",
        Price: 29.99,
        Stock: 100,
    }
    if err := db.Create(&valid).Error; err != nil {
        fmt.Printf("Error: %v\n", err)
    } else {
        fmt.Printf("Created product ID: %d\n", valid.ID)
    }
}

Exercise 2: Activity Logging (10 minutes)

Objective: Log all CRUD operations to activity table

Exercise 3: Computed Fields (15 minutes)

Objective: Implement order total calculation via hooks

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Hook not being called Cause: Using methods that skip hooks (UpdateColumn, Exec) Solution: Use Model().Updates() or ensure hooks are needed

  2. Problem: Infinite loop in hooks Cause: Hook triggers same operation that calls hook Solution: Use flags or tx.Set(“skip_hooks”, true)

  3. Problem: Hook error not stopping operation Cause: Error not being returned properly Solution: Always return errors, don’t just log them

Debugging Tips

  • Add logging to track hook execution order
  • Use tx.Statement.Changed() to check field changes
  • Test hooks in isolation with unit tests
  • Check transaction scope in database logs

8. Best Practices and Professional Tips (5 minutes)

Industry Standards

  • Keep hooks focused and single-purpose
  • Don’t put complex business logic in hooks (use services)
  • Always handle errors in hooks
  • Document hook behavior for team awareness
  • Test hooks thoroughly

Next Steps in Your Learning Journey

  • Learn about performance optimization
  • Study testing strategies with hooks
  • Explore plugins and middleware patterns
  • Practice with complex business workflows

9. Summary and Review (5 minutes)

Key Takeaways

  1. Hooks execute automatically at defined lifecycle points
  2. Before hooks can validate and transform data
  3. After hooks handle side effects like logging and notifications
  4. Returning errors prevents operations and rolls back transactions

Quick Review Questions

  1. What’s the difference between BeforeSave and BeforeCreate?
  2. How do you prevent an operation in a hook?
  3. Which methods skip hooks?
  4. When does AfterFind run?

For Instructors

Teaching Tips

  • Show hook execution order with logging
  • Demonstrate error handling behavior
  • Use real-world examples (validation, audit, encryption)
  • Show how hooks interact with transactions

Assessment Ideas

  • Implement validation hooks for given requirements
  • Debug hooks that aren’t firing
  • Design audit system using hooks
  • Refactor business logic into hooks

Adaptation Notes

  • For beginners: Focus on basic Before/After patterns
  • For advanced: Cover plugin patterns and composition
  • Use database-level triggers comparison for context