Hooks
Hooks are where domain logic lives. Register once at startup, typically from
init or main; they run in registration order, and one returning an error
aborts the operation with the error reaching the caller unwrapped.
Where domain logic goes argues why the seam is here. This is how to use it.
BeforeQuery is the load-bearing one
Section titled “BeforeQuery is the load-bearing one”It receives the query itself, so one registration constrains every read of the model — including the reads the generated REST handlers issue.
sqlb.On[Post](reg).BeforeQuery(func(ctx context.Context, q *sqlb.Builder[Post]) error { org, ok := auth.OrgFrom(ctx) if !ok { return auth.ErrNoTenant } q.Where(sqlb.F("org_id").Eq(org), sqlb.F("deleted_at").IsNull()) return nil})Multi-tenancy and soft deletes stop being something each call site has to remember. This is also why REST registration is generic over the model rather than reflective: hooks are keyed by type, and a reflective dispatcher could not run them.
Returning an error is how “no tenant in this context” becomes impossible to forget rather than merely documented — no statement runs at all.
The hook amends a clone, on the exec path, so q.SQL() on the builder you built
does not show what it added. q.Resolved(ctx, db) does — reach for it when the
predicate has to be read as text, for a raw statement that must count the same
rows or for a test asserting the scope is in force. See
Inspecting.
Say it in the schema, so the missing hook is the one that is caught
Section titled “Say it in the schema, so the missing hook is the one that is caught”The hook above cannot be forgotten at a call site. It can be forgotten
entirely, and an unscoped model serves every tenant’s rows with a 200 next to
them. So the table declares what it expects:
schema.Ref("org", Org).Filterable().ReadOnly().Scoped()Scoped writes no predicate — it is inert in exactly the way SoftDelete’s
column is. What it does is oblige the resource: rest.Resource refuses to
mount a model whose declarations no hook satisfies, and names every missing
registration at once. The obligation follows the operations, because a
BeforeQuery hook says nothing about what a request can overwrite by id — an
exposed update needs BeforeUpdate, a delete needs BeforeDelete, and a
create needs BeforeCreate to supply the tenant column that ReadOnly kept
out of the request body.
The check proves a hook exists, not that it is right. That is worth knowing before relying on it, and it catches the case that actually happens: the table somebody added last week. ADR-0030 has the reasoning, including why the predicate is not generated for you.
example/tasks/app/hooks.go is this taken
as far as it goes: one file, a little over two hundred lines, confining six
models across twenty-five endpoints. Two details there are worth stealing. The
scoping is one generic function used four times rather than four near-copies,
which is only possible because every table in that schema names the column
workspace_id — a convention kept deliberately so the boundary can be written
once. And reads and writes are scoped by separate registrations, because a
BeforeQuery predicate constrains what a request can see and says nothing about
what it can overwrite by id.
The rest
Section titled “The rest”| Hook | Receives | Use for |
|---|---|---|
BeforeCreate |
*T |
Normalising an email, deriving a slug, stamping an owner |
AfterCreate |
*T, with defaults populated |
Validation |
BeforeUpdate |
*Update[T] |
Forcing a column, narrowing affected rows |
AfterUpdate |
[]T |
Validation |
BeforeDelete |
*Delete[T] |
Narrowing, or refusing |
AfterDelete |
int64 |
Validation |
AfterDeleteRows |
[]T, as they were |
Anything needing the row’s identity |
AfterDelete and AfterDeleteRows are two hooks rather than one because the
rows are not free. A Delete is write-only for predicates, so a BeforeDelete
cannot ask what a statement addresses and the rows have to come back from the
statement itself — which means DELETE … RETURNING and a scan of everything it
matched. sqlb adds that clause only when an AfterDeleteRows hook is registered
for the model, so a delete whose rows nobody reads still costs one command tag.
Register the rows form when a count is not enough, which in practice means
publishing anything: an event that says how many posts were deleted and not
which is worse than no event, because the subscriber invalidating a cache
keyed on the row has nothing to key on and the feed looks wired up.
rest.PublishChanges uses it for exactly that.
All of these run inside the caller’s transaction. That is right for validation — an error rolls the write back — and wrong for anything the outside world can observe.
The write hooks are narrower than BeforeQuery: they receive the row or the
statement rather than a handle. They can still reach the database, but only
where a transaction is — rest wraps every generated write in one, so
sqlb.TxFrom(ctx) finds it and a hook can query, as
Reading your own writes below shows. On a read, or
under Options.DisableTransactions, there is nothing to find.
What fires when, and inside which transaction
Section titled “What fires when, and inside which transaction”Four questions decide whether a domain invariant holds, and none of them is answerable from a signature. Stated here so they need not be answered by reading the source.
Every write path fires the hooks, not just the generated ones.
sqlb.InsertRows(&a, &b).Exec(ctx, tx) runs BeforeCreate on each row and
AfterCreate on each stored row, exactly as POST /posts does. A hand-written
HTML form handler and a generated REST handler enforce the same rules without
either knowing the other exists, and that is the property the whole arrangement
is for.
AfterCreate receives a pointer into the returned rows, so it can change
the response. Mutating *T there changes what the caller gets back and what
rest writes to the wire — which is how a generated POST /orders can answer
with the fill it just executed rather than with the order as submitted. This
makes AfterCreate a good deal more than the “validation” its row in the table
above suggests.
A defaulted column holding its zero value is omitted from the insert. So
the database supplies it rather than a zero overwriting it, and a BeforeCreate
that copies one column into another falls out correctly in the zero case
without a special case. This is why “has a default” and “is optional in the
create body” are the same question.
Hooks reach the transaction. WithTx hands fn a *sqlb.DB carrying the
same registry, so hooks fire on statements issued inside it, and TxFrom(ctx)
resolves within a hook — a BeforeCreate can read what earlier statements in
the same unit of work have written but not yet committed.
The gap that remains is narrower and deliberate: BeforeUpdate cannot read the
assignments it was handed, so a rule that depends on what a column is becoming
belongs in a BEFORE trigger.
ADR-0021 records why the event types
that would have closed it are not being built.
What has landed from that record is the transaction: rest.Resource wraps every
generated create, update and delete in one, so AfterCommit is reachable from a
generated write. Set Options.DisableTransactions to opt out, and read the next
section before you do.
An insert can mean something
Section titled “An insert can mean something”AfterCreate running inside the write’s transaction is what lets a generated
POST be a domain operation rather than an insert. The handler decodes a body,
validates it and inserts a row, and knows nothing about the rule; the hook turns
that insert into a placement — reserve, match, write the consequence — in the
same transaction, so a refusal rolls the row back with it.
That is why a schema modelled this way has no “rejected” status: an operation that could not be performed is not a row, it is a 422.
The alternative is a hand-written /orders/place, which would work and would be
a second door: the generated create would still exist, and the next person
to write against the model would insert rows that reserved nothing. Closing both
doors with one registration is the argument for hooks in a sentence.
Writing the consequence
Section titled “Writing the consequence”“Write the consequence” is the step the signature does not show. A hook receives
its own model and nothing else — no *DB, no Executor, no application handle —
so read AfterCreate(func(context.Context, *Order) error) cold and the
reasonable conclusion is that a hook can amend the statement it was given and
nothing more. It is not, and the door out is sqlb.TxFrom(ctx): the ctx that
looks like plumbing is carrying the transaction the write is running in.
Here is the checkout in full. Placing an order decrements the shop’s stock, in the same transaction, so a refusal rolls the order back with it:
sqlb.On[Order](reg).AfterCreate(func(ctx context.Context, o *Order) error { tx, ok := sqlb.TxFrom(ctx) if !ok { return errors.New("orders must be placed in a transaction") } // system, not tx. See below. updated, err := sqlb.UpdateRows[Stock](). SetExpr("count", sqlb.Raw{SQL: `"count" - ?`, Args: []any{o.Qty}}). Where(sqlb.F("sku").Eq(o.SKU)). One(ctx, tx.WithHooks(system)) if err != nil { return err // ErrNotFound rolls the order back: nothing to sell. } if updated.Count < 0 { return fmt.Errorf("%w: %s is oversold", ErrConflict, o.SKU) } return nil})Two things in that block are the whole of it.
tx.WithHooks(system), not tx. TxFrom hands back the handle the request
is running on, and that handle carries the request’s rules. Stock is
Scoped, so rest.Resource obliged a BeforeUpdate hook confining stock rows
to their owner — and running this statement through the request’s registry
appends the buyer’s scope to the shop’s inventory write:
UPDATE "stocks" SET "count" = "count" - $1 WHERE ("sku" = $2) AND ("shop_id" = $3)-- ^ the buyerAgainst a real database that matches nothing. The rule written to confine a
request has silently confined the domain logic that was supposed to act past it.
So the escalated write runs on a handle carrying a different registry — the
second one from Scoping and tests — which is what makes the
statement unscoped and says so at the call site. tx.Tx() gives the raw pgx.Tx
and resolves to no registry at all, which is the same thing spelled without a
registry to name.
One, not Exec. Exec is the natural spelling for a decrement — you want
the effect, not the row — and it is the one that goes quiet: zero rows is
([]T{}, nil), the hook returns nil, and the transaction commits with an order
that reserved nothing. One answers ErrNotFound on zero rows, which rolls the
write back. Prefer it in a hook whenever “this changed nothing” is a reason to
refuse (#159).
Both mistakes are invisible in the Go code and in review, which is why this
section exists rather than a sentence about TxFrom.
Why a hook’s own statements are subject to the same rules at all. Hooks here
run at the statement layer, not at the API layer. That is what makes one
registration cover the generated handler, the background job and the admin script
alike — and it is the same property that puts a hook’s own writes inside the
rules, because from below there is nothing to distinguish them. Frameworks that
confine at the API boundary (PocketBase’s rules, a Django permission class) leave
DAO access open, so a hook there reaches the database unconfined by default and a
background job reaches it unconfined too. The trade is real in both directions;
this is the side sqlb takes, and tx.WithHooks(system) is the escape hatch it
costs.
AfterCommit, for side effects
Section titled “AfterCommit, for side effects”Publishing an event, enqueuing a job, invalidating a cache: none of these may
happen if the write does not. AfterCreate running inside the transaction means
the transaction can still abort after the hook has already told the world it
succeeded.
sqlb.On[Order](reg).AfterCreate(func(ctx context.Context, o *Order) error { id := o.ID return sqlb.AfterCommit(ctx, func(ctx context.Context) error { return events.Publish(ctx, OrderPlaced{ID: id}) })})Callbacks run in registration order once Commit returns nil, and not at all if
it rolls back. The context they receive carries no transaction — there is
nothing left to join, and handing back a committed one would be a trap.
A failing callback does not stop the others; the failures are joined under
ErrAfterCommit. That sentinel matters, because the two cases need opposite
responses:
if err := db.WithTx(ctx, placeOrder); err != nil { if errors.Is(err, sqlb.ErrAfterCommit) { // The order exists. Something downstream of it did not fire. log.Error("order placed, notification failed", "err", err) } else { return err // The order does not exist. }}Outside a transaction, AfterCommit is an error rather than an immediate call:
under autocommit sqlb cannot say when the commit happened, so the callback would
fire before the insert or after it depending on which hook called it.
From a generated handler there is always a transaction, because
rest.Resource opens one per write. The two ways to end up without one are a
write you issue yourself outside WithTx, and a resource that set
Options.DisableTransactions. The second is worth stating plainly: turning it on
does not disable AfterCommit, it makes every registration fail at request time.
That is loud rather than silent, which is the point — but it means the option is
a decision about the resource’s hooks, not only about its latency.
This is in-process and at-most-once. A callback that never ran because the process died leaves no trace — that is what a transactional outbox is for, and it is not built (ADR-0012).
Reading your own writes
Section titled “Reading your own writes”A hook that needs to see rows written earlier in the same transaction must read through the transaction handle. Reading through the pool would miss them, because they are not committed yet:
sqlb.On[Post](reg).BeforeCreate(func(ctx context.Context, p *Post) error { tx, ok := sqlb.TxFrom(ctx) if !ok { return errors.New("posts must be created inside a transaction") } n, err := sqlb.Query[Post]().Where(sqlb.F("slug").Eq(p.Slug)).Count(ctx, tx) …})A check-then-act like that is only sound when something else has the last word. Where the guarantee is a unique index, the read exists to turn an unclassifiable Postgres error into a 409 that names the problem — which is a good reason. Where there is no constraint underneath, two concurrent requests will both pass the check; see where domain logic goes.
Locking order
Section titled “Locking order”A hook is also where a lock is taken deliberately, and it has to be taken in
BeforeCreate, not AfterCreate. This one is invisible in the Go code.
Inserting a row takes a FOR KEY SHARE lock on every row its foreign keys
reference — Postgres checking the reference, not anything you wrote. Key-share
locks are shared, so two concurrent inserts both get them; if each then tries to
upgrade the same referenced row to FOR UPDATE inside AfterCreate, each waits
for the other’s share lock. That is a guaranteed deadlock, it scales with
concurrency, and it surfaces as a 500 naming a statement you did not write:
ERROR: deadlock detected (SQLSTATE 40P01) on: SELECT ... FROM "stocks" WHERE "id" = $1 LIMIT 2 FOR UPDATETaking the exclusive lock in BeforeCreate — before the row exists, so before
the key-share lock is taken — fixes it, and costs one line of ordering.
The other rule is to take locks in a consistent order: two transactions locking the same rows in opposite orders deadlock, and the test that would have found it is the one nobody writes.
Both are easy to state and impossible to see, which is why they are worth being
a named function with the explanation attached rather than a line inside a
handler. ForUpdate, ForShare and SkipLocked are on
Mutations and transactions.
Scoping and tests
Section titled “Scoping and tests”On[T](r) registers into the registry you hand it, and db.WithHooks(r) is
how a handle acquires it. There is no process-wide registry to fall back on
(ADR-0047) — a handle built by
sqlb.New starts with an empty one of its own, so the rules in force are a
property of how the handle was assembled rather than of what ran first.
A test therefore gets isolation for free: build a registry, attach it, and there is nothing to tear down. Two tenants’ worth of differing domain rules coexist in one process the same way.
One consequence worth knowing: an Executor that is not a *sqlb.DB — a raw
pool, a borrowed pgx.Tx — carries no registry, so a statement issued against
one runs unconfined. That is why models whose rows must not be read unscoped
declare Scoped, which refuses the mount rather than trusting the call site
(ADR-0030).
The second registry is not only for tests
Section titled “The second registry is not only for tests”It is a normal part of assembling an application, and this repo has arrived at it
twice: example/tasks builds sys beside its hooked handle, and example/fxapp
provides fxkit.Unscoped as a distinct type so grep -r 'fxkit.Unscoped' lists
every consumer. Three reasons to want one, in the order an application meets
them:
- Reads that happen before there is a tenant. Sign-in has to find the user
before it knows which workspace to scope to.
example/tasksusessysfor exactly two endpoints, and for nothing else. - A hook writing past the scope of the request that triggered it. The checkout above: the buyer’s rules must not narrow the shop’s inventory write. This is the one an application hits on day one and the one with the quietest failure, which is why it has its own section.
- Isolation between suites and between tenants, which is where a test starts and is the cheapest of the three.
Two values, one of which never leaves its own file, is harder to misuse than one
handle and a “skip the hooks” flag — a flag is something a caller can pass, and
the set of callers allowed to pass it is the whole question. That is why there is
no db.Unscoped(): the answer to “who may escalate” should be readable from the
wiring, not from every call site that happens to hold a handle.
- Inspecting and tracing — seeing what a hook did
- Mounting resources — the handlers these hooks reach
- Capabilities —
ReadOnlyplus a hook, andScoped