Database

bun

The optional bun adapter: query builder and struct models, with the Transactor contract unchanged.

dbx/bunx is the optional bun adapter for the kit's transaction model. The default dbx/pg carries a native pgx.Tx in the context and works with hand-written SQL; a project that wants bun for query building wires this package instead. Bun only enters the binary when something imports bunx. The core kit stays bun-free.

import "github.com/gp-system/gpsystem/dbx/bunx"

What bun is, and what it is not

Let's be honest about the positioning: bun is a query builder with struct mapping (an "ORM-lite"), not Eloquent. What you get:

  • struct-based models (bun:"..." tags), builder-style query writing (NewSelect().Model(...).Where(...)),
  • relation loading (Relation(...)), bulk insert/update, typed scanning.

What you do not get from Eloquent: no ActiveRecord ($product->save()), no model events (creating/saved), no lazy loading, no accessors/mutators, no global scopes. A model is a plain struct; a query is an explicit builder call. This can feel spartan at first compared to a full ActiveRecord ORM. In exchange, every query lives where you wrote it, and exactly what you see is what runs.

Wiring

You pick it with the --db bun flag at project generation (see new project); the manifest records it, and every later generator (new module, add event, add worker) emits bun-flavoured code. The generated main.go wires it like this:

pool := pg.MustNewPool(ctx, cfg.DB)   // pool construction is shared with the pgx stack
bunDB := bunx.Open(pool)

deps := shop.Dependencies{
    DB:         bunDB,                     // *bun.DB, the repositories hold this
    Transactor: bunx.NewTransactor(bunDB), // instead of pg.NewTransactor(pool)
}

bunx.Open builds a *bun.DB over the kit's existing pgxpool.Pool (with pgdialect), so the dbx.Config and the pool configuration are reused; closing the pool closes the underlying handles. Open also installs the bunotel query hook: queries running through the bun query builder emit OTel spans (statement + timing) just like the pgx path, and show up in the trace waterfall and in Sentry. Raw SQL running through bunx.Conn bypasses this hook (see below).

The Transactor contract is identical

bunx.NewTransactor returns the same dbx.Transactor interface as dbx/pg: commit on nil, rollback on error or panic, a nested call joins the outer transaction (flat nesting, the outermost call commits). The only difference is the carried type: a bun.Tx travels in the context instead of a pgx.Tx, with the bunx.ContextWithTx / bunx.TxFromContext pair.

Which means your service layer does not change when you switch implementations: the PlaceOrder service on the Transactions page is identical to the letter on pgx and on bun, only the main.go wiring and the repositories' internals differ.

Repositories

Bun's query builder methods (NewSelect, NewInsert, …) take no context, so a repository resolves the query surface from the context at the top of each method. bunx.From returns the bun.Tx carried by the context (when WithinTransaction opened one), otherwise the root *bun.DB, both are bun.IDB, so the caller uses the result uniformly.

The shop's product repository with bun:

package repository

type Product struct {
    bun.BaseModel `bun:"table:products"`

    ID         int64     `bun:"id,pk,autoincrement"`
    Name       string    `bun:"name,notnull"`
    PriceCents int64     `bun:"price_cents,notnull"`
    Stock      int       `bun:"stock,notnull"`
    CreatedAt  time.Time `bun:"created_at,notnull"`
}

type ProductRepository struct {
    db *bun.DB
}

func (r *ProductRepository) ListProducts(ctx context.Context, limit int) ([]Product, error) {
    var products []Product
    err := bunx.From(ctx, r.db).NewSelect().Model(&products).
        OrderExpr("created_at DESC, id DESC").
        Limit(limit).
        Scan(ctx)
    return products, err
}

The order insert, called from the PlaceOrder transaction, runs on the bun.Tx automatically:

func (r *OrderRepository) Insert(ctx context.Context, o *Order) error {
    _, err := bunx.From(ctx, r.db).NewInsert().Model(o).Exec(ctx)
    return err
}

Raw SQL and sqlc: the Conn resolver

Because a bun.Tx embeds a *sql.Tx, raw SQL and sqlc (in database/sql mode) can join the same transaction through bunx.Conn. The shop's stock decrement: an atomic UPDATE that would be awkward to express with the builder:

func (r *ProductRepository) DecrementStock(ctx context.Context, productID int64, qty int) error {
    res, err := bunx.Conn(ctx, r.db).ExecContext(ctx,
        `UPDATE products SET stock = stock - $2 WHERE id = $1 AND stock >= $2`,
        productID, qty)
    if err != nil {
        return err
    }
    if n, _ := res.RowsAffected(); n == 0 {
        return errs.New("insufficient stock",
            errs.Code("insufficient_stock"), errs.Public("The product is out of stock."))
    }
    return nil
}

bunx.Conn deliberately returns the raw *sql.Tx / *sql.DB, not the bun wrappers. The bun wrapper reformats every query through its own placeholder syntax (?), which would break native $N placeholders, so sqlc-generated postgres SQL runs unchanged too. The trade-off is that queries running this way skip bun's query hooks (otel/logging); code that wants those uses the bun builder via From.

The "one transaction, two query styles" guarantee (bun builder + raw SQL, shared commit/rollback) is pinned down by the kit's integration tests against a real PostgreSQL: go test -tags=integration ./dbx/bunx/.

Outbox under bun

The outbox store has a bun adapter too, mirroring the same split: bun projects wire NewStore(bunDB) from github.com/gp-system/gpsystem/events/outbox/bunx, so the outbox insert joins the bun transaction opened by WithinTransaction. The generator wires this automatically in --db bun projects:

Dispatcher: outbox.NewDispatcher(outboxbunx.NewStore(bunDB)),

Constraint

Within a single project, bunx and the pgx-native executor (dbx/pg's DB) cannot share one transaction: the two abstractions check out connections through different paths and carry the transaction under different context keys. In a bun project, raw SQL goes through bunx.Conn, never through pg.NewDB(pool). Migrations are unaffected: the same cmd/migrate binary runs under both stacks.

Patterns used

  • Unit of Work / transaction in context, the same pattern as the pgx stack: Design patterns.
  • Resolver functions (From(ctx, db), Conn(ctx, db)): the same "join the context's transaction if there is one" logic, with a bun-specific return type.
Copyright © 2026