Session 05 ยท ๐Ÿ”Œ GORM

GORM Setup & Models

The schema already exists. An ORM's job is to express it in Go without losing anything โ€” and the places it loses something quietly are worth knowing before you write a single query.

โœ“
5aconnecting to PostgreSQL
โœ“
5btables โ†’ structs
โœ“
5cthe tags that matter

5a

Opening the connection โ€” and the three settings nobody sets

gorm.Open is one line. The pool underneath it is where production problems come from.

internal/repository/postgres/db.go
package postgres

import (
    "fmt"
    "time"

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

func Open(dsn string) (*gorm.DB, error) {
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Warn),  // see every slow query
        // NowFunc, NamingStrategy and PrepareStmt also live here
    })
    if err != nil {
        return nil, fmt.Errorf("open postgres: %w", err)
    }

    sqlDB, err := db.DB()          // the *sql.DB underneath โ€” GORM does not hide it
    if err != nil {
        return nil, err
    }
    sqlDB.SetMaxOpenConns(25)                  // hard ceiling โ€” Postgres has one too
    sqlDB.SetMaxIdleConns(25)                  // match it, or connections churn
    sqlDB.SetConnMaxLifetime(5 * time.Minute) // survive a failover

    return db, nil
}
1gorm.Open does not connect. It builds a pool and โ€” depending on the driver โ€” may not touch the database at all. If you want startup to fail loudly on a bad DSN, ping it: sqlDB.Ping().
2Defaults are unbounded. MaxOpenConns defaults to zero, meaning unlimited โ€” so a traffic spike opens connections until PostgreSQL's own max_connections refuses, and every request fails at once.
3Idle below open causes churn. If idle is 2 and open is 25, the pool constantly closes and reopens sockets under load. Set them equal unless you have a reason.
4*gorm.DB is safe for concurrent use and is meant to be created once and shared. Opening one per request is the classic way to exhaust a database.

The DSN does not belong in this file. It arrives as a parameter, and Session 11 shows where it comes from โ€” a config struct read once at startup, which refuses to boot if the value is missing.

5b

One table, one struct โ€” and four places it goes wrong

GORM infers a great deal from Go conventions. Walk it with Next to see what it gets right for free, and where the inference is quietly wrong.

โœ“Convention covers the boring 80%. Appointment โ†’ appointments, PatientID โ†’ patient_id, ID โ†’ primary key, CreatedAt/UpdatedAt maintained automatically.
โœ—A nullable column is not a zero value. Phone string cannot tell "no phone recorded" from "empty string". Use *string or sql.NullString when NULL is meaningful.
โœ—float64 for money re-introduces the bug Session 01 removed. The column is numeric; map it to decimal.Decimal, not a float, or the rounding error returns on the Go side.
โ†’gorm.DeletedAt changes every query you write. Adding it turns on soft deletes globally โ€” Delete becomes an UPDATE and every SELECT silently gains WHERE deleted_at IS NULL.

5c

Struct tags: which ones earn their place

Tags do two different jobs โ€” describing what already exists, and instructing AutoMigrate what to create. Confusing them is the usual source of surprise.

internal/model/appointment.go
type Appointment struct {
    ID          uint            `gorm:"primaryKey"`
    PatientID   uint            `gorm:"not null;index"`
    DoctorID    uint            `gorm:"not null;index:idx_doctor_slot,unique,priority:1"`
    StartsAt    time.Time       `gorm:"not null;index:idx_doctor_slot,unique,priority:2"`
    DurationMin int             `gorm:"not null;default:15"`
    Status      string          `gorm:"type:text;not null;default:'booked'"`
    Fee         decimal.Decimal `gorm:"type:numeric(10,2);not null"`
    CreatedAt   time.Time
    UpdatedAt   time.Time

    // association fields โ€” no column of their own (Session 08)
    Patient     Patient         `gorm:"foreignKey:PatientID"`
    Doctor      Doctor          `gorm:"foreignKey:DoctorID"`
}

func (Appointment) TableName() string { return "appointments" }  // only if you disagree with the default
Tag
What it does
When it matters
primaryKey
marks the key
only if the field is not called ID
type:...
exact column type
always, for numeric and text precision
index:name,unique
composite unique index
the double-booking rule, in Go
not null / default
column constraints
only applied by AutoMigrate
-
ignore the field entirely
computed fields you never persist
Mistake

Believing gorm:"not null" validates anything at runtime. It does not โ€” it is an instruction to AutoMigrate about how to build the column. If the table was created by a migration file instead, the tag is decoration and the only thing enforcing the rule is the database, exactly as Session 03 intended.

TASK

Map the rest of the schema yourself

(a) Write the Patient, Doctor and Speciality structs for the tables you designed in Sessions 01โ€“03. (b) One field on Patient is genuinely nullable โ€” find it and give it a pointer type, then write one sentence on what breaks if you use a plain string. (c) Express the UNIQUE (doctor_id, starts_at) constraint using only struct tags, and then say which of the two โ€” the tag or the SQL constraint โ€” you would trust in production, and why.

Recap

Session 05 in four lines

1gorm.Open builds a pool, not a connection โ€” set MaxOpenConns, MaxIdleConns and ConnMaxLifetime, and share the one *gorm.DB.
2GORM's conventions map names, keys and timestamps for free; the inference fails on nullability, money and soft deletes.
3Use *T when NULL is meaningful, and decimal.Decimal with type:numeric for money.
4Tags like not null instruct AutoMigrate; they enforce nothing at runtime. The database is still what guarantees the rule.