Chapter 13

Database Integration

Iris ships no ORM and no drivers. The repository seam, injected connections, kataras/pg, transactions, and mapping driver errors to status codes.

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 your Product struct.

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 repository seam The repository interface is the seam where the application meets persistence: services speak domain sentinels, only a store knows a driver error, and one error map turns those sentinels into status codes. HTTP api.ProductsAPI ctx.BindJSON[T] runs Validate, then ctx.OK / ctx.Created / ctx.NoContent Service catalog.Service business rules. No SQL, no driver types, no status codes. catalog.Repository Create(ctx, p), List(ctx), Get(ctx, id), Delete(ctx, id) every method takes context.Context first. This is the test seam. MemStore sync.RWMutex and a map mints its own UUIDs PostgresStore pg.Repository[Product] Init, Close, InTransaction SQLStore database/sql hand-written Scan PostgreSQL, MySQL, SQLite, MongoDB any context-aware driver ON THE WAY BACK A store is the only layer that knows a driver error, and it translates on the way out: pg.ErrNoRows and sql.ErrNoRows become ErrNotFound, pg.IsErrDuplicate becomes ErrSKUTaken. Above the seam there are domain sentinels and nothing else. One map turns those sentinels into status codes: Errors(errors.NewOptions().MapErrors(NotFound, ErrNotFound).MapErrors(AlreadyExists, ErrSKUTaken)) gives 404 and 409. sql.ErrNoRows is already 404; importing rest/errors/pgerrors adds the pg equivalents. Anything unmapped is a logged 500, and the schema never reaches the wire.
The repository interface is the seam where the application meets persistence: services speak domain sentinels, only a store knows a driver error, and one error map turns those sentinels into status codes.

The running example is a small product catalog. First the domain:

Go
// 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:

Go
// 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:

Go
// 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):

Go
// 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:

Go
// 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:

Go
// 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() or Close() error method becomes a shutdown closer automatically: it runs when the application shuts down gracefully, after in-flight requests drain. No manual defer db.Close() bookkeeping.
  • A service implementing iris.Initializer (Init(ctx context.Context) error) runs exactly once inside Build(), 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 of Deferrables.

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.

Shell
go get github.com/kataras/pg@latest

The 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:

Go
// 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:

Go
// 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:

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

Go
// 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):

Go
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:

Go
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:

Go
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:

Go
// 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:

Go
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:

Go
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:

Go
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:

Go
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.Context first. iris.Context implements it, so client disconnects cancel in-flight queries; use ctx.WithoutCancel() for writes that must outlive the request.
  • Register stores through the builder’s Services step: a Close method auto-registers graceful shutdown, and iris.Initializer’s Init runs schema creation or a connectivity ping inside Build().
  • For PostgreSQL, github.com/kataras/pg pairs naturally with Iris: schema from struct tags, generic pg.Repository[T], re-entrant InTransaction, and the rest/errors/pgerrors import for automatic 404/409/400 mapping of driver errors.
  • database/sql follows the identical seam with hand-written scanning; its ErrNoRows maps 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

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.