Iris is database-agnostic by design: it ships no ORM, no drivers, and no storage adapters. What it does ship is everything a database-backed application needs around the driver: dependency injection to construct and share connections, lifecycle hooks for startup checks and graceful shutdown, request contexts that cancel queries when clients disconnect, and a central error map that turns driver errors into correct HTTP responses. This chapter teaches that pattern once, shows it end to end with PostgreSQL, and tells you how to apply it to any other store.
Background
A database is a program whose entire job is storing and retrieving data reliably. Relational databases (PostgreSQL, MySQL, SQLite) organize data into tables: grids where each row is one record (one product, one user) and each column one attribute. Document databases (MongoDB) store documents instead, JSON-like records that need not share a fixed shape. You could, in principle, persist data by writing files yourself, until two requests write at the same moment, the process crashes mid-write, or you need “all products cheaper than X” without reading every file. Databases exist because they solve exactly those problems: concurrent access, crash safety and fast lookup over large data sets.
Four terms carry this chapter:
- A query is a request you send to the database (“give me the product with this ID”, “insert this row”), usually written in SQL, the standard language of relational databases.
- A connection is the channel a query travels over. Opening one is comparatively expensive, so applications keep a connection pool: a small set of open connections that requests borrow and return, instead of every request dialing the database from scratch.
- A transaction groups several statements into an all-or-nothing unit: either every step commits, or the database rolls them all back as if none had happened. Money leaving one account must arrive in another; a transaction is how the database promises there is no in-between state.
- An ORM-style layer (object-relational mapper) translates between
your language’s structs and the database’s rows so you do not
hand-write the copying in either direction. In this chapter that role
is played by
pg.Repository[T], which scans rows straight into yourProductstruct.
Everything in this chapter flows through one Go interface: a named set of method signatures that any type can satisfy. Handlers and services depend on that interface, never on a concrete driver, which is what buys the chapter’s repeated promise: the real database, an in-memory fake for tests and any future replacement are interchangeable implementations behind the same four methods. What disappears from your code is storage detail spread across handlers (connection handles, driver error checks, SQL strings next to HTTP status codes), leaving one file per database that you can swap with one line of wiring.
The Repository Seam
Handlers never talk to a database. They call a service; the service talks to storage through an interface. That interface, the repository seam, is the single point where the application meets persistence, and it is what makes the rest of this chapter mechanical: to change databases you write one new type and change one line of wiring.
The running example is a small product catalog. First the domain:
// catalog/catalog.go
package catalog
import (
"errors"
"time"
"github.com/kataras/iris/v14/rest/errors/validation"
)
// Sentinel errors: the domain speaks its own language. The mapping to
// HTTP status codes happens once, in main.go (see the Errors section).
var (
ErrNotFound = errors.New("product not found")
ErrSKUTaken = errors.New("sku already exists")
)
// Product is the stored entity. The pg struct tags describe the
// PostgreSQL columns for the store you will meet later in this chapter;
// every other store (and the JSON encoder) ignores them.
type Product struct {
ID string `pg:"type=uuid,primary,default=gen_random_uuid()" json:"id"`
CreatedAt time.Time `pg:"type=timestamp,default=clock_timestamp()" json:"created_at"`
Name string `pg:"type=varchar(255)" json:"name"`
SKU string `pg:"type=varchar(64),unique" json:"sku"`
Price int64 `pg:"type=bigint" json:"price"` // Cents.
}
// ProductInput is the request payload. ctx.BindJSON runs Validate
// automatically after decoding, so handlers never call it by hand.
type ProductInput struct {
Name string `json:"name"`
SKU string `json:"sku"`
Price int64 `json:"price"`
}
func (in ProductInput) Validate() error {
return validation.Join(
validation.String("name", in.Name).NotEmpty().Length(1, 255),
validation.String("sku", in.SKU).NotEmpty().Length(1, 64),
validation.Number("price", in.Price).Positive(),
)
}If you dislike driver tags on a domain struct, define a separate storage entity inside the store implementation and map between the two. For most applications the single struct is the right trade: one type, one JSON shape, one schema definition.
The seam itself:
// catalog/repository.go
package catalog
import "context"
// Repository is the storage seam: the service depends on this
// interface, production wires a database implementation, tests wire
// MemStore or a one-method stub. Every method takes a context.Context
// first — iris.Context implements it, so request cancellation reaches
// the database driver with zero extra code.
type Repository interface {
Create(ctx context.Context, p Product) (id string, err error) // ErrSKUTaken
List(ctx context.Context) ([]Product, error)
Get(ctx context.Context, id string) (Product, error) // ErrNotFound
Delete(ctx context.Context, id string) (bool, error)
}Two details are load-bearing. The first parameter of every method is a
plain context.Context: handlers pass their iris.Context straight
through, and when a client disconnects, the cancellation propagates into
pgx, database/sql, the MongoDB driver (any context-aware driver)
and aborts the query. (When a write must outlive the request, an audit
insert after the response for example, pass ctx.WithoutCancel()
instead: it detaches from cancellation but keeps request values.) The
second detail is that ID generation lives inside the store, not the
service: an in-memory store mints UUIDs itself, PostgreSQL uses a
gen_random_uuid() column default, and the seam hides the difference by
returning the new id.
The in-memory implementation is ordinary Go: a mutex and a map. Iris
serves each request on its own goroutine (Go’s lightweight thread),
so two requests can touch this map at the same moment; the
sync.RWMutex is the guard that prevents corruption, giving writers
exclusive access while readers share it. This store is the development
store and, later, the test suite’s fake:
// catalog/memstore.go
package catalog
import (
"context"
"sort"
"sync"
"time"
"uuid"
)
type MemStore struct {
mu sync.RWMutex
products map[string]Product
}
var _ Repository = (*MemStore)(nil)
func NewMemStore() *MemStore {
return &MemStore{products: make(map[string]Product)}
}
func (s *MemStore) Create(_ context.Context, p Product) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, existing := range s.products {
if existing.SKU == p.SKU {
return "", ErrSKUTaken
}
}
p.ID = uuid.New().String()
p.CreatedAt = time.Now().UTC()
s.products[p.ID] = p
return p.ID, nil
}
func (s *MemStore) List(_ context.Context) ([]Product, error) {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]Product, 0, len(s.products))
for _, p := range s.products {
list = append(list, p)
}
sort.Slice(list, func(i, j int) bool {
return list[i].CreatedAt.Before(list[j].CreatedAt)
})
return list, nil
}
func (s *MemStore) Get(_ context.Context, id string) (Product, error) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.products[id]
if !ok {
return Product{}, ErrNotFound
}
return p, nil
}
func (s *MemStore) Delete(_ context.Context, id string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return false, nil
}
delete(s.products, id)
return true, nil
}Business rules live in a service that depends on the seam. Here it is thin; in a real application this is where invariants, authorization checks and cross-entity logic accumulate (see Chapter 8 for the layering rationale):
// catalog/service.go
package catalog
import "context"
// Service holds the business rules. Its constructor declares what it
// needs; the iris dependency container calls it with the registered
// Repository.
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Create(ctx context.Context, in ProductInput) (string, error) {
return s.repo.Create(ctx, Product{
Name: in.Name,
SKU: in.SKU,
Price: in.Price,
})
}
func (s *Service) List(ctx context.Context) ([]Product, error) {
return s.repo.List(ctx)
}
func (s *Service) Get(ctx context.Context, id string) (Product, error) {
return s.repo.Get(ctx, id)
}
func (s *Service) Delete(ctx context.Context, id string) (bool, error) {
return s.repo.Delete(ctx, id)
}Wiring a Store Through Services
The HTTP layer stays thin: read input, call the service, hand the result
and the error to a response helper. Note that every service call receives
ctx (the iris.Context) as its context.Context:
// api/products.go
package api
import (
"github.com/kataras/iris/v14"
"shop/catalog"
)
type ProductsAPI struct {
svc *catalog.Service
}
func NewProductsAPI(svc *catalog.Service) *ProductsAPI {
return &ProductsAPI{svc: svc}
}
func (api *ProductsAPI) Routing(r iris.Router) {
r.Get("/", api.list)
r.Post("/", api.create)
r.Get("/{id:uuid}", api.get)
r.Delete("/{id:uuid}", api.delete)
}
func (api *ProductsAPI) list(ctx iris.Context) {
products, err := api.svc.List(ctx)
ctx.List(products, err)
}
func (api *ProductsAPI) create(ctx iris.Context) {
in, ok := ctx.BindJSON[catalog.ProductInput]()
if !ok {
return // 400 already written: malformed JSON or failed Validate.
}
id, err := api.svc.Create(ctx, in)
ctx.Created(id, err) // 201 with {"id": "..."}.
}
func (api *ProductsAPI) get(ctx iris.Context) {
p, err := api.svc.Get(ctx, ctx.Params().GetString("id"))
ctx.OK(p, err) // ErrNotFound is mapped to 404 centrally.
}
func (api *ProductsAPI) delete(ctx iris.Context) {
deleted, err := api.svc.Delete(ctx, ctx.Params().GetString("id"))
ctx.NoContent(deleted, err) // 204 deleted, 304 not found.
}These four handlers exist because a catalog.Service sits between them
and the store, and that service owns the SKU invariant. When there is no
such rule and the handlers would only forward to the repository, do not
write them: mvc.UseRepository(repo) registers the same resource from
the repository alone, with mvc.Guard for the write routes. See
Chapter 24.
Everything assembles in the builder. The repository arrives as a
parameter so main can pass the real store while tests pass fakes:
// main.go
package main
import (
"github.com/kataras/iris/v14"
"github.com/kataras/iris/v14/rest/errors"
"shop/api"
"shop/catalog"
)
func newApp(repo catalog.Repository) *iris.Application {
return iris.NewBuilder(iris.DefaultOptions).
Prefix("/api").
AllowOrigin("none").
Compression(true).
LogRequests(false).
Health(true, "development", "shop").
Errors(errors.NewOptions().
MapErrors(errors.NotFound, catalog.ErrNotFound).
MapErrors(errors.AlreadyExists, catalog.ErrSKUTaken)).
Services(repo, catalog.NewService).
RouterMiddlewares().
Middlewares().
API("/products", api.NewProductsAPI).
Build()
}
func main() {
app := newApp(catalog.NewMemStore())
app.Listen(":8080")
}Services does more than store values for injection: it routes them by
shape, and two of those rules exist precisely for database stores:
- A registered value (or constructor output) with a
Close()orClose() errormethod becomes a shutdown closer automatically: it runs when the application shuts down gracefully, after in-flight requests drain. No manualdefer db.Close()bookkeeping. - A service implementing
iris.Initializer(Init(ctx context.Context) error) runs exactly once insideBuild(), after dependency injection and before the server starts. This is the place for schema creation, a connectivity ping or cache warm-up; a returned error aborts startup instead of failing on the first request. It is the startup counterpart ofDeferrables.
The next section uses both.
PostgreSQL with kataras/pg
Two more terms before the code. A database schema is the set of
table definitions (names, column types, rules) that must exist before
the first query can run. A constraint is such a rule enforced by the
database itself: the unique tag on SKU means the database refuses a
second row with the same SKU, no matter which program inserts it, and
reports the attempt as a duplicate-key error.
For PostgreSQL, github.com/kataras/pg
is a separate module by the Iris author, the same library Iris’ own
auth/identity sub-package uses internally. It sits on top of the
pgx pool and derives the schema from the
pg struct tags you saw on Product: pg.NewSchema() registers
entities, DB.CreateSchema issues the corresponding
CREATE TABLE IF NOT EXISTS statements, and the generic
pg.Repository[T] scans rows into T without hand-written Scan calls.
go get github.com/kataras/pg@latestThe store implements the same Repository seam. It also implements
Init and Close, so registering it wires schema creation into
Build() and pool shutdown into graceful termination:
// catalog/pgstore.go
package catalog
import (
"context"
"errors"
"github.com/kataras/pg"
)
type PostgresStore struct {
products *pg.Repository[Product]
}
var _ Repository = (*PostgresStore)(nil)
func NewPostgresStore(db *pg.DB) *PostgresStore {
return &PostgresStore{products: pg.NewRepository[Product](db)}
}
// Init implements iris.Initializer: it runs once inside Build(), after
// injection, and a non-nil error aborts startup.
func (s *PostgresStore) Init(ctx context.Context) error {
return s.products.DB().CreateSchema(ctx)
}
// Close is picked up by Services automatically and runs on graceful
// shutdown, closing the underlying pgx pool.
func (s *PostgresStore) Close() {
s.products.DB().Close()
}
func (s *PostgresStore) Create(ctx context.Context, p Product) (string, error) {
var id string
if err := s.products.InsertSingle(ctx, p, &id); err != nil {
if _, ok := pg.IsErrDuplicate(err); ok {
return "", ErrSKUTaken // The unique "sku" constraint.
}
return "", err
}
return id, nil
}
func (s *PostgresStore) List(ctx context.Context) ([]Product, error) {
return s.products.Select(ctx,
`SELECT * FROM products ORDER BY created_at`)
}
func (s *PostgresStore) Get(ctx context.Context, id string) (Product, error) {
p, err := s.products.SelectByID(ctx, id)
if err != nil {
if errors.Is(err, pg.ErrNoRows) {
return Product{}, ErrNotFound
}
return Product{}, err
}
return p, nil
}
func (s *PostgresStore) Delete(ctx context.Context, id string) (bool, error) {
return s.products.DeleteByID(ctx, id)
}Notice the error translation at the boundary: pg.ErrNoRows becomes the
domain’s ErrNotFound and a duplicate-key violation (detected with
pg.IsErrDuplicate) becomes ErrSKUTaken. The service and handlers
never see driver errors. The central map in newApp already turns
those domain errors into 404 and 409.
Swapping the database is now the promised one-line change in main:
// main.go (PostgreSQL variant)
package main
import (
"context"
"log"
"os"
"github.com/kataras/pg"
// Optional safety net: maps any pg driver error that escapes the
// store untranslated (pg.ErrNoRows -> 404, duplicate key -> 409,
// invalid input syntax -> 400). See the error mapping section.
_ "github.com/kataras/iris/v14/rest/errors/pgerrors"
"shop/catalog"
)
func main() {
schema := pg.NewSchema()
schema.MustRegister("products", catalog.Product{})
db, err := pg.Open(context.Background(), schema,
os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
app := newApp(catalog.NewPostgresStore(db))
app.Listen(":8080")
}newApp is unchanged. A working DATABASE_URL looks like:
DATABASE_URL="postgres://postgres:secret@localhost:5432/shop?sslmode=disable"Pool sizing travels in the same connection string through the standard
pgx parameters (pool_max_conns, pool_min_conns,
pool_max_conn_lifetime, pool_max_conn_idle_time,
pool_health_check_period), so operations can tune the pool per
environment without a code change. Never hard-code the connection
string: it is a credential, and the environment (or a secret manager
feeding it) is its only home (Chapter 11).
Beyond what the store above uses, pg.Repository[T] covers the rest of
day-to-day SQL with the same shape (Insert, Update,
UpdateOnlyColumns, Upsert, Exists, SelectSingle and raw
Query/QueryRow escapes) plus InTransaction, which the
transactions section below builds on.
The Standard Library: database/sql
If you prefer to stay on the standard library (or you target MySQL,
SQLite or SQL Server), the seam pattern is identical; you just write the
scanning yourself. database/sql is stdlib; only the driver you register
(pgx’s stdlib adapter, go-sql-driver/mysql, modernc.org/sqlite, …)
is a third-party choice.
// catalog/sqlstore.go
package catalog
import (
"context"
"database/sql"
"errors"
)
type SQLStore struct {
db *sql.DB
}
func NewSQLStore(db *sql.DB) *SQLStore {
return &SQLStore{db: db}
}
func (s *SQLStore) Get(ctx context.Context, id string) (Product, error) {
var p Product
err := s.db.QueryRowContext(ctx,
`SELECT id, created_at, name, sku, price
FROM products WHERE id = $1`, id).
Scan(&p.ID, &p.CreatedAt, &p.Name, &p.SKU, &p.Price)
if errors.Is(err, sql.ErrNoRows) {
return Product{}, ErrNotFound
}
return p, err
}The remaining methods follow the same shape: ExecContext for writes,
QueryContext plus a scan loop for lists, always parameterized queries
($1, ?), never string concatenation, and always the ...Context
variants so request cancellation reaches the database.
The parameterization rule guards against SQL injection, the classic
attack in which user input pasted into a query string becomes part of
the query itself: a “name” like '; DROP TABLE products;-- rewrites
your statement. Placeholders send the query and its values to the
database separately, so input can never change the query’s structure.
*sql.DB is itself a connection pool. Configure it once, right after
sql.Open, and register the store the same way as before (give it
Close and, if you want a startup ping, an Init that calls
PingContext):
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)The defaults (unlimited open connections, two idle) are rarely what a production service wants, but the right numbers depend on your database and workload. Measure before you tune.
One convenience specific to database/sql: the central error map
already knows sql.ErrNoRows and renders it as 404 by default, so even
an untranslated leak produces a correct response instead of a 500.
MongoDB, Redis and ORMs
The seam does not care what is behind it, so third-party stores follow the same recipe. Consult each library’s own documentation for its API; this book does not duplicate it.
MongoDB. Implement Repository with the official Go driver
(go.mongodb.org/mongo-driver), holding a collection handle in the
store. Pass the request context into every driver call and translate the
driver’s “no documents” sentinel into ErrNotFound at the store
boundary, exactly as the PostgreSQL store translates pg.ErrNoRows.
Redis. Redis is usually not the repository itself but a layer beside
it: caching, rate limiting, session-like state. Keep the client
(github.com/redis/go-redis/v9 is the common choice) registered in
Services so its lifecycle is managed, and see
Chapter 14 for the cache-aside pattern that
wraps a Repository without the service ever knowing.
ORMs and generators. GORM, ent and sqlc all work with Iris for the
same reason everything else does: they live entirely inside your store
implementation. Map the library’s record-not-found error into your
domain sentinel at the boundary, keep models out of your handlers, and
register the handle through Services. If an ORM error can escape to a
handler, add it to the central map with MapErrors alongside your
domain errors.
Column Types That Survive JSON
A model crosses two boundaries with different vocabularies. SQL has
NULL; JSON does not have NaN. rest/types holds the small scalar
types that sit on both sides of that gap, each implementing
database/sql’s Scanner and Valuer as well as the JSON encoders, so
one field serves the column and the wire.
Two of them are worth naming here because they solve failures that only show up in production data.
types.Number is a float64 with a JSON null. encoding/json
refuses to encode NaN or an infinity, because JSON has no literal for
them, and a computed average over an empty set produces one often enough
that a single row takes the whole document down with it. Number
marshals all three as null, and a JSON null unmarshals back to NaN:
type Reading struct {
Sensor string `json:"sensor"`
Average types.Number `json:"average"` // null when there is nothing to average.
}The same rule holds on the database side: a NULL column scans to NaN
and a NaN is written as NULL, so a nullable float8 needs no
sql.NullFloat64 wrapper. The zero value is the number zero and not null;
types.NullNumber() returns the null one and IsNull() reports it.
types.Weekday is a time.Weekday that travels as a lowercase name:
type Shift struct {
Day types.Weekday `json:"day"` // "monday", not 1.
}A client reading a schedule gets a word it can print instead of a number
whose base it has to guess, and the JSON stays readable in a log. The
column is text. types.ParseWeekday accepts any case and the three-letter
form ("Monday", "monday", "MON", "mon"), the seven days are
exported as constants, and the two conversions are types.WeekdayOf going
in and d.Std() coming out. The numbering is time.Weekday’s, Sunday
being zero, which means the zero value marshals as "sunday" rather than
as an error: a schedule struct nobody filled in claims Sunday, so set the
field deliberately.
Transactions Below the Handler
Transactions belong in the store (or, for multi-repository coordination, the service), never in handlers. A handler that begins a transaction couples HTTP concerns to storage concerns, risks holding a database transaction open across response rendering and slow clients, and bypasses the guarantee that every unit of work is atomic no matter who calls it. Express each atomic unit as one method on the layer that owns it, and let the error it returns flow through the usual pipeline.
With kataras/pg, InTransaction wraps a function in
begin/commit/rollback: return nil to commit, return an error to roll
back. The callback receives a transactional repository, so the body
reads exactly like non-transactional code:
// Reprice atomically loads a product and writes the new price.
func (s *PostgresStore) Reprice(ctx context.Context, id string,
price int64) error {
return s.products.InTransaction(ctx,
func(tx *pg.Repository[Product]) error {
p, err := tx.SelectByID(ctx, id)
if err != nil {
if errors.Is(err, pg.ErrNoRows) {
return ErrNotFound // Rolls back, maps to 404.
}
return err
}
p.Price = price
_, err = tx.Update(ctx, p)
return err // nil commits; non-nil rolls back.
})
}InTransaction is re-entrant: when the repository is already inside a
transaction, the callback runs directly on the current one instead of
opening a nested transaction. That makes store methods composable: a
service can call Reprice on its own, or inside a larger
InTransaction block that spans several operations, and atomicity
follows the outermost boundary.
The same discipline with database/sql is a few more lines, but the
shape is identical: one store method, one atomic unit:
func (s *SQLStore) Reprice(ctx context.Context, id string,
price int64) (err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
return
}
err = tx.Commit()
}()
// All statements go through tx, with the request context.
_, err = tx.ExecContext(ctx,
`UPDATE products SET price = $1 WHERE id = $2`, price, id)
return err
}Mapping Database Errors to HTTP
Everything storage-related converges on the central error map from Chapter 9. The strategy has three rings:
Ring 1: domain errors, mapped once. Stores translate driver errors
into domain sentinels (ErrNotFound, ErrSKUTaken), and the builder’s
Errors step maps those to canonical codes for every handler at once:
Errors(errors.NewOptions().
MapErrors(errors.NotFound, catalog.ErrNotFound).
MapErrors(errors.AlreadyExists, catalog.ErrSKUTaken)).ctx.OK, ctx.Created and ctx.NoContent all hand a non-nil error
to this pipeline before rendering anything, so the handlers you wrote
earlier get correct 404 and 409 responses without a single status-code
check.
Ring 2: driver defaults. errors.NewOptions() starts from a
default map that already includes database/sql.ErrNoRows → 404. For
kataras/pg users the equivalent is opt-in, one blank import:
import _ "github.com/kataras/iris/v14/rest/errors/pgerrors"It registers a resolver that maps pg.ErrNoRows to 404, duplicate-key
violations to 409, and both PostgreSQL “invalid input syntax” and
“operator does not exist” errors to 400 with the SQLSTATE noise trimmed. Duplicate-key details (table, column,
constraint names, the offending value) are logged server-side but never
forwarded to the client: driver errors leak schema information, which
is exactly why this mapping exists. The import is deliberately separate
so applications that do not use PostgreSQL keep the driver out of their
dependency graph.
Ring 3: call-site detail. When a handler or service needs to enrich an error before it enters the pipeline, wrap it at the call site; the mapping still applies to the underlying error:
if err := api.svc.Archive(ctx, id); err != nil {
errors.HandleErrorf(ctx, err, "archive product %s", id)
return
}For transformations that need request data (attaching the
authenticated user to a constraint violation, for instance), register a
context-aware mapper with MapErrorFuncCtx on the error code map;
Chapter 9 covers it in depth.
The result is a strict layering: drivers speak driver errors, stores
speak domain errors, handlers speak (value, err), and exactly one
place in the program knows that “product not found” is a 404.
Summary
- Iris integrates with any database through a pattern, not adapters: handlers call a service, the service depends on a repository interface, and each database is one implementation of that seam.
- Every repository method takes
context.Contextfirst.iris.Contextimplements it, so client disconnects cancel in-flight queries; usectx.WithoutCancel()for writes that must outlive the request. - Register stores through the builder’s
Servicesstep: aClosemethod auto-registers graceful shutdown, andiris.Initializer’sInitruns schema creation or a connectivity ping insideBuild(). - For PostgreSQL,
github.com/kataras/pgpairs naturally with Iris: schema from struct tags, genericpg.Repository[T], re-entrantInTransaction, and therest/errors/pgerrorsimport for automatic 404/409/400 mapping of driver errors. database/sqlfollows the identical seam with hand-written scanning; itsErrNoRowsmaps to 404 by default. MongoDB, Redis and ORMs slot in the same way: translate their sentinels at the store boundary.- Transactions live in store or service methods as single atomic units, never in handlers; errors they return flow through the same central map as everything else.
Further Reading
- Accessing relational databases. The
official Go guide to
database/sql: connections, pooling, queries and transactions. - Tutorial: Accessing a relational database: a hands-on Go walkthrough from empty project to working queries.
- kataras/pg package documentation: the full API of the PostgreSQL layer used in this chapter.
- pgx package documentation: the underlying PostgreSQL driver and pool, including the connection string parameters.
- OWASP SQL Injection Prevention Cheat Sheet: why parameterized queries are non-negotiable, with examples.