Skip to content

Architecture

devituz edited this page Jun 3, 2026 · 1 revision

Architecture

This page walks through how the packages fit together. If you've used Laravel / Eloquent, most concepts map 1:1; if you're coming from raw database/sql, this is a tour of the abstractions involved and why they exist.

Layered overview

┌───────────────────────────────────────────────────────────────┐
│  Application code (HTTP handlers, gRPC servers, jobs, CLIs)   │
└───────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────┐  ┌─────────────────┐  ┌────────────────────┐
│  orm (Query[T]) │  │  factory.New[T] │  │  cli.App (cobra)   │
└─────────────────┘  └─────────────────┘  └────────────────────┘
                            │                       │
                            ▼                       ▼
┌─────────────────┐  ┌─────────────────┐  ┌────────────────────┐
│ query.Builder   │  │ schema.Blueprint│  │ migrations.Migrator│
└─────────────────┘  └─────────────────┘  └────────────────────┘
                            │
                            ▼
┌───────────────────────────────────────────────────────────────┐
│  database.Connection (Tx, Executor, Grammar) → *sql.DB        │
└───────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│ postgres    │  │ mysql       │  │ sqlite      │  ← drivers/* register
└─────────────┘  └─────────────┘  └─────────────┘    grammars + factories

Each layer talks to the one directly below it. There's no global state past init() (which registers drivers, migrations, seeders); no service locator; no DI container. A handler that wants to read a row walks ORM → Query → Executor → *sql.DB and back — every step exposed and substitutable.

Driver abstraction (database/)

The framework rests on three small interfaces:

type Executor interface {
    ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
    QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
    QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
    PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
}

type Grammar interface {
    Name() string
    Quote(ident string) string
    Placeholder(n int) string
    CompileType(kind string, opts ColumnTypeOptions) string
    SupportsReturning() bool
    LastInsertIDStrategy() InsertIDStrategy
}

*database.Connection and *database.Tx both satisfy Executor, so query code is agnostic about whether it's inside a transaction. Grammar encapsulates everything dialect-specific: identifier quoting ("…" vs `…`), placeholders (? vs $1), and column type rendering.

Adding a driver is a matter of writing a Grammar implementation, calling database.Register("yourdialect", openFn) in init(), and blank-importing the package from your binary — see the existing drivers/postgres/, drivers/mysql/, and drivers/sqlite/ implementations.

Schema (schema/)

schema.Create("users", func(t *schema.Blueprint) {...}) returns a *schema.Definition. The migration runner calls schema.NewCompiler(grammar).Compile(def), which produces one or more Statements — usually a single CREATE TABLE plus separate CREATE INDEX statements.

Column kinds are portable strings ("bigInteger", "jsonb", …); each Grammar.CompileType maps them to dialect SQL. This is the seam that lets the same blueprint compile to BIGSERIAL on Postgres, BIGINT AUTO_INCREMENT on MySQL, and INTEGER PRIMARY KEY on SQLite.

The Blueprint is immutable from the perspective of the migrator — you build it inside a func(t *Blueprint) closure, the runner snapshots the result, and compiles once. There's no global registry of columns that needs synchronising.

Migrations (migrations/)

A migration is anything implementing:

type Migration interface {
    Name() string
    Up(ctx *Context) error
    Down(ctx *Context) error
}

In practice you use migrations.Define(name, up, down) and register it in init(). The Registry keeps them sorted by name (timestamp- prefixed by convention), and Migrator:

  1. opens a transaction per migration,
  2. acquires an advisory lock (pg_advisory_lock on Postgres, a migrations_lock row elsewhere),
  3. applies pending migrations in batch N+1,
  4. records each application with its SHA-256 checksum so subsequent runs can detect if a migration was edited after being applied.

Pretend mode never executes — it captures emitted SQL via the Context.preview flag and prints it.

See Migrations-and-Schema for the user-facing API.

Query builder (query/)

query.New(conn, "users") produces a chainable builder. Internally it holds slices of wheres, joins, orders, groups, havings; ToSQL() walks them in one pass and emits a single string with positional arguments. The hot path is allocation-conscious (we pre-size args) — see benchmarks/.

Key features:

  • Nested Where(func(q *Builder) { … }) groups
  • WhereIn([]any | []int | []string | []int64 | []uint64) overloads
  • Aggregates (Count, Sum, Avg, Max, Min)
  • LockForUpdate / SharedLock
  • Insert / InsertGetID (RETURNING-aware) / InsertBatch
  • Update with WHERE-binding placeholder reuse
  • Postgres $N placeholder numbering preserved across WHERE/INSERT binding boundaries

The builder is not safe for concurrent reuse — make a new builder per goroutine. This is intentional: chaining mutates fields, and adding a mutex would be wasted work in 99% of use cases.

ORM (orm/)

Models embed orm.Model, which gives them ID, CreatedAt, UpdatedAt. The reflection cache in internal/reflectutil parses each struct exactly once and stores:

  • column ↔ field map
  • primary-key field
  • created_at / updated_at field pointers
  • cast tags (orm:"cast:json")

orm.Save is a single function — it inserts when the primary key is zero, otherwise updates. Hooks (BeforeCreate / …) are dispatched as interface assertions at runtime: there's no global state to register them with, no init-order dance to debug. Adding a hook is a method on your model and the framework finds it on the next call.

Relations (relations/)

Each relation is a plain function:

relations.HasManyOf(conn, parent, &dst, "user_id").Load(ctx)

For eager-loading a batch of parents, use LoadMany — it issues a single WHERE foreign_key IN (...) query and distributes results by callback. This avoids the N+1 problem without needing global state.

BelongsToMany joins through a pivot table; MorphMany / MorphOne filter on a <name>_type discriminator column.

CLI (cli/)

The CLI is a tiny Cobra wrapper. cli.New(opts) builds the root command and mounts:

  • make:* generators (read cli/stubs/*.stub via go:embed),
  • migrate* commands (call into migrations.Migrator),
  • db:* commands.

Stub overrides are first-class: cmd.SetStubOverride("model", "./mystubs/model.stub") lets a project ship custom templates without forking. The same Env struct gets passed to every command, so it's trivial to add new commands inside your own binary — see CLI-Reference for the "Custom binary" pattern.

Factory & seeder (factory/, seeder/)

factory.New[T](conn, definition) returns a generic builder. States are just func(*Faker, *T) callbacks; states + per-instance overrides

  • before- and after-hooks compose cleanly because everything is a slice of functions.

The seeder runner topologically sorts registered seeders by their declared Dependencies(). Cycles fail loudly; the same seeder can be run on its own via --only.

Threading & transactions

  • *database.Manager, *database.Connection, the migration Registry, the seeder Registry, and the reflectutil schema cache are all goroutine-safe.
  • The query builder is not safe for concurrent reuse — make a new builder per goroutine. The chained-mutation style would otherwise require a per-builder mutex that's wasted in nearly all call sites.
  • Transactions are scoped to a function via conn.Transaction(ctx, fn) — the closure receives a *Tx that satisfies Executor; queries inside the closure run in the transaction. Rollback is automatic on a non-nil return.

Web framework (web/)

web.App is a thin wrapper around http.ServeMux (Go 1.22+ pattern routing). The (any, error) handler contract is implemented in Context.respond, which:

  1. honours errors first (orm.ErrNotFound → 404, *ValidationError → 422, anything else → 500),
  2. respects body-already-written state (so middleware writing 401 isn't overridden),
  3. falls back to JSON-marshalling the return value with a 200 status if neither side did anything explicit.

The middleware chain is a slice of func(Handler) Handler — first registered is outermost. Built-ins (Logger, Recovery, SecurityHeaders, RateLimit, …) all follow the same shape, so user middleware is symmetric with framework middleware.

See Web-Framework for the user-facing API.

Extensions

A growing collection of optional packages and adapters extends the core without bloating its dependency graph:

  • Adapters (separate Go modules)adapters/gin, adapters/websocket, adapters/grpc, filesystem/s3, drivers/redis. They depend on third-party libraries that would otherwise inflate the core module's go.sum.
  • Core add-ons (no external deps)crypt, cache, events, httpclient, filesystem, process, authz, carbon, session, mail, queue, scheduling, i18n, notifications, broadcasting, mock.

Each extension follows the same pattern: small interface in the core (Mailer, Disk, Broadcaster, Queue), with an in-memory implementation in-box and remote drivers in separate sub-modules.

Performance notes

See benchmarks/ for live numbers. Rough indicators on Apple M1 Pro / SQLite in-memory:

  • query.Builder.ToSQL (10 wheres): ~1 µs / ~1 KB allocations
  • orm.Save insert (round-trip): ~5 µs
  • InsertBatch(100): ~140 µs (≈ 1.4 µs/row)
  • Query[T].Get() over 200 rows w/ hydration: ~310 µs

The reflection cache is the main correctness win; without it Save would re-parse the model on every call. The cache is read-mostly so sync.RWMutex contention stays negligible past warm-up.

See also

Clone this wiki locally