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:
- Hook Types - All available hooks and their execution order
- Before Hooks - Validation, transformation, and blocking operations
- After Hooks - Side effects, notifications, and logging
- Error Handling - Preventing operations and handling failures
- Practical Patterns - Auditing, soft deletes, encryption
- 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:
- BeforeSave
- BeforeCreate
- [Database INSERT]
- AfterCreate
- AfterSave
For Update:
- BeforeSave
- BeforeUpdate
- [Database UPDATE]
- AfterUpdate
- AfterSave
Common Misconceptions
-
Misconception: “Hooks always run” Reality: Methods like
UpdateColumn,Deletewith raw conditions skip hooks. -
Misconception: “Hook errors are ignored” Reality: Returning error from hook aborts the operation and rolls back transaction.
-
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(¤t, 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(¤t, 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
-
Problem: Hook not being called Cause: Using methods that skip hooks (UpdateColumn, Exec) Solution: Use Model().Updates() or ensure hooks are needed
-
Problem: Infinite loop in hooks Cause: Hook triggers same operation that calls hook Solution: Use flags or tx.Set(“skip_hooks”, true)
-
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
- Hooks execute automatically at defined lifecycle points
- Before hooks can validate and transform data
- After hooks handle side effects like logging and notifications
- Returning errors prevents operations and rolls back transactions
Quick Review Questions
- What’s the difference between BeforeSave and BeforeCreate?
- How do you prevent an operation in a hook?
- Which methods skip hooks?
- 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