Skip to content

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.

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.

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.

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.

“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 buyer

Against 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.

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).

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.

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 UPDATE

Taking 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.

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).

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:

  1. Reads that happen before there is a tenant. Sign-in has to find the user before it knows which workspace to scope to. example/tasks uses sys for exactly two endpoints, and for nothing else.
  2. 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.
  3. 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.