GORM — Architecture#

Architectural style#

Layered Library with Callback Pipeline and Dialect Plugin System

GORM is a pure library (no binary, no server). Its architecture has three distinct layers:

  1. Public API layer — the root gorm package exports the user-facing API (DB, Config, Statement, chainable + finisher methods). This is what library consumers import.
  2. Callback pipeline layer — the callbacks/ package implements CRUD operations as ordered, composable function pipelines registered into per-operation processor objects. This is the primary behavioral extension mechanism.
  3. Domain sub-packagesclause/ (typed SQL AST), schema/ (Go struct reflection), logger/, migrator/, utils/ are pure leaf packages with no dependency on each other or on callbacks/.

Cross-cutting the layering is the Dialector plugin system: the Dialector interface abstracts all database-specific behavior. External dialect packages (e.g., gorm.io/driver/postgres) implement Dialector and call callbacks.RegisterDefaultCallbacks() during Initialize(*DB), wiring the concrete CRUD implementations into the callback pipeline. This means GORM core compiles with zero database driver dependencies.

Evidence: gorm.go imports only clause/, logger/, and schema/ — no database/sql driver. interfaces.go defines Dialector as an 8-method interface. callbacks/callbacks.go:RegisterDefaultCallbacks() is called by dialects, not by the core.

Component diagram (textual)#

┌─────────────────────────────────────────────────────────────┐
│  User Code                                                  │
│  db.Where("name=?","Alice").Find(&users)                    │
└───────────────────────┬─────────────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────────────┐
│  Root Package (gorm)                                        │
│  ┌──────────────┐  ┌───────────────┐  ┌──────────────────┐  │
│  │   DB struct  │  │   Statement   │  │  Config / Option │  │
│  │  (clone int) │  │ (SQL builder) │  │  (Plugin map)    │  │
│  └──────┬───────┘  └──────┬────────┘  └──────────────────┘  │
│         │  chainable_api  │  finisher_api                    │
│         └────────────┬────┘                                  │
└──────────────────────┼──────────────────────────────────────┘
                       │ Execute(db)
┌──────────────────────▼──────────────────────────────────────┐
│  Callback Pipeline (callbacks.processor)                    │
│  per operation: create / query / update / delete / row / raw│
│                                                             │
│  [BeginTx] → [BeforeCreate] → [SaveAssoc] → [Create]       │
│           → [SaveAssoc] → [AfterCreate] → [Commit/Rollback] │
│                                                             │
│  Registered by: Dialector.Initialize() → RegisterDefaultCallbacks()│
└───────────┬───────────────────────┬────────────────────────┘
            │                       │
┌───────────▼──────────┐  ┌────────▼────────────────────────┐
│  clause/ (SQL AST)   │  │  schema/ (Struct Reflection)    │
│  SELECT, WHERE,      │  │  Schema, Field, Relationship    │
│  ORDER BY, …         │  │  NamingStrategy, Serializer     │
│  Dialect-agnostic    │  │  Tag parsing, index/FK defs     │
└───────────┬──────────┘  └────────────────────────────────┘
            │ Dialector renders clauses
┌───────────▼──────────────────────────────────────────────┐
│  Dialector interface (e.g., gorm.io/driver/postgres)     │
│  Name(), Initialize(), Migrator(), DataTypeOf(),         │
│  BindVarTo(), QuoteTo(), Explain()                       │
└───────────┬──────────────────────────────────────────────┘
            │
┌───────────▼──────────────────────────────────────────────┐
│  ConnPool interface (wraps database/sql)                 │
│  ExecContext / QueryContext / QueryRowContext             │
│  PreparedStmtDB (optional LRU cache layer)               │
└──────────────────────────────────────────────────────────┘

Core components#

DB#

  • Package: gorm.io/gorm (root)
  • Responsibility: The user-facing database handle. Carries a pointer to Config (shared) and a *Statement (per-operation). The clone int field implements a lazy copy-on-write strategy: clone=1 means the next operation gets a fresh Statement; clone=2 clones the existing one.
  • Key types: DB, Session, Config
  • Dependencies: clause/, logger/, schema/, Statement, callbacks

Statement#

  • Package: gorm.io/gorm (statement.go)
  • Responsibility: Mutable scratchpad for a single database operation. Holds the clause map, SQL builder, parameter vars, model/dest values, schema, connection pool reference, and per-query settings. Assembled by the chainable API and consumed by the callback pipeline.
  • Key types: Statement, join, StatementModifier
  • Dependencies: clause/, schema/, ConnPool

callbacks / processor#

  • Package: gorm.io/gorm (callbacks.go) + gorm.io/gorm/callbacks
  • Responsibility: The behavioral core. callbacks manages six named processor objects (create/query/update/delete/row/raw). Each processor holds an ordered slice of func(*DB) handlers compiled from declarative before/after constraints. processor.Execute(db) runs all registered fns in order.
  • Key types: callbacks, processor, callback
  • Dependencies: Root package, schema/, utils/

clause (SQL AST)#

  • Package: gorm.io/gorm/clause
  • Responsibility: Typed representation of every SQL clause (SELECT, FROM, WHERE, JOIN, ORDER BY, LIMIT, GROUP BY, ON CONFLICT, RETURNING, WITH, FOR). Each clause type implements Expression.Build(*Statement) to render dialect-specific SQL via the Dialector.QuoteTo / BindVarTo hooks. Provides safe, composable query construction without string concatenation.
  • Key types: Clause, ClauseBuilder, Expression, Writer, Expr, Column, Table
  • Dependencies: None (leaf package)

schema#

  • Package: gorm.io/gorm/schema
  • Responsibility: Reflects Go struct types into a rich schema model: table name, columns, data types, relationships (HasOne/HasMany/BelongsTo/ManyToMany), constraints, indexes, and serializers. Results are cached in a sync.Map pool to avoid repeated reflection. Implements the NamingStrategy interface for table/column naming conventions.
  • Key types: Schema, Field, Relationship, NamingStrategy, Serializer
  • Dependencies: jinzhu/inflection, jinzhu/now, golang.org/x/text

Dialector (interface / adapter)#

  • Package: defined in gorm.io/gorm/interfaces.go, implemented in separate driver repos
  • Responsibility: Encapsulates all database-specific behavior: binding variables, quoting identifiers, explaining SQL, providing the migrator implementation, mapping Go types to DB types. During Initialize(*DB), the dialector calls callbacks.RegisterDefaultCallbacks() to wire CRUD implementations.
  • Key types: Dialector interface (8 methods)
  • Dependencies: Root package *DB, clause/, schema/

logger#

  • Package: gorm.io/gorm/logger
  • Responsibility: Logging abstraction with configurable log levels, slow-query threshold, SQL parameter redaction, and elapsed-time reporting. Ships both a default colorized implementation and a Go 1.21 slog-based adapter.
  • Key types: Interface, Config, Logger, SlogLogger
  • Dependencies: stdlib only

migrator#

  • Package: gorm.io/gorm/migrator
  • Responsibility: Portable migration logic (create table, add column, drop index, etc.) shared by all dialects as a base CommonMigrator. Each dialect embeds this and overrides database-specific behavior.
  • Key types: Migrator, ColumnType, TableType
  • Dependencies: Root package

Data flow#

Example: db.Where("name = ?", "Alice").First(&user)

  1. Chainable phaseWhere("name = ?", "Alice"):

    • db.getInstance() — if clone > 0, allocates a new Statement with fresh clause map (copy-on-write)
    • Adds a clause.Where{Exprs: [clause.Expr{SQL: "name = ?", Vars: ["Alice"]}]} to Statement.Clauses
    • Returns the new *DB (original unchanged)
  2. Finisher phaseFirst(&user):

    • db.getInstance() — another copy
    • Sets Statement.Dest = &user, Statement.Model = &user
    • Adds clause.OrderBy, clause.Limit{Limit: 1} to Statement.Clauses
    • Calls tx.callbacks.Query().Execute(tx)
  3. Processor.Execute(db):

    • Resolves any pending scopes
    • Calls stmt.Parse(stmt.Model)schema/ reflects the User struct, caches *schema.Schema
    • Iterates p.fns (the ordered callback slice): [Query, Preload, AfterQuery]
  4. Query callback (callbacks/query.go):

    • Calls stmt.Build("SELECT", "FROM", "WHERE", "GROUP BY", "ORDER BY", "LIMIT", "FOR") — each clause renders itself into stmt.SQL via clause.Expression.Build(stmt)
    • Dialector.BindVarTo() and QuoteTo() inject database-specific syntax
    • Calls stmt.ConnPool.QueryContext(ctx, stmt.SQL.String(), stmt.Vars...) → returns *sql.Rows
    • scan.go maps column values back to the User struct fields via schema.Field reflection
  5. AfterQuery callback — invokes the model’s AfterFind hook if implemented

  6. Loggingprocessor.Execute() calls db.Logger.Trace() with SQL + elapsed time after all fns run

Transaction flow (Create with default transaction):

BeginTransaction → BeforeCreate → SaveBeforeAssociations
→ Create (SQL exec) → SaveAfterAssociations → AfterCreate
→ CommitOrRollback

BeginTransaction and CommitOrRollback are registered with a match predicate (!SkipDefaultTransaction) so they’re excluded when transactions are disabled.

Initialization / Bootstrap#

gorm.Open(dialector, opts...) in gorm.go:134:

  1. Option sorting*Config options are sorted to the front (applied first)
  2. Option applicationopt.Apply(config) for each non-nil option (modifies Config in place)
  3. Defaults — NamingStrategy, Logger, NowFunc, Plugins map, cacheStore are set if nil
  4. DB constructiondb = &DB{Config: config, clone: 1}
  5. Callback initializationinitializeCallbacks(db) creates 6 empty processor objects (no fns yet)
  6. Dialector initializationconfig.Dialector.Initialize(db):
    • Dialect opens the underlying *sql.DB
    • Sets db.ConnPool
    • Calls callbacks.RegisterDefaultCallbacks(db, config) — this is where CRUD implementations are registered into the processors
  7. PreparedStmt (if enabled) — wraps ConnPool in PreparedStmtDB (LRU-backed cache)
  8. Root Statementdb.Statement = &Statement{DB: db, ConnPool: ..., Context: context.Background(), Clauses: map[string]clause.Clause{}}
  9. Ping — optional connection health check
  10. AfterInitialize — each option’s AfterInitialize(db) runs (plugins are initialized here)

No DI framework is used. Dependencies are wired manually: DB embeds *Config (which holds the Dialector, callbacks, Logger, ConnPool). There is no IoC container. The Option interface (Apply + AfterInitialize) is GORM’s own lightweight equivalent of functional options with deferred post-init hooks.

Configuration#

Configuration uses the Option interface pattern — not purely functional options, but a two-phase interface:

  • Apply(*Config) error — modifies the Config struct before DB is created
  • AfterInitialize(*DB) error — runs after DB is fully initialized (used by plugins)

*Config itself implements Option, so passing a pre-built Config is the primary way to configure GORM:

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

Per-session overrides use db.Session(&Session{...}) — this creates a shallow copy of Config with the session fields applied, leaving the original DB unmodified. db.WithContext(ctx) is sugar over db.Session(&Session{Context: ctx}).

The cacheStore (*sync.Map) allows arbitrary key-value storage on the DB instance, used internally for the prepared statement cache and available to plugins via db.Set / db.Get.

Key design decisions#

  1. Callback pipeline as the extension mechanism, not inheritance. CRUD operations are not implemented as methods on DB. Instead, they are registered func(*DB) handlers into per-operation processors. Dialects register their implementations during Initialize(). Users extend or override behavior by calling db.Callback().Create().Before("gorm:create").Register("myplugin:hook", fn). This allows dialects, plugins, and user code to modify any stage of any operation without subclassing.

  2. Typed SQL AST (clause/) instead of string concatenation. Every query is assembled from typed clause structs (SELECT, WHERE, ORDER BY, …) that implement Expression.Build(*Statement). The Statement carries a map[string]clause.Clause keyed by clause name, so any stage can add or override clauses before rendering. Dialect-specific rendering (quoting, binding) is injected via Dialector methods called during Build(). This makes query construction both composable and dialect-safe.

  3. DB clone / copy-on-write for chaining safety. The clone int field (0=no clone, 1=new Statement, 2=clone Statement) controls whether getInstance() allocates a fresh or copied Statement. This allows chainable methods to be called on a shared *DB without race conditions — each chain starts from a cloned instance. The pattern is unusual in Go ORMs and trades some memory allocation cost for a clean chaining API.

  4. Dialector interface decouples core from all database drivers. The core gorm.io/gorm module has zero SQL driver imports. Each dialect is a separate Go module. The interface boundary is rich enough (Initialize, Migrator, DataTypeOf, DefaultValueOf, BindVarTo, QuoteTo, Explain) to support full dialect customization, including custom type mapping, upsert syntax, and schema migration.

  5. schema/ as a cached reflection layer. Rather than reflecting struct types on every query, schema.Parse() returns a cached *Schema from a sync.Map pool keyed by reflect.Type. The schema model captures relationships, serializers, constraints, and indexes at parse time. This amortizes reflection cost over the application lifetime and makes query execution reflection-free for warm paths.