Booking a slot is three writes that must all happen or none. And when the double-booking constraint from Session 03 finally fires, the difference between a useful API and a 500 is entirely in how you read the error.
9a
Booking creates the appointment, writes an audit row and decrements the doctor's remaining slots. Walk it with Next โ including the run where the third write fails.
db.Transaction(func(tx *gorm.DB) error {...}) is the form to use. Return nil and it commits; return an error and it rolls back. You never call Commit or Rollback yourself.tx, not db, inside the closure. Every statement that should be part of the transaction must go through the tx handle. A stray db.Create in there commits immediately and survives the rollback.What rolls back is the database, not your Go values. After a rollback, a.ID still holds the id the failed insert generated, and any field a hook mutated stays mutated. Treat the struct as untrustworthy once the transaction returns an error.
9b
Two patients book the same 09:30 slot, two milliseconds apart. A transaction alone does not save you here โ the constraint does.
var ( ErrSlotTaken = errors.New("appointment: slot already booked") ErrNotFound = errors.New("appointment: not found") ErrDoctorGone = errors.New("appointment: doctor does not exist") ) func (r *AppointmentRepo) Book(ctx context.Context, a *model.Appointment) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // no SELECT-then-INSERT: let the constraint decide if err := tx.Create(a).Error; err != nil { return translate(err) } if err := tx.Create(&model.AuditEntry{ Action: "book", AppointmentID: a.ID, }).Error; err != nil { return translate(err) // rolls the appointment back too } return nil // nil commits }) } // translate turns driver-specific failures into our own vocabulary func translate(err error) error { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound } var pgErr *pgconn.PgError if errors.As(err, &pgErr) { switch pgErr.ConstraintName { case "idx_doctor_slot": // 23505 unique_violation return ErrSlotTaken case "appointments_doctor_id_fkey": // 23503 foreign_key_violation return ErrDoctorGone } } return fmt.Errorf("book appointment: %w", err) }
SELECT to see whether the slot is free, inside a transaction, still loses the race under PostgreSQL's default READ COMMITTED โ both transactions read "free" before either inserts.23505, and no slot is ever sold twice โ across any number of servers.idx_doctor_slot is the name you chose in Session 03 and it is stable.appointments_doctor_id_starts_at_key, and now your error handling depends on PostgreSQL's naming algorithm.9c
The repository's job is to stop leaking database vocabulary. Callers should handle ErrSlotTaken, not pgconn.PgError code 23505.
errors.Is walks the wrap chain, so it keeps working after fmt.Errorf("...: %w", err). Comparing with == breaks the moment anyone adds context.errors.As is for extracting a typed error you need fields from โ here the PostgreSQL error's ConstraintName and Code.%w preserves the chain; %v flattens it to text and destroys errors.Is.pgconn or gorm โ which is exactly what makes the service layer testable in Session 10 and the handler in Session 11 nine lines long.Returning gorm.ErrRecordNotFound straight out of the repository. It compiles, it works, and it quietly makes GORM part of your service layer's contract โ so the day you swap to sqlc or raw database/sql, every caller has to change. Translate at the boundary.
(a) Write Book with a SELECT-then-INSERT inside a transaction, then call it from two goroutines for the same slot and show that both succeed. (b) Remove the check, rely on the unique index, and show that exactly one now fails with 23505. (c) Write translate so the caller receives ErrSlotTaken, and add a test asserting errors.Is(err, ErrSlotTaken) still holds after the service wraps it with %w.
Recap
db.Transaction(fn) commits on nil and rolls back on any error โ and every statement inside must use tx.SELECT-then-INSERT still loses the race under READ COMMITTED.errors.Is.