GORM — Patterns#

Concurrency patterns#

Done-channel for shutdown / one-shot synchronization#

  • Usage: 3 occurrences in production code (schema, internal/lru, internal/stmt_store); more in tests
  • Example: schema/schema.go:207initialized: make(chan struct{}) acts as a one-shot broadcast: the schema parse goroutine closes the channel when finished, and any concurrent caller waiting on it receives the signal immediately. Same pattern in internal/stmt_store/stmt_store.go:163 for prepared-statement readiness.
  • Assessment: Idiomatic and efficient. Closing a chan struct{} to broadcast “done” to N waiters is the canonical Go pattern — better than a sync.WaitGroup when you need the wait to be observable by arbitrary latecomers.

Goroutine-based background eviction (LRU)#

  • Usage: 1 goroutine in internal/lru/lru.go:80
  • Example: go func(done <-chan struct{}) { ... }(l.done) — a background goroutine wakes on a ticker to evict expired LRU entries. Receives the done channel as a parameter to avoid a closure capture race.
  • Assessment: Clean. Passing done as a parameter rather than closing over l.done avoids a subtle data race if the field is reassigned.

sync.Map for concurrent caches#

  • Usage: 5+ locations; the dominant concurrency primitive
  • Key locations: gorm.go:76 (Config.cacheStore), schema/schema.go:61 (per-schema field cache), schema/pool.go:10 (global schema pool), schema/serializer.go:18 (serializer registry), statement.go:38 (per-statement settings)
  • Assessment: Appropriate for read-heavy, write-once caches (schema parsing happens once per type, then is read-only). The schema/ caches are the performance-critical path — avoiding a sync.RWMutex here is justified by the read-dominant access pattern.

sync.RWMutex for mutable shared state#

  • Usage: schema/relationship.go:37 (Relationships.Mux), prepare_stmt.go:17 (PreparedStmtDB.Mux)
  • Assessment: Used where writes are possible at runtime (relationship resolution during concurrent schema parsing, prepared statement map updates). Correctly favors readers.

Context propagation (not goroutine-based)#

  • Usage: 149 occurrences of context.Context — pervasive but passive
  • Pattern: Context is threaded through Statement.Context and forwarded to ConnPool.QueryContext / ExecContext. GORM does not launch goroutines for query execution; context is used solely for cancellation/deadline propagation into the underlying database/sql driver.
  • Assessment: Correct and minimal. Context is not stored in structs (the one exception, Statement.Context, exists because Statement is a per-operation value object, not a long-lived service).

Worker pools / Fan-out / Rate limiting#

  • Present: No. GORM is a synchronous library. All operations execute on the caller’s goroutine. No internal worker pools, pipelines, or rate limiters.

Graceful shutdown#

  • Present: Not applicable to a library. Connection pool draining is delegated to database/sql.

Error handling#

Style#

Sentinel errors + error accumulation on DB.Error; wrapping with fmt.Errorf %w.

GORM does not define custom error types with Error() string methods. Instead it defines a large set of sentinel package-level errors and accumulates them on the DB handle.

Error types defined#

errors.go declares 18 sentinel errors (all errors.New("...")):

  • ErrRecordNotFound, ErrInvalidTransaction, ErrNotImplemented
  • ErrMissingWhereClause, ErrUnsupportedRelation, ErrPrimaryKeyRequired
  • ErrModelValueRequired, ErrModelAccessibleFieldsRequired, ErrSubQueryRequired
  • ErrInvalidData, ErrUnsupportedDriver, ErrRegistered, ErrInvalidField
  • ErrEmptySlice, ErrDryRunModeUnsupported, ErrInvalidDB, ErrInvalidValue
  • ErrDuplicatedKey, ErrForeignKeyViolated, ErrCheckConstraintViolated

logger.ErrRecordNotFound is the canonical definition; gorm.ErrRecordNotFound is a re-export alias.

Error accumulation pattern (AddError)#

Rather than returning errors from chained methods (which would break the fluent API), GORM accumulates errors into db.Error:

// gorm.go:401
func (db *DB) AddError(err error) error {
    if db.Error == nil {
        db.Error = err
    } else {
        db.Error = fmt.Errorf("%v; %w", db.Error, err)
    }
    ...
    return db.Error
}

Multiple errors from a single operation chain are joined with %v; %w — the last error is wrapped, preserving errors.Is compatibility with the most recent error. Callers check result.Error after finisher methods.

Wrapping approach#

  • fmt.Errorf("%w", err) for wrapped errors (preserves errors.Is / errors.As checks)
  • fmt.Errorf("%v; %w", existing, new) in AddError for multi-error accumulation
  • schema/ uses bare fmt.Errorf for parse-time errors stored on schema.err (not returned immediately — deferred until first use)
  • Dialects translate DB-specific errors into canonical sentinel errors (ErrDuplicatedKey, ErrForeignKeyViolated) via the ErrorTranslator interface

Usage of errors.Is#

Callers use errors.Is(err, gorm.ErrRecordNotFound) — the standard pattern, enabled by sentinel + wrapping. 15+ occurrences in tests. The logger also uses errors.Is(err, ErrRecordNotFound) to conditionally suppress “not found” log noise (IgnoreRecordNotFoundError config).

Examples#

  • gorm.go:412: multi-error accumulation — fmt.Errorf("%v; %w", db.Error, err)
  • callbacks/transaction.go:12: direct equality check (tx.Error == gorm.ErrInvalidTransaction) — older style, pre-errors.Is
  • schema/relationship.go:79: deferred schema error — schema.err = fmt.Errorf("failed to parse field: %s, error: %w", field.Name, err)

Configuration pattern#

Two-phase Option interface#

// gorm.go:100
type Option interface {
    Apply(*Config) error
    AfterInitialize(*DB) error
}

Phase 1 (Apply) modifies *Config before the DB is constructed. Phase 2 (AfterInitialize) runs after initialization — used by plugins and dialects for post-boot wiring. *Config itself implements Option, so the primary API is:

db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{
    Logger:                 logger.Default.LogMode(logger.Info),
    NamingStrategy:         schema.NamingStrategy{TablePrefix: "t_"},
    PrepareStmt:            true,
    SkipDefaultTransaction: true,
})

This is not the typical functional option pattern (func(*T)) — it is a two-phase interface that allows plugins to hook both before and after initialization.

Per-session overrides with db.Session#

// Creates a shallow config clone; original DB unmodified
tx := db.Session(&gorm.Session{
    Context:               ctx,
    SkipDefaultTransaction: true,
    DryRun:                true,
})

db.WithContext(ctx) is sugar over db.Session(&Session{Context: ctx}). This gives GORM a clean immutable-ish API where chains don’t mutate the root *DB.


Dependency injection#

Approach: manual wiring via DB struct + Dialector.Initialize#

No DI framework (no wire, dig, or fx). Dependencies flow through DB.Config:

DB
└── *Config
    ├── Dialector      ← set by gorm.Open caller
    ├── *callbacks     ← created by initializeCallbacks()
    ├── Logger         ← defaulted or caller-provided
    ├── ConnPool       ← set by Dialector.Initialize()
    ├── NamingStrategy ← defaulted or caller-provided
    └── cacheStore     ← always defaulted

The only “inversion of control” is Dialector.Initialize(*DB) — the dialect receives the fully constructed (but empty-callback) DB and fills in ConnPool + all CRUD callbacks. This is dependency injection by convention: the system hands the container (DB) to the plugin (Dialector) and trusts it to wire itself.

Evidence#

  • gorm.go:214: config.Dialector.Initialize(db) — dialect wires ConnPool and registers callbacks
  • callbacks/callbacks.go:23: RegisterDefaultCallbacks(db, config) — called from dialect, not from core
  • Plugin interface (interfaces.go:24): Plugin.Initialize(*DB) — same pattern for third-party plugins

Other notable patterns#

Callback pipeline (Registry + Chain of Responsibility)#

The most architecturally significant pattern in GORM. Six named processor objects (create/query/update/delete/row/raw) each hold an ordered []func(*DB) compiled from a declarative []*callback list with before/after dependency constraints and match predicates.

// callbacks/callbacks.go
createCallback.Register("gorm:before_create", BeforeCreate)
createCallback.Before("gorm:create").Register("myplugin:hook", fn)
createCallback.Match(enableTransaction).Register("gorm:begin_transaction", BeginTransaction)

Ordering is resolved at registration time via a topological sort of before/after constraints. The match predicate (func(*DB) bool) enables conditional callbacks (e.g., skip transaction callbacks when SkipDefaultTransaction = true) without branching inside callback functions.

This pattern replaces method overriding / inheritance: dialects and plugins extend behavior by inserting callbacks at specific positions in the pipeline rather than subclassing any type.

Clone / Copy-on-Write for chaining safety (clone int)#

// gorm.go:106
type DB struct {
    *Config
    Error        error
    RowsAffected int64
    Statement    *Statement
    clone        int  // 0=no clone, 1=new Statement, 2=clone Statement
}

getInstance() checks clone to decide whether to allocate a fresh Statement (for the first chained call after Open/Session) or copy the existing one (for subsequent chained calls). This gives GORM safe concurrent use of a single *DB as a factory — each chain starts from its own Statement scratchpad without explicit locking.

Typed SQL AST (clause/) — Visitor-like Expression.Build#

Every SQL fragment is a typed value implementing clause.Expression:

type Expression interface {
    Build(builder Builder)
}

Clauses are stored in Statement.Clauses as map[string]clause.Clause (keyed by clause name like "WHERE", "ORDER BY"). During stmt.Build(...), each clause type renders itself into the Builder’s strings.Builder, calling Dialector.QuoteTo / BindVarTo for dialect injection. This is a visitor pattern where the visited nodes (clause structs) call back into the builder rather than accepting a visitor object.

Reflection caching via sync.Map (schema pool)#

// schema/pool.go
var normalPool sync.Map  // key: reflect.Type → value: *Schema

// schema/schema.go:134
func Parse(dest interface{}, cacheStore *sync.Map, namer Namer) (*Schema, error)

schema.Parse is the hot path — called on every DB operation. Results are memoized in a sync.Map keyed by reflect.Type. The first call per struct type pays full reflection cost; subsequent calls return the cached *Schema immediately. This pattern amortizes reflection across the application lifetime and keeps warm-path execution reflection-free.

Generics for type-safe builder API (Go 1.18+)#

generics.go introduces a parallel generic API on top of the *DB base:

func G[T any](db *DB, opts ...clause.Expression) Interface[T]

type Interface[T any] interface {
    Find(context.Context, ...clause.Expression) ([]*T, error)
    First(context.Context, ...clause.Expression) (*T, error)
    Create(context.Context, *T) (*T, error)
    // ...
}

G[User](db) returns a type-safe query builder that eliminates interface{} scanning. Implemented as a struct hierarchy (g[T], chainG[T], createG[T], execG[T]) with method sets matching the interface contracts. This is a builder pattern wrapped in a generic type constraint — unusual in that the non-generic and generic APIs coexist, the former for dynamic/reflection-heavy use, the latter for typed access.

Sentinel error catalogue (18 errors)#

All domain errors are errors.New(...) package-level variables. No custom structs. No Unwrap() chains beyond fmt.Errorf("%w", ...). The ErrorTranslator interface allows dialects to map database-specific errors to canonical GORM sentinels:

type ErrorTranslator interface {
    Translate(err error) error
}

This keeps the sentinel set stable across all dialects while allowing driver-specific error codes to be surfaced via errors.Is(err, gorm.ErrDuplicatedKey).

Interface embedding for connection pool hierarchy#

// interfaces.go
type Tx interface {
    TxCommitter                  // Commit() / Rollback()
    ConnPool                     // ExecContext / QueryContext / QueryRowContext
}
type TxBeginner interface {
    BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error)
}
type ConnPoolBeginner interface {
    ConnPool
    TxBeginner
}

The ConnPool hierarchy uses embedding to express “a Tx is both a committer and a connection pool” without duplicating method lists. PreparedStmtDB implements ConnPoolBeginner to transparently intercept queries for LRU-cached statement preparation.

Table-driven tests#

  • Prevalence: 92 occurrences of t.Run / test table patterns across tests/ and package tests
  • Style: Named struct slices with name, input, expected fields; anonymous inline struct tables for lighter cases
  • Example: tests/ integration test files (e.g., delete_test.go, associations_has_one_test.go) use sequential named sub-tests rather than table slices, reflecting integration-over-unit test culture

Type assertions and type switches#

  • Prevalence: Extensive in scan.go and schema/field.go — the core type-mapping and row-scanning logic
  • Pattern: switch dest := dest.(type) to dispatch on concrete types when mapping database/sql column values to Go struct fields. Also used in finisher_api.go to detect if a value is *sql.Rows vs a model struct.
  • Assessment: Necessary consequence of Go’s pre-generics type system for ORM scanning; the generic API in generics.go reduces (but doesn’t eliminate) this need.