Опирается на правила:
R-SQLC-ERR-1…R-SQLC-ERR-4иR-SQLC-ERR-X1…R-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сохраняет цепочку%w—pgconn.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, ¬Found) и маппит в 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, ¬Found) {
// 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, ¬Found)
}
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.ErrNoRows | R-SQLC-ERR-X1 | return nil, &order.NotFoundError{ID: id} |
slog.ErrorContext(...) в репозитории + проброс вверх | R-SQLC-ERR-X2 | только fmt.Errorf("...: %w", err), без логирования |
panic(err) при pgx-ошибке | R-SQLC-ERR-X3 | return nil, fmt.Errorf("...: %w", err) |
err.(*pgconn.PgError) прямой кастинг | R-SQLC-ERR-2 | errors.As(err, &pgErr) — цепочка сохраняется |
| Таймаут через pgx-параметры в репозитории | R-SQLC-ERR-4 | context.WithTimeout на Handler или middleware |
fmt.Errorf("%v", err) без %w | R-ERR-WHERE-X2 | fmt.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 технические ошибки.