-
Notifications
You must be signed in to change notification settings - Fork 0
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.
┌───────────────────────────────────────────────────────────────┐
│ 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.
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.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.
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:
- opens a transaction per migration,
- acquires an advisory lock (
pg_advisory_lockon Postgres, amigrations_lockrow elsewhere), - applies pending migrations in batch
N+1, - 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.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 -
Updatewith WHERE-binding placeholder reuse - Postgres
$Nplaceholder 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.
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_atfield 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.
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.
The CLI is a tiny Cobra wrapper. cli.New(opts) builds the root
command and mounts:
-
make:*generators (readcli/stubs/*.stubviago:embed), -
migrate*commands (call intomigrations.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.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.
-
*database.Manager,*database.Connection, the migrationRegistry, the seederRegistry, and thereflectutilschema 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*Txthat satisfiesExecutor; queries inside the closure run in the transaction. Rollback is automatic on a non-nil return.
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:
- honours errors first (
orm.ErrNotFound→ 404,*ValidationError→ 422, anything else → 500), - respects body-already-written state (so middleware writing 401 isn't overridden),
- 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.
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'sgo.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.
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.Saveinsert (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.
- ORM, Web-Framework, Migrations-and-Schema — the user-facing surface of the layers described above.
-
Framework-Integration — how applications consume the
abstractions without buying into the bundled
webframework.
lagodev on GitHub · MIT-licensed — see LICENSE.