Опирается на правила: R-SQLC-ERR-1R-SQLC-ERR-4 и R-SQLC-ERR-X1R-SQLC-ERR-X3 из sqlc Style Guide → раздел 6. Ошибки и pgx.

Важно знать

  • pgx.ErrNoRows — не технический сбой, это сигнал «строки нет»; конвертировать в доменную NotFoundError, не прокидывать вверх «как есть».
  • return nil, nil при ErrNoRows — главный антипаттерн: вызывающий получает nil-агрегат без сигнала об ошибке и падает с nil-pointer глубже.
  • Ошибки БД-ограничений читаются через errors.As(err, &pgErr)pgErr.Code: "23505" — unique violation, "23503" — foreign key violation; оба превращаются в доменные ошибки.
  • Все pgx-ошибки оборачиваются с контекстом (fmt.Errorf("find order %s: %w", id, err)) и всплывают вверх — репозиторий не логирует.
  • Таймаут устанавливается через context.WithTimeout на Handler или middleware, а не через pgx-параметры напрямую.
  • panic(err) на pgx-ошибке — запрещён: ошибки persistence — значения, возвращаемые через (T, error).
  • errors.As сохраняет цепочку %wpgconn.PgError достижим сквозь несколько обёрток.

Ошибки из pgx попадают в репозиторий в двух видах: pgx.ErrNoRows (строк не нашлось) и *pgconn.PgError (сервер PostgreSQL вернул SQLSTATE). Задача репозитория — превратить их в доменные ошибки с контекстом и пробросить наверх. Ни логирования, ни panic, ни nil, nil в репозитории.

pgx.ErrNoRows → доменная NotFoundError

R-SQLC-ERR-1: когда sqlc-сгенерированный метод (:one) не находит строку, pgx возвращает pgx.ErrNoRows. Репозиторий обязан перехватить это через errors.Is и вернуть доменную ошибку с контекстом: тип сущности и её идентификатор.

// core/order/port/order_repository.go
type OrderRepository interface {
    FindByID(ctx context.Context, id uuid.UUID) (*Order, error)
}

// core/order/errors.go
type NotFoundError struct {
    ID uuid.UUID
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("order %s not found", e.ID)
}
// adapters/out/persistence/postgres_order_repository.go
func (r *PostgresOrderRepository) FindByID(ctx context.Context, id uuid.UUID) (*order.Order, error) {
    row, err := r.q.GetOrderByID(ctx, id)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, &order.NotFoundError{ID: id}
        }
        return nil, fmt.Errorf("find order %s: %w", id, err)
    }
    return toDomain(row), nil
}

errors.Is(err, pgx.ErrNoRows) — первая проверка: если строки нет, возвращаем доменную ошибку. Любая другая ошибка pgx (connection error, timeout) оборачивается с %w и идёт вверх как техническая.

На edge (chi-handler) вызывающий делает errors.As(err, &notFound) и маппит в 404:

// adapters/in/http/order_handler.go
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    id := uuid.MustParse(chi.URLParam(r, "id"))
    ord, err := h.getOrderHandler.Handle(r.Context(), id)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    render.JSON(w, r, toResponse(ord))
}
// edge/httperr/render.go (фрагмент)
var notFound *orderDom.NotFoundError
if errors.As(err, &notFound) {
    // Kind() = Domain → 404
}

Обёртка fmt.Errorf("find order %s: %w", id, err) на любом уровне не ломает errors.As — цепочка %w сохранена.

pgconn.PgError и SQLSTATE — constraint violations

R-SQLC-ERR-2: PostgreSQL сигнализирует о нарушении ограничений через *pgconn.PgError с полем Code (SQLSTATE). Два кода, которые нужно обрабатывать в репозитории:

  • "23505" — unique_violation: попытка вставить дубликат; → доменная ошибка конфликта.
  • "23503" — foreign_key_violation: ссылочная целостность нарушена; → доменная бизнес-ошибка.
// core/order/errors.go
type AlreadyExistsError struct {
    ID uuid.UUID
}

func (e *AlreadyExistsError) Error() string {
    return fmt.Sprintf("order %s already exists", e.ID)
}

type CustomerNotFoundError struct {
    CustomerID uuid.UUID
}

func (e *CustomerNotFoundError) Error() string {
    return fmt.Sprintf("customer %s not found", e.CustomerID)
}
// adapters/out/persistence/postgres_order_repository.go
func (r *PostgresOrderRepository) Save(ctx context.Context, o *order.Order) error {
    err := r.q.InsertOrder(ctx, toInsertParams(o))
    if err != nil {
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) {
            switch pgErr.Code {
            case "23505":
                return &order.AlreadyExistsError{ID: o.ID}
            case "23503":
                return &order.CustomerNotFoundError{CustomerID: o.CustomerID}
            }
        }
        return fmt.Errorf("save order %s: %w", o.ID, err)
    }
    return nil
}

errors.As(err, &pgErr) — правильный способ достать *pgconn.PgError из цепочки обёрток pgx. Прямой кастинг err.(*pgconn.PgError) сломается, если pgx обернул ошибку внутренне.

Пример с Product — нарушение уникальности по артикулу:

// adapters/out/persistence/postgres_product_repository.go
func (r *PostgresProductRepository) Save(ctx context.Context, p *product.Product) error {
    err := r.q.InsertProduct(ctx, toInsertParams(p))
    if err != nil {
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) && pgErr.Code == "23505" {
            return &product.DuplicateSkuError{SKU: p.SKU}
        }
        return fmt.Errorf("save product %s: %w", p.SKU, err)
    }
    return nil
}

Оборачивание с контекстом — fmt.Errorf с %w

R-SQLC-ERR-3: все pgx-ошибки, которые не стали доменными, оборачиваются с контекстом через fmt.Errorf("...: %w", err). Репозиторий не логирует — он только добавляет операционный контекст (что делали, какой идентификатор) и возвращает вверх.

// ХОРОШО — контекст + цепочка %w
func (r *PostgresOrderRepository) FindByCustomer(ctx context.Context, customerID uuid.UUID) ([]*order.Order, error) {
    rows, err := r.q.ListOrdersByCustomer(ctx, db.ListOrdersByCustomerParams{
        CustomerID: customerID,
        Limit:      100,
        Offset:     0,
    })
    if err != nil {
        return nil, fmt.Errorf("list orders for customer %s: %w", customerID, err)
    }
    return toOrders(rows), nil
}
// ПЛОХО — логируем и пробрасываем: двойное логирование (R-SQLC-ERR-X2)
func (r *PostgresOrderRepository) FindByCustomer(ctx context.Context, customerID uuid.UUID) ([]*order.Order, error) {
    rows, err := r.q.ListOrdersByCustomer(ctx, ...)
    if err != nil {
        slog.ErrorContext(ctx, "failed to list orders", "error", err) // ← логировать здесь — нельзя
        return nil, fmt.Errorf("list orders: %w", err)
    }
    return toOrders(rows), nil
}

Логируется один раз — на edge, в httperr.Write. Это правило из R-ERR-LOG-4 (Error Handling Style Guide).

Таймаут через context.WithTimeout

R-SQLC-ERR-4: таймаут устанавливается на Handler или middleware через context.WithTimeout, не через pgx-специфичные параметры внутри репозитория. Репозиторий принимает ctx и передаёт его в sqlc-методы — pgx сам отслеживает отмену контекста.

// core/order/handler/get_order_handler.go
func (h *GetOrderHandler) Handle(ctx context.Context, id uuid.UUID) (*order.Order, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    return h.repo.FindByID(ctx, id)
}
// adapters/out/persistence/postgres_order_repository.go
func (r *PostgresOrderRepository) FindByID(ctx context.Context, id uuid.UUID) (*order.Order, error) {
    row, err := r.q.GetOrderByID(ctx, id) // ctx несёт дедлайн из handler'а
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return nil, &order.NotFoundError{ID: id}
        }
        return nil, fmt.Errorf("find order %s: %w", id, err)
    }
    return toDomain(row), nil
}

Когда дедлайн истечёт, pgx вернёт ошибку с context.DeadlineExceeded в цепочке. На Handler это можно отличить через errors.Is(err, context.DeadlineExceeded) и вернуть таймаут-специфичную ошибку.

Пример с Customer — явный таймаут для критичного пути:

// core/customer/handler/get_customer_handler.go
func (h *GetCustomerHandler) Handle(ctx context.Context, id uuid.UUID) (*customer.Customer, error) {
    ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()

    cust, err := h.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, &customer.LookupTimeoutError{CustomerID: id}
        }
        return nil, fmt.Errorf("get customer: %w", err)
    }
    return cust, nil
}

Тестирование error-путей

R-SQLC-TEST-3 предписывает покрывать три класса ошибок в интеграционном тесте: ErrNoRows → NotFoundError, constraint-нарушение → доменная ошибка, транзакционный откат.

// adapters/out/persistence/postgres_order_repository_test.go

func TestPostgresOrderRepository_FindByID_NotFound(t *testing.T) {
    ctx := context.Background()
    tx, _ := testPool.Begin(ctx)
    t.Cleanup(func() { tx.Rollback(ctx) })

    repo := NewPostgresOrderRepository(testPool).WithTx(tx)
    _, err := repo.FindByID(ctx, uuid.New())

    var notFound *order.NotFoundError
    require.ErrorAs(t, err, &notFound)
}

func TestPostgresOrderRepository_Save_DuplicateID(t *testing.T) {
    ctx := context.Background()
    tx, _ := testPool.Begin(ctx)
    t.Cleanup(func() { tx.Rollback(ctx) })

    repo := NewPostgresOrderRepository(testPool).WithTx(tx)
    ord := &order.Order{ID: uuid.New(), CustomerID: uuid.New(), Status: order.StatusNew}

    require.NoError(t, repo.Save(ctx, ord))

    err := repo.Save(ctx, ord) // дубликат
    var alreadyExists *order.AlreadyExistsError
    require.ErrorAs(t, err, &alreadyExists)
    require.Equal(t, ord.ID, alreadyExists.ID)
}

Тест через Testcontainers — реальный PostgreSQL, реальные ограничения. Mock-реализации pgxpool.Pool не тестируют ни SQL, ни constraint-поведение (R-SQLC-TEST-X1).

Что запрещено

АнтипаттернПравилоЧто взамен
return nil, nil при pgx.ErrNoRowsR-SQLC-ERR-X1return nil, &order.NotFoundError{ID: id}
slog.ErrorContext(...) в репозитории + проброс вверхR-SQLC-ERR-X2только fmt.Errorf("...: %w", err), без логирования
panic(err) при pgx-ошибкеR-SQLC-ERR-X3return nil, fmt.Errorf("...: %w", err)
err.(*pgconn.PgError) прямой кастингR-SQLC-ERR-2errors.As(err, &pgErr) — цепочка сохраняется
Таймаут через pgx-параметры в репозиторииR-SQLC-ERR-4context.WithTimeout на Handler или middleware
fmt.Errorf("%v", err) без %wR-ERR-WHERE-X2fmt.Errorf("...: %w", err) — цепочка для errors.As

Куда дальше

  • Repository pattern в sqlc — доменный порт, PostgresOrderRepository, WithTx.
  • Транзакции в sqlc — граница TX на Handler, defer tx.Rollback, WithTx(pgx.Tx).
  • Nested-fetch в sqlc — JOIN + сборка агрегата в маппере, избегание N+1.
  • Иерархия ошибок в Go — apperr.Kind, доменные vs технические ошибки.