Transactions
The hard requirement behind dbx: a service must be able to call multiple repositories inside one database transaction, without any repository knowing about transactions. This page covers the pattern in depth: the pgx-specific mechanics live on the pgx page, the bun variant on the bun page.
The three pieces
// 1. The contract the service holds (dbx package, driver-independent):
type Transactor interface {
WithinTransaction(ctx context.Context, fn func(ctx context.Context) error) error
}
// 2. The query surface the repositories hold (the pgx variant here):
type DBTX interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// 3. The executor connecting the two: every call first looks for a
// transaction in the context; when there is none, it runs on the pool.
db := pg.NewDB(pool)
tx := pg.NewTransactor(pool)
WithinTransaction opens a transaction, injects it into the context, and calls fn with the modified context. Inside fn, every repository call passes that same context along. pg.DB (or, on bun, the bunx.From/bunx.Conn resolvers) fishes the transaction out of it and runs the query on it. Repository signatures do not change; only the executor knows the transaction exists.
The PlaceOrder service
The best example of the pattern is the shop's order placement. Three writes must happen atomically: inserting the order, decrementing the stock, and the outbox row for the orderPlaced event. If any of them fails, none may survive: an order must not exist without its stock deduction, and no confirmation email may go out for an order that was never recorded.
The full method lives in the module's shared core/ package (internal/modules/shop/core/), called from the api surface's service seam:
type OrderService struct {
orders *repository.OrderRepository
products *repository.ProductRepository
tx dbx.Transactor
dispatcher events.Dispatcher
}
func (s *OrderService) PlaceOrder(ctx context.Context, in PlaceOrderInput) (*Order, error) {
var order *Order
err := s.tx.WithinTransaction(ctx, func(ctx context.Context) error {
var err error
order, err = s.orders.Insert(ctx, in) // 1. order
if err != nil {
return err
}
if err := s.products.DecrementStock(ctx, in.ProductID, in.Qty); err != nil {
return err // 2. stock
}
return s.dispatcher.Dispatch(ctx, shopevents.OrderPlaced{ // 3. outbox row
OrderID: order.ID,
Email: in.Email,
})
})
if err != nil {
return nil, err
}
return order, nil
}
(shopevents is the alias of the module's own events package: internal/modules/shop/events, generated by add event.) All three calls resolve the same pgx.Tx from the context:
orders.Insertandproducts.DecrementStockthroughpg.DB;- the
dispatcheris anoutbox.NewDispatcherwhose store also writes throughpg.DB. The event lands in theoutbox_eventstable in the same transaction as the business writes. On commit they become visible together; on rollback the event never existed. That is the essence of the transactional outbox.
If DecrementStock returns an insufficient_stock error, the already-executed Insert rolls back with it, and the event is never delivered. The error maps to a problem+json response through the errs chain.
Semantics
The guarantees, pinned down by the kit's integration tests (go test -tags=integration ./dbx/pg/ and ./dbx/bunx/, against a real PostgreSQL):
- Commit only when
fnreturnsnil. Any error rolls back, and propagates to the caller unchanged. - A panic rolls back and is re-raised. A handler panic cannot leave a transaction dangling.
- Rollback survives a cancelled context: the cleanup runs with
context.WithoutCancel, so a request timeout does not prevent an orderly rollback. - A nested
WithinTransactionjoins the outer transaction: flat nesting, no savepoints. The outermost call always owns commit/rollback: if the inner block succeeds but the outer one fails afterwards, the inner writes roll back too.
The practical consequence of flat nesting: a service method can freely call another service method that itself uses WithinTransaction, the inner call becomes part of the outer transaction without noticing. There is no "are we already in a transaction?" logic to write.
WithinTransaction.Why the context, and not a parameter?
The classic alternative is passing the transaction by hand: every repository method takes a tx parameter, or exists in two variants (Insert / InsertTx). That hurts in three places:
- Every signature gets infected.
ListProducts(ctx, params)becomesListProducts(ctx, tx, params), even for calls that will never have a transaction. - The layers collapse into each other. The service would have to import and pass around a driver type (
pgx.Tx), even though the service layer's job is business logic, not connection management. - Cross-cutting code is left out. The outbox dispatcher is kit code: it cannot know about your repositories' transaction parameter. From the context, though, it reads the transaction exactly like your own repos do.
Context carrying is precisely the kind of implicit state context.Context is for: request-scoped, travelling down the call chain, cross-cutting. The Go standard library carries traces, deadlines and the cancel signal the same way.
Implementation independence
Not a single line of the service code above is pgx-specific: it depends on dbx.Transactor and your own repository interfaces. In a --db bun project the same method compiles and runs unchanged. main.go injects bunx.NewTransactor(bunDB), and the repositories' internals use the bun resolvers. Drawing the transaction boundary belongs to the service layer; what carries the transaction underneath is a wiring detail.
One transactor per connection
A Transactor is scoped to a single connection: WithinTransaction begins a transaction on the pool it wraps and injects it into the context under that implementation's own key. Repositories bound to a named connection receive a different Transactor (<Name>Transactor on Dependencies) than the default one: calling the default Transactor.WithinTransaction around a call to a named connection's repository does not put that call in the same transaction; it runs on its own connection, uncoordinated. This is also why a connection's connector (pgx or bun) cannot vary per call: a pgx.Tx and a bun.Tx are two different types carried under two different context keys, so mixing them on one connection would defeat the whole point of Transactor. If two repositories must commit or roll back together, they must sit behind the same connection's Transactor.
Patterns used
This page is the full write-up of the Design patterns catalog's Unit of Work / transaction in context entry: the dbx.Transactor interface and the panic-safe rollback logic, with code, live there.