Skip to content
Spice Framework on GitHub

SQL data access and transactions

frameworkMaturity: previewSource: spice@c0641b3Exact reviewed source

Spice keeps database/sql visible. Repositories depend on data.Executor, which is implemented by both *sql.DB and *sql.Tx, while an instance-owned data.Manager controls each transaction:

manager, err := data.NewManager(db, transactionObserver)
if err != nil {
return err
}
definition := data.Definition{
ID: "orders.PlaceOrder",
Module: "example.com/shop/orders",
Isolation: sql.LevelSerializable,
}
err = manager.Within(ctx, definition, func(ctx context.Context, queries data.Executor) error {
_, err := queries.ExecContext(ctx, "INSERT INTO orders (id) VALUES (?)", orderID)
return err
})

The database and its pool settings remain application-owned. Constructing a manager does not open a connection, change pool settings, start a goroutine, or install global state.

The manager owns commit and rollback; its callback receives no transaction control methods. A callback error causes rollback and any rollback failure is joined with the original error. A panic also causes rollback and synchronous observation before the original panic value is re-raised. Commit occurs only after a nil callback result. Context cancellation is handled by database/sql and the selected driver.

Definitions require stable boundary and module identities. Generated @data.Transactional typed HTTP adapters supply these from immutable application/module IR. Observers receive the same bounded metadata, elapsed duration, error, and panic state. They may enrich the callback context and finish in reverse nesting order; they must not panic or block indefinitely.

Drivers, migrations, generated repositories, retry policy, and OpenTelemetry transaction adapters are separate opt-in slices. The core transaction package uses only the standard library and performs no implicit retry: repeating a transaction is safe only when application semantics make the entire callback retryable.

Generated HTTP transaction boundaries

Annotate an exported typed @Get or @Post method and make the transaction dependency explicit as parameter 1:

// @Post("/orders")
// @data.Transactional(isolation="serializable")
func (*OrdersController) Create(
ctx context.Context,
queries data.Executor,
request CreateOrderRequest,
) (CreateOrderResponse, error) {
// Every repository call receives queries explicitly.
return createOrder(ctx, queries, request)
}

The application graph must contain exactly one @Bean whose exact output type is *data.Manager. Spice rejects an executor parameter without the annotation, an annotated route without the executor parameter, raw net/http handlers, and missing or ambiguous manager providers. Generation emits a direct Manager.Within call; it does not use reflection or a service locator. Within also exposes the exact transaction-owned executor through data.ExecutorFromContext for generated interface-bound service decorators; typed HTTP routes continue to receive the same executor directly.

isolation is optional and defaults to default. Accepted values are default, read-uncommitted, read-committed, write-committed, repeatable-read, snapshot, serializable, and linearizable. Optional readOnly is Boolean. These values are passed to database/sql; whether a driver supports a particular isolation mode or enforces read-only semantics remains driver-specific.

Request binding and validation run before the transaction starts. The generated adapter commits only after the route returns a nil error. Route errors, panics, and request-context cancellation follow data.Manager rollback semantics. Response encoding occurs after commit, so an HTTP write failure never causes a committed transaction to be retried or misreported as rolled back.

Typed repository queries

data/repository provides immutable, reflection-free query definitions for application-owned and generated repositories:

findOrder, err := repository.NewQuery(repository.QuerySpec[Order]{
ID: "orders.find",
Module: "example.com/shop/orders",
Statement: "SELECT id, quantity FROM orders WHERE id = $1",
MaxRows: 1,
Decode: func(row repository.Scanner) (Order, error) {
var order Order
err := row.Scan(&order.ID, &order.Quantity)
return order, err
},
})
if err != nil {
return err
}
order, err := findOrder.One(ctx, queries, orderID)

One requires exactly one result, Optional accepts zero or one, and List preserves driver order while enforcing an explicit in-memory bound. Their sentinel errors work with errors.Is. Every path closes rows and reports iteration and close failures. Failures identify the stable query ID without including SQL text or argument values.

SQL, placeholders, indexes, ordering, and database-side result limits remain dialect/application concerns. MaxRows is a final memory-safety boundary, not a replacement for a LIMIT clause. The same query can run against a pool or the transaction-owned executor supplied to Manager.Within.