Session 09 ยท ๐Ÿ”’ Transactions

Transactions & Error Handling

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.

โœ“
9aall-or-nothing writes
โœ“
9bthe race, and who wins
โœ“
9csentinels and errors.Is

9a

Three writes, one outcome

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.

txdb.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.
โœ—Use 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.
โ†’A panic still rolls back โ€” GORM recovers, rolls back and re-panics. The database is left consistent even when the code is not.
โœ“Keep transactions short. An open transaction holds locks and a connection. Never make an HTTP call inside one โ€” a slow third party becomes a database outage.

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

The race from Session 03, in Go

Two patients book the same 09:30 slot, two milliseconds apart. A transaction alone does not save you here โ€” the constraint does.

internal/repository/postgres/appointment.go
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)
}
โœ—Do not check first. A 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.
โœ“Insert and let it fail. The unique index is evaluated atomically. One insert wins, the other gets 23505, and no slot is ever sold twice โ€” across any number of servers.
โ†’Match on the constraint name, not the message. Message text is localised and changes between versions; idx_doctor_slot is the name you chose in Session 03 and it is stable.
โ†’This is why constraints get names. An anonymous constraint surfaces as appointments_doctor_id_starts_at_key, and now your error handling depends on PostgreSQL's naming algorithm.

9c

Errors are part of your API

The repository's job is to stop leaking database vocabulary. Callers should handle ErrSlotTaken, not pgconn.PgError code 23505.

Approach
What breaks it
Verdict
err.Error() contains "duplicate"
locale, driver version, wording
never
err == gorm.ErrRecordNotFound
any wrapping with %w
use errors.Is
errors.Is(err, gorm.ErrRecordNotFound)
nothing
correct
errors.As(&pgErr) + constraint name
renaming the constraint
correct, in the repository only
Package sentinels (ErrSlotTaken)
nothing
what callers should see
Iserrors.Is walks the wrap chain, so it keeps working after fmt.Errorf("...: %w", err). Comparing with == breaks the moment anyone adds context.
Aserrors.As is for extracting a typed error you need fields from โ€” here the PostgreSQL error's ConstraintName and Code.
%wWrap when you add context, return a sentinel when you are classifying. %w preserves the chain; %v flattens it to text and destroys errors.Is.
โœ“The boundary is the repository. Nothing above it imports pgconn or gorm โ€” which is exactly what makes the service layer testable in Session 10 and the handler in Session 11 nine lines long.
Mistake

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.

TASK

Lose the race on purpose

(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

Session 09 in four lines

1db.Transaction(fn) commits on nil and rolls back on any error โ€” and every statement inside must use tx.
2A transaction gives you atomicity, not mutual exclusion: SELECT-then-INSERT still loses the race under READ COMMITTED.
3Insert and let the named unique index reject the loser; match on the constraint name, never on message text.
4Translate driver errors into package sentinels at the repository boundary, and compare with errors.Is.