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.
5a
gorm.Open is one line. The pool underneath it is where production problems come from.
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 }
gorm.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().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.*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
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.
Appointment โ appointments, PatientID โ patient_id, ID โ primary key, CreatedAt/UpdatedAt maintained automatically.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
Tags do two different jobs โ describing what already exists, and instructing AutoMigrate what to create. Confusing them is the usual source of surprise.
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
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.
(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
gorm.Open builds a pool, not a connection โ set MaxOpenConns, MaxIdleConns and ConnMaxLifetime, and share the one *gorm.DB.*T when NULL is meaningful, and decimal.Decimal with type:numeric for money.not null instruct AutoMigrate; they enforce nothing at runtime. The database is still what guarantees the rule.