Models and Database Connection

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

  • Define GORM models with proper conventions and naming strategies
  • Use GORM struct tags to customize column definitions and constraints
  • Implement embedded structs and custom data types
  • Configure database connections with proper pooling and timeout settings
  • Understand GORM’s built-in model conventions (timestamps, soft deletes)
  • Create indexes, unique constraints, and composite keys
  • Handle null values and default values properly

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Think about your favorite application - it might be a social media platform, an e-commerce site, or a project management tool. Behind the scenes, these applications have carefully designed data models that determine how information is stored and retrieved. Today, we’ll learn how to create these foundational data structures using GORM, transforming simple Go structs into powerful database tables with relationships, constraints, and behaviors.

Lesson Overview

  • Duration: 120 minutes
  • Prerequisites: Lesson 1 completed, basic understanding of database schemas
  • Materials Needed: Go 1.20+ when using SQLite driver v1.6.0 (GORM’s core supports Go 1.18+), GORM set up, database connection working (Gin integration uses Go 1.25+)

Money fields: Some models below use float64 with a SQL decimal column for illustration. A SQL column type does not make Go floating-point arithmetic exact. For real balances, prices, or taxes, use integer minor units or a suitable decimal type with a compatible scanner/valuer.

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. Model Conventions - GORM’s default behaviors and naming strategies
  2. Struct Tags Deep Dive - Mastering GORM tags for column customization
  3. Embedded Structs - Reusing common fields across models
  4. Custom Types - Working with special data types and JSON
  5. Connection Management - Production-ready database configuration
  6. Hands-on practice building a complete data model

Success Criteria:

By the end of this session, you should be able to design and implement complex GORM models with proper constraints, relationships groundwork, and production-ready database connections.

3. Foundation Knowledge (15-20 minutes)

Core Concepts

GORM Model Conventions

GORM follows “convention over configuration” principles. By default:

  • Struct name User becomes table users (pluralized, snake_case)
  • Field ID becomes primary key column id
  • Field CreatedAt automatically tracks creation time
  • Field UpdatedAt automatically tracks update time
  • Field DeletedAt enables soft delete functionality

Real-World Analogy: Think of GORM conventions like a skilled assistant who anticipates your needs. When you name your struct “User”, the assistant knows you probably want a “users” table. When you add a “CreatedAt” field, they automatically fill it in every time you create a record.

Why This Matters: Conventions reduce boilerplate code and enforce consistency across your application. Understanding these conventions helps you work with GORM efficiently and know when to override them.

The gorm.Model Base

GORM provides a base struct that includes common fields:

type Model struct {
    ID        uint           `gorm:"primaryKey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

Embedding gorm.Model gives you these four fields automatically.

Key Terminology

  • Tag: Metadata attached to struct fields that GORM interprets
  • Column: A database table column mapped to a struct field
  • Constraint: Rules that limit values in a column (unique, not null, etc.)
  • Index: Database structure that improves query performance
  • Soft Delete: Marking records as deleted without physically removing them
  • DSN: Data Source Name - connection string for databases

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

Understanding GORM Tags

GORM tags provide fine-grained control over how your struct fields map to database columns. They follow the format gorm:"tag1;tag2;tag3".

Common GORM Tags Reference

Tag Purpose Example
column Custom column name gorm:"column:user_name"
type Column data type gorm:"type:varchar(100)"
size Column size/length gorm:"size:255"
primaryKey Primary key gorm:"primaryKey"
unique Unique constraint gorm:"unique"
uniqueIndex Unique index gorm:"uniqueIndex"
index Create index gorm:"index"
not null Not null constraint gorm:"not null"
default Default value gorm:"default:0"
autoIncrement Auto increment gorm:"autoIncrement"
embedded Embed struct gorm:"embedded"
embeddedPrefix Prefix for embedded fields gorm:"embeddedPrefix:author_"
serializer Custom serializer gorm:"serializer:json"
- Ignore field gorm:"-"
-> Read-only field gorm:"->"
<- Write-only field gorm:"<-"

Naming Strategies

GORM converts Go naming conventions to database conventions by default:

type UserProfile struct {    // Table: user_profiles
    ID           uint       // Column: id
    FirstName    string     // Column: first_name
    LastName     string     // Column: last_name
    EmailAddress string     // Column: email_address
    CreatedAt    time.Time  // Column: created_at
}

You can customize the naming strategy globally:

db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{
    NamingStrategy: schema.NamingStrategy{
        TablePrefix:   "app_",           // table name prefix
        SingularTable: true,             // use singular table name
        NoLowerCase:   false,            // use snake_case (default)
        NameReplacer:  strings.NewReplacer("CID", "Cid"), // custom replacer
    },
})

Common Misconceptions

  1. Misconception: “All struct fields are automatically persisted” Reality: Only exported fields (capitalized) are persisted. Use gorm:"-" to explicitly skip fields.

  2. Misconception: “You must always use gorm.Model” Reality: gorm.Model is optional. You can define your own ID and timestamp fields with custom types.

  3. Misconception: “Primary key must be named ID” Reality: Any field can be a primary key with gorm:"primaryKey" tag.

5. Live Coding Demonstration (20-25 minutes)

Example 1: Complete Model Definition

package main

import (
    "database/sql"
    "encoding/json"
    "time"

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

// BaseModel defines common fields for all models (alternative to gorm.Model)
type BaseModel struct {
    ID        uint           `gorm:"primaryKey"`
    CreatedAt time.Time      `gorm:"autoCreateTime"`
    UpdatedAt time.Time      `gorm:"autoUpdateTime"`
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

// Address is embedded in User
type Address struct {
    Street     string `gorm:"size:200"`
    City       string `gorm:"size:100"`
    State      string `gorm:"size:50"`
    PostalCode string `gorm:"size:20"`
    Country    string `gorm:"size:100;default:'USA'"`
}

// Settings represents JSON data stored in a single column
type Settings struct {
    Theme          string `json:"theme"`
    Notifications  bool   `json:"notifications"`
    Language       string `json:"language"`
}

// Scan implements the sql.Scanner interface for Settings
func (s *Settings) Scan(value interface{}) error {
    if value == nil {
        return nil
    }
    bytes, ok := value.([]byte)
    if !ok {
        return nil
    }
    return json.Unmarshal(bytes, s)
}

// Value implements the driver.Valuer interface for Settings
func (s Settings) Value() (driver.Value, error) {
    return json.Marshal(s)
}

// User demonstrates comprehensive model definition
type User struct {
    BaseModel                          // Embed base fields
    
    // Basic fields with various constraints
    Username     string         `gorm:"size:50;uniqueIndex;not null"`
    Email        string         `gorm:"size:255;uniqueIndex:idx_email;not null"`
    Password     string         `gorm:"size:255;not null;->"` // Write-only
    DisplayName  string         `gorm:"size:100"`
    
    // Nullable fields using pointers
    Phone        *string        `gorm:"size:20"`
    Bio          *string        `gorm:"type:text"`
    DateOfBirth  *time.Time     `gorm:"type:date"`
    
    // Numeric fields with defaults
    Age          uint8          `gorm:"default:0"`
    Points       int64          `gorm:"default:0"`
    Balance      float64        `gorm:"type:decimal(10,2);default:0.00"`
    
    // Boolean and enum fields
    IsActive     bool           `gorm:"default:true;index"`
    IsVerified   bool           `gorm:"default:false"`
    Role         string         `gorm:"type:varchar(20);default:'user';check:role IN ('admin','user','guest')"`
    Status       string         `gorm:"type:varchar(20);default:'pending'"`
    
    // Embedded struct for address
    Address      Address        `gorm:"embedded;embeddedPrefix:addr_"`
    
    // JSON data stored as serialized column
    Settings     Settings       `gorm:"type:json;serializer:json"`
    Metadata     datatypes.JSON `gorm:"type:json"` // Using GORM datatypes
    
    // Computed/virtual field (not stored in database)
    FullName     string         `gorm:"-"`
    
    // Composite index
    FirstName    string         `gorm:"size:50;index:idx_name"`
    LastName     string         `gorm:"size:50;index:idx_name"`
}

// TableName customizes the table name
func (User) TableName() string {
    return "users"
}

// BeforeCreate hook to set defaults
func (u *User) BeforeCreate(tx *gorm.DB) error {
    if u.Role == "" {
        u.Role = "user"
    }
    return nil
}

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

Explanation:

  • Lines 12-17: Custom base model with standard fields
  • Lines 20-26: Address struct to be embedded in User
  • Lines 29-33: Settings struct for JSON serialization
  • Lines 36-47: Scanner and Valuer interfaces for custom JSON type
  • Line 51: Embedding BaseModel gives us ID, timestamps, and soft delete
  • Lines 54-56: String fields with size constraints and unique indexes
  • Lines 59-61: Nullable fields using pointers
  • Lines 64-66: Numeric fields with different types and defaults
  • Lines 69-72: Boolean and enum-like fields with check constraints
  • Line 75: Embedded Address struct with prefix for column names
  • Lines 78-79: JSON data storage options
  • Line 82: Ignored field not persisted to database
  • Lines 85-86: Composite index on multiple fields

Example 2: Advanced Connection Configuration

package main

import (
    "fmt"
    "log"
    "os"
    "time"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

// DatabaseConfig holds connection parameters
type DatabaseConfig struct {
    Host         string
    Port         int
    User         string
    Password     string
    DBName       string
    SSLMode      string
    MaxIdleConns int
    MaxOpenConns int
    MaxLifetime  time.Duration
}

// NewDatabase creates a configured database connection
func NewDatabase(config DatabaseConfig) (*gorm.DB, error) {
    dsn := fmt.Sprintf(
        "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
        config.Host,
        config.Port,
        config.User,
        config.Password,
        config.DBName,
        config.SSLMode,
    )

    // Custom logger configuration
    newLogger := logger.New(
        log.New(os.Stdout, "\r\n", log.LstdFlags),
        logger.Config{
            SlowThreshold:             200 * time.Millisecond, // Slow SQL threshold
            LogLevel:                  logger.Info,            // Log level
            IgnoreRecordNotFoundError: true,                   // Ignore not found errors
            Colorful:                  true,                   // Enable color
        },
    )

    // Open connection with custom configuration
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger:                                   newLogger,
        DisableForeignKeyConstraintWhenMigrating: false,
        SkipDefaultTransaction:                   false,
        PrepareStmt:                              true, // Prepared statement caching
    })
    if err != nil {
        return nil, fmt.Errorf("failed to connect to database: %w", err)
    }

    // Get underlying sql.DB for connection pool configuration
    sqlDB, err := db.DB()
    if err != nil {
        return nil, fmt.Errorf("failed to get sql.DB: %w", err)
    }

    // Configure connection pool
    sqlDB.SetMaxIdleConns(config.MaxIdleConns)
    sqlDB.SetMaxOpenConns(config.MaxOpenConns)
    sqlDB.SetConnMaxLifetime(config.MaxLifetime)
    sqlDB.SetConnMaxIdleTime(5 * time.Minute)

    // Verify connection
    if err := sqlDB.Ping(); err != nil {
        return nil, fmt.Errorf("failed to ping database: %w", err)
    }

    return db, nil
}

func main() {
    config := DatabaseConfig{
        Host:         getEnv("DB_HOST", "localhost"),
        Port:         5432,
        User:         getEnv("DB_USER", "postgres"),
        Password:     getEnv("DB_PASSWORD", "secret"),
        DBName:       getEnv("DB_NAME", "myapp"),
        SSLMode:      getEnv("DB_SSLMODE", "disable"),
        MaxIdleConns: 10,
        MaxOpenConns: 100,
        MaxLifetime:  time.Hour,
    }

    db, err := NewDatabase(config)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("Database connected successfully!")
    _ = db // Use db for operations
}

func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

Key Points:

  • Lines 14-24: Structured configuration for database connection
  • Lines 38-47: Custom logger with slow query threshold and log level
  • Lines 50-55: GORM configuration with prepared statements
  • Lines 63-68: Connection pool settings for production
  • Lines 71-73: Connection verification with Ping

Example 3: Complex Model with Multiple Indexes

package main

import (
    "time"

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

// Order demonstrates complex indexes and constraints
type Order struct {
    ID          uint      `gorm:"primaryKey"`
    OrderNumber string    `gorm:"size:20;uniqueIndex"`
    
    // Foreign key (will be properly set up in associations lesson)
    CustomerID  uint      `gorm:"index;not null"`
    
    // Composite unique constraint
    StoreID     uint      `gorm:"uniqueIndex:idx_store_date"`
    OrderDate   time.Time `gorm:"uniqueIndex:idx_store_date;type:date"`
    
    // Indexed fields for common queries
    Status      string    `gorm:"size:20;index:idx_status;default:'pending'"`
    PaymentType string    `gorm:"size:20;index:idx_payment"`
    
    // Financial fields
    Subtotal    float64   `gorm:"type:decimal(12,2);not null"`
    Tax         float64   `gorm:"type:decimal(12,2);default:0"`
    Discount    float64   `gorm:"type:decimal(12,2);default:0"`
    Total       float64   `gorm:"type:decimal(12,2);not null"`
    
    // Shipping info embedded
    ShippingAddress Address `gorm:"embedded;embeddedPrefix:ship_"`
    BillingAddress  Address `gorm:"embedded;embeddedPrefix:bill_"`
    
    // Metadata
    Notes       string    `gorm:"type:text"`
    CreatedAt   time.Time
    UpdatedAt   time.Time
    CompletedAt *time.Time
}

// Product with comprehensive constraints
type Product struct {
    ID          uint    `gorm:"primaryKey"`
    SKU         string  `gorm:"size:50;uniqueIndex;not null"`
    Name        string  `gorm:"size:200;not null;index"`
    Description string  `gorm:"type:text"`
    
    // Categorization with composite index for filtering
    CategoryID  uint    `gorm:"index:idx_cat_brand"`
    BrandID     uint    `gorm:"index:idx_cat_brand"`
    
    // Pricing with check constraints
    Price       float64 `gorm:"type:decimal(10,2);not null;check:price >= 0"`
    CostPrice   float64 `gorm:"type:decimal(10,2);check:cost_price >= 0"`
    
    // Inventory
    StockQty    int     `gorm:"default:0;check:stock_qty >= 0"`
    MinStockQty int     `gorm:"default:0"`
    
    // Status flags with multi-column index
    IsActive    bool    `gorm:"default:true;index:idx_active_featured"`
    IsFeatured  bool    `gorm:"default:false;index:idx_active_featured"`
    
    // Dimensions (nullable)
    Weight      *float64 `gorm:"type:decimal(8,2)"`
    Length      *float64 `gorm:"type:decimal(8,2)"`
    Width       *float64 `gorm:"type:decimal(8,2)"`
    Height      *float64 `gorm:"type:decimal(8,2)"`
    
    CreatedAt   time.Time
    UpdatedAt   time.Time
    DeletedAt   gorm.DeletedAt `gorm:"index"`
}

func main() {
    db, _ := gorm.Open(sqlite.Open("shop.db"), &gorm.Config{})
    
    // Migrate all models
    db.AutoMigrate(&Order{}, &Product{})
}

Debugging Demonstration

// Common mistake: Using zero values for optional fields
type User struct {
    Name  string
    Age   int  // Zero value (0) will be saved - might not be intended
    Phone string // Empty string will be saved
}

// Corrected: Use pointers for truly optional/nullable fields
type User struct {
    Name  string
    Age   *int    // nil means not set, 0 means actual zero
    Phone *string // nil means not set
}

// Common mistake: Forgetting index on frequently queried fields
type Order struct {
    CustomerID uint // No index - slow queries on large tables
    Status     string
}

// Corrected: Add appropriate indexes
type Order struct {
    CustomerID uint   `gorm:"index"`
    Status     string `gorm:"index"`
}

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

Exercise 1: Design an E-commerce Model (15 minutes)

Objective: Create a comprehensive Customer model

Instructions:

  1. Create a Customer model with personal info, contact details, and preferences
  2. Include embedded address structs for shipping and billing
  3. Add appropriate indexes and constraints
  4. Implement nullable fields correctly
  5. Add JSON field for preferences

Starter Code:

package main

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

// TODO: Define Address struct

// TODO: Define Preferences struct with JSON serialization

// TODO: Define Customer model with all requirements

func main() {
    db, _ := gorm.Open(sqlite.Open("ecommerce.db"), &gorm.Config{})
    // TODO: Migrate Customer model
}

Solution:

package main

import (
    "time"

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

type Address struct {
    Street     string `gorm:"size:200"`
    City       string `gorm:"size:100;not null"`
    State      string `gorm:"size:50"`
    PostalCode string `gorm:"size:20"`
    Country    string `gorm:"size:100;default:'USA'"`
}

type Preferences struct {
    Newsletter    bool   `json:"newsletter"`
    SMSAlerts     bool   `json:"sms_alerts"`
    EmailAlerts   bool   `json:"email_alerts"`
    PreferredLang string `json:"preferred_language"`
    Currency      string `json:"currency"`
}

type Customer struct {
    ID              uint           `gorm:"primaryKey"`
    CustomerNumber  string         `gorm:"size:20;uniqueIndex;not null"`
    
    // Personal Info
    FirstName       string         `gorm:"size:50;not null;index:idx_name"`
    LastName        string         `gorm:"size:50;not null;index:idx_name"`
    Email           string         `gorm:"size:255;uniqueIndex;not null"`
    Phone           *string        `gorm:"size:20"`
    DateOfBirth     *time.Time     `gorm:"type:date"`
    
    // Addresses
    ShippingAddress Address        `gorm:"embedded;embeddedPrefix:ship_"`
    BillingAddress  Address        `gorm:"embedded;embeddedPrefix:bill_"`
    
    // Account Status
    IsActive        bool           `gorm:"default:true;index"`
    IsVerified      bool           `gorm:"default:false"`
    Tier            string         `gorm:"size:20;default:'standard';index"` // standard, silver, gold, platinum
    
    // Preferences
    Preferences     Preferences    `gorm:"serializer:json"`
    
    // Financials
    TotalSpent      float64        `gorm:"type:decimal(12,2);default:0"`
    LoyaltyPoints   int            `gorm:"default:0"`
    
    // Timestamps
    CreatedAt       time.Time
    UpdatedAt       time.Time
    LastLoginAt     *time.Time
    DeletedAt       gorm.DeletedAt `gorm:"index"`
}

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

Exercise 2: Production Database Connection (10 minutes)

Objective: Create a reusable database connection module

Requirements:

  • Support environment-based configuration
  • Implement proper connection pooling
  • Add logging configuration
  • Include connection health check

Exercise 3: Multi-Model Schema (15 minutes)

Objective: Design a blog system schema

Challenge: Create models for:

  • Author (with profile information)
  • Post (with content and metadata)
  • Tag (with name and slug)
  • Include proper indexes and constraints
  • Prepare for relationships (foreign key fields)

Extension Ideas:

  • Add revision history model
  • Implement status workflow
  • Add SEO metadata fields

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Migration fails with “duplicate column” error Cause: Running migration after renaming a field (GORM adds new, doesn’t remove old) Solution: Use manual migration for column renames, or handle in hook

  2. Problem: Time fields show wrong timezone Cause: Database and application timezone mismatch Solution: Use UTC consistently, configure DSN with timezone parameter

  3. Problem: Connection pool exhausted under load Cause: Not closing connections or pool too small Solution: Configure MaxOpenConns appropriately, ensure no connection leaks

Debugging Tips

  • Use db.Migrator().HasColumn(&User{}, "Name") to check column existence
  • Enable SQL logging to see generated DDL statements
  • Use db.Exec() for raw DDL when AutoMigrate is insufficient
  • Test migrations on a copy of production data

8. Best Practices and Professional Tips (5 minutes)

Industry Standards

  • Use meaningful, consistent naming conventions
  • Add indexes for all foreign keys and frequently queried columns
  • Use appropriate data types (don’t use TEXT for short strings)
  • Always include timestamps for audit trail
  • Consider soft deletes for data that might need recovery

Next Steps in Your Learning Journey

  • Learn about GORM associations and relationships
  • Study CRUD operations in depth
  • Explore migration strategies for production
  • Practice with different database systems

9. Summary and Review (5 minutes)

Key Takeaways

  1. GORM conventions reduce boilerplate - naming strategies and auto-timestamps
  2. Tags provide fine control - customize every aspect of column definition
  3. Connection pooling is essential - proper configuration for production loads
  4. Use pointers for nullable fields - distinguish between zero values and null

Quick Review Questions

  1. What’s the difference between gorm.Model and a custom base model?
  2. How do you create a composite index on multiple columns?
  3. Why use pointers for optional fields instead of zero values?
  4. What connection pool settings should you configure for production?

For Instructors

Teaching Tips

  • Show database schema after migration to visualize the result
  • Compare generated SQL across different database types
  • Demonstrate connection pool behavior under load
  • Use real-world examples from popular applications

Assessment Ideas

  • Design a model from given business requirements
  • Review existing model and suggest improvements
  • Debugging exercise with common migration issues
  • Performance analysis of index strategies

Adaptation Notes

  • For beginners: Focus on basic tags, skip advanced features
  • For advanced: Include database-specific optimizations
  • Online delivery: Provide schema visualization tools
  • Time management: Connection pooling section can be homework