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:207—initialized: 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 ininternal/stmt_store/stmt_store.go:163for prepared-statement readiness. - Assessment: Idiomatic and efficient. Closing a
chan struct{}to broadcast “done” to N waiters is the canonical Go pattern — better than async.WaitGroupwhen 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
doneas a parameter rather than closing overl.doneavoids 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 async.RWMutexhere 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.Contextand forwarded toConnPool.QueryContext/ExecContext. GORM does not launch goroutines for query execution; context is used solely for cancellation/deadline propagation into the underlyingdatabase/sqldriver. - Assessment: Correct and minimal. Context is not stored in structs (the one exception,
Statement.Context, exists becauseStatementis 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,ErrNotImplementedErrMissingWhereClause,ErrUnsupportedRelation,ErrPrimaryKeyRequiredErrModelValueRequired,ErrModelAccessibleFieldsRequired,ErrSubQueryRequiredErrInvalidData,ErrUnsupportedDriver,ErrRegistered,ErrInvalidFieldErrEmptySlice,ErrDryRunModeUnsupported,ErrInvalidDB,ErrInvalidValueErrDuplicatedKey,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 (preserveserrors.Is/errors.Aschecks)fmt.Errorf("%v; %w", existing, new)inAddErrorfor multi-error accumulationschema/uses barefmt.Errorffor parse-time errors stored onschema.err(not returned immediately — deferred until first use)- Dialects translate DB-specific errors into canonical sentinel errors (
ErrDuplicatedKey,ErrForeignKeyViolated) via theErrorTranslatorinterface
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.Isschema/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 defaultedThe 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 callbackscallbacks/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 acrosstests/and package tests - Style: Named struct slices with
name,input,expectedfields; 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.goandschema/field.go— the core type-mapping and row-scanning logic - Pattern:
switch dest := dest.(type)to dispatch on concrete types when mappingdatabase/sqlcolumn values to Go struct fields. Also used infinisher_api.goto detect if a value is*sql.Rowsvs a model struct. - Assessment: Necessary consequence of Go’s pre-generics type system for ORM scanning; the generic API in
generics.goreduces (but doesn’t eliminate) this need.