В примере есть два пакета: пользователи и каталог. Основная реализация работает через GORM, а следом показан тот же перевод ошибок через стандартный database/sql.

Правило не зависит от DDD: sentinel-ошибку объявляет пакет или слой, который определяет её смысл для вызывающего кода. Это может быть каталог, пакет пользователей, интерфейс репозитория или инфраструктурный пакет. Расположение каталогов вторично; важен публичный контракт.

Содержание

  1. Как общий ErrNotFound создаёт ошибку
  2. Как на самом деле работает errors.Is
  3. Где должна находиться sentinel-ошибка
  4. Почему разным причинам нужны разные значения
  5. Перевод ошибки на границе репозитория
  6. Пример с GORM
  7. Тот же подход без ORM
  8. Где переводить ошибку в HTTP-код
  9. Когда sentinel недостаточно
  10. Как тестировать контракт ошибок
  11. Практическое правило
  12. Материалы

Как общий ErrNotFound создаёт ошибку

Рассмотрим endpoint просмотра товара. Перед выдачей ответа сервис загружает пользователя, от имени которого пришёл запрос, и сам товар из каталога. Не найтись может любой из них, и отвечать на это нужно по-разному: без пользователя endpoint отдаёт 403 Forbidden, без товара 404 Not Found.

Причины разные, хотя низкоуровневое описание у них одинаковое: строка не найдена в базе данных.

Общая ошибка выглядит удобной

Разработчик создаёт пакет с переиспользуемыми ошибками:

// internal/commonerrors/errors.go
package commonerrors

import "errors"

var ErrNotFound = errors.New("not found")

Пакеты пользователей и каталога экспортируют ошибки с понятными именами, но присваивают им одно общее значение:

// internal/users/errors.go
package users

import "explaining_errors/internal/commonerrors"

var ErrUserNotFound = commonerrors.ErrNotFound
// internal/catalog/errors.go
package catalog

import "explaining_errors/internal/commonerrors"

var ErrProductNotFound = commonerrors.ErrNotFound

На уровне имён всё выглядит аккуратно. Но обе переменные указывают на один и тот же sentinel. Для errors.Is они неразличимы.

Ошибка проходит через несколько слоёв

Сервис добавляет контекст и возвращает ошибку выше:

func (s *Service) ViewProduct(
	ctx context.Context,
	viewerID int64,
	productID int64,
) (Product, error) {
	if _, err := s.users.Get(ctx, viewerID); err != nil {
		return Product{}, fmt.Errorf("load viewer %d: %w", viewerID, err)
	}

	product, err := s.catalog.Get(ctx, productID)
	if err != nil {
		return Product{}, fmt.Errorf("load product %d: %w", productID, err)
	}

	return product, nil
}

HTTP-слой пытается выбрать ответ по причине ошибки:

func writeProductError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, catalog.ErrProductNotFound):
		http.Error(w, "product not found", http.StatusNotFound)
	case errors.Is(err, users.ErrUserNotFound):
		http.Error(w, "access denied", http.StatusForbidden)
	default:
		http.Error(w, "internal error", http.StatusInternalServerError)
	}
}

Теперь пользователь 42 отсутствует в базе. Репозиторий пользователей возвращает users.ErrUserNotFound, сервис оборачивает её через %w, а HTTP-слой выполняет первую проверку:

errors.Is(err, catalog.ErrProductNotFound)

Результат равен true, потому что catalog.ErrProductNotFound и users.ErrUserNotFound содержат одно значение commonerrors.ErrNotFound. Проверка каталога стоит первой, поэтому endpoint отвечает 404 product not found. До загрузки товара выполнение даже не дошло.

Если поменять проверки местами, исчезнет только этот конкретный симптом. Тогда отсутствие товара начнёт превращаться в 403 access denied. Порядок условий не может восстановить семантику, которую потеряли при создании общей ошибки.

Последствия не ограничиваются HTTP

Неверная классификация влияет не только на статус ответа:

  • воркер может прекратить повторы для временной ошибки;
  • gRPC-метод может вернуть неправильный status code;
  • метрика запишет сбой не в ту категорию;
  • аудит зафиксирует отсутствие ресурса вместо ошибки доступа;
  • бизнес-логика выполнит компенсационное действие не для той сущности.

Как на самом деле работает errors.Is

Фраза «errors.Is сравнивает указатели» описывает только частный случай и может запутать. Согласно документации errors.Is, функция проходит по дереву обёрнутых ошибок. Значение считается совпавшим, если оно равно target или реализует подходящий метод Is(error) bool.

Два отдельных вызова errors.New создают разные значения, даже если текст совпадает:

a := errors.New("not found")
b := errors.New("not found")

fmt.Println(errors.Is(a, b)) // false

Совпадение текста не участвует в сравнении. Но если два пакета переиспользуют один sentinel, обе проверки закономерно срабатывают:

shared := errors.New("not found")
userErr := fmt.Errorf("load user: %w", shared)

fmt.Println(errors.Is(userErr, shared)) // true

Оборачивание через %w сохраняет ошибку в цепочке. %v оставляет только текст, поэтому errors.Is больше не сможет найти исходное значение. Механика wrapping подробно разобрана в официальной статье Working with Errors in Go 1.13.

Sentinel принадлежит семантической границе

Проблема не в общем пакете сама по себе и не в отсутствии DDD. Проблема возникает, когда одно значение начинает обозначать разные факты, на которые вызывающий код должен реагировать по-разному.

Отсюда правило: ошибку объявляет тот пакет, чей контракт разбирает вызывающий код. Владелец не тот, кто первым получил техническую ошибку, а тот, кто отвечает за различие, ради которого эту ошибку вообще проверяют.

Правило применимо к любой организации приложения:

  • в проекте с доменами ошибкой владеет доменный пакет;
  • если код сгруппирован по возможностям продукта, ошибкой владеет соответствующий пакет, например catalog или checkout;
  • в слоистой архитектуре ошибку может объявлять порт репозитория.

В любом из этих вариантов ошибка входит в публичный API пакета.

Общие sentinel-ошибки допустимы, если их общий смысл намеренный. Стандартная библиотека использует sql.ErrNoRows для результата запроса без строк и fs.ErrNotExist для отсутствующего файла или каталога. Эти ошибки описывают контракт соответствующей абстракции, а не конкретный товар или пользователя.

Общий ErrNotFound также уместен, если публичный контракт сервиса намеренно не различает ресурсы и все вызывающие реагируют одинаково. Не нужно создавать несколько значений только ради структуры проекта. Разделение требуется там, где различие меняет поведение программы.

Внутри инфраструктурного слоя также может существовать общий storage.ErrNotFound. Но до выхода из репозитория его следует перевести в ошибку, смысл которой понимает приложение.

Разные причины должны иметь разные значения

Пакет пользователей объявляет собственную ошибку:

// internal/users/errors.go
package users

import "errors"

var ErrUserNotFound = errors.New("user not found")

Каталог делает то же самое:

// internal/catalog/errors.go
package catalog

import "errors"

var ErrProductNotFound = errors.New("product not found")

Теперь цепочки различаются независимо от текста и числа обёрток:

productErr := fmt.Errorf("load product 100: %w", catalog.ErrProductNotFound)

fmt.Println(errors.Is(productErr, catalog.ErrProductNotFound)) // true
fmt.Println(errors.Is(productErr, users.ErrUserNotFound))      // false

Это небольшое повторение конструкции errors.New, но не дублирование смысла. Отсутствующий пользователь и отсутствующий товар это разные факты приложения.

Перевод ошибки на границе репозитория

Репозиторий скрывает способ хранения данных. Код выше него не должен знать, использует приложение GORM, database/sql, pgx, sqlc или удалённый API.

Пример с GORM

GORM возвращает gorm.ErrRecordNotFound, когда First, Last или Take не нашли запись. Это поведение описано в документации GORM.

Репозиторий каталога переводит ошибку ORM в собственный sentinel:

func (r *ProductRepository) Get(
	ctx context.Context,
	productID int64,
) (catalog.Product, error) {
	var row productRow

	err := r.db.WithContext(ctx).
		Where("id = ?", productID).
		Take(&row).Error

	switch {
	case errors.Is(err, gorm.ErrRecordNotFound):
		return catalog.Product{}, fmt.Errorf(
			"product %d: %w",
			productID,
			catalog.ErrProductNotFound,
		)
	case err != nil:
		return catalog.Product{}, fmt.Errorf("select product %d: %w", productID, err)
	default:
		return mapProduct(row), nil
	}
}

В логах останется контекст product 100, а вызывающий код сможет проверить причину через errors.Is(err, catalog.ErrProductNotFound).

UPDATE и DELETE требуют отдельной проверки

В традиционном API GORM отсутствие строки при UPDATE или DELETE не превращается в gorm.ErrRecordNotFound. Запрос может завершиться без технической ошибки и изменить ноль строк, поэтому отсутствие сущности приходится выводить из RowsAffected.

Считать ноль изменённых строк отсутствием сущности можно не всегда. Условие запроса должно однозначно искать сущность, а поведение RowsAffected должно быть известно для выбранной базы данных и драйвера. Например, при оптимистической блокировке условие WHERE id = ? AND version = ? может изменить ноль строк из-за несовпадения версии. Это уже конфликт состояния, а не обязательно not found.

Когда условия выполнены, проверка выглядит так:

func (r *ProductRepository) Archive(
	ctx context.Context,
	productID int64,
) error {
	result := r.db.WithContext(ctx).
		Model(&productRow{}).
		Where("id = ?", productID).
		Update("archived", true)

	if result.Error != nil {
		return fmt.Errorf("archive product %d: %w", productID, result.Error)
	}
	if result.RowsAffected == 0 {
		return fmt.Errorf("product %d: %w", productID, catalog.ErrProductNotFound)
	}

	return nil
}

Без проверки RowsAffected метод вернёт nil для несуществующего товара, а вызывающий код решит, что архивирование прошло успешно.

Общий helper не должен владеть ошибкой приложения

Повторяющийся перевод можно вынести в инфраструктурный helper. Sentinel передаётся аргументом, поэтому helper не импортирует пакеты пользователей или каталога:

func WrapNotFound(err error, resource string, target error) error {
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return fmt.Errorf("%s: %w", resource, target)
	}
	return err
}

Использование остаётся явным:

err := db.Where("id = ?", productID).Take(&row).Error
err = gormutil.WrapNotFound(
	err,
	fmt.Sprintf("product %d", productID),
	catalog.ErrProductNotFound,
)

Helper переиспользует механику, но решение о смысле ошибки остаётся в репозитории.

Тот же подход без ORM

GORM просто библиотека для работы с БД, то же самое можно сделать при помощи database/sql. Запрос без строк возвращает sql.ErrNoRows из метода Scan, а репозиторий переводит его в тот же контракт приложения:

func (r *ProductRepository) Get(
	ctx context.Context,
	productID int64,
) (catalog.Product, error) {
	var product catalog.Product

	err := r.db.QueryRowContext(
		ctx,
		`SELECT id, name FROM products WHERE id = $1`,
		productID,
	).Scan(&product.ID, &product.Name)

	switch {
	case errors.Is(err, sql.ErrNoRows):
		return catalog.Product{}, fmt.Errorf(
			"product %d: %w",
			productID,
			catalog.ErrProductNotFound,
		)
	case err != nil:
		return catalog.Product{}, fmt.Errorf("select product %d: %w", productID, err)
	default:
		return product, nil
	}
}

Для UPDATE и DELETE принцип тот же. Число изменённых строк приходит из sql.Result:

result, err := r.db.ExecContext(
	ctx,
	`UPDATE products SET archived = TRUE WHERE id = $1`,
	productID,
)
if err != nil {
	return fmt.Errorf("archive product %d: %w", productID, err)
}

rowsAffected, err := result.RowsAffected()
if err != nil {
	return fmt.Errorf("read affected rows: %w", err)
}
if rowsAffected == 0 {
	return fmt.Errorf("product %d: %w", productID, catalog.ErrProductNotFound)
}

С pgx, sqlc или другой библиотекой меняется только техническая ошибка, которую распознаёт адаптер. Sentinel приложения и проверки над репозиторием остаются прежними.

Где переводить ошибку в HTTP-код

Ошибка catalog.ErrProductNotFound описывает факт приложения, но ничего не говорит о конкретном транспорте. HTTP-обработчик может превратить её в 404, gRPC-адаптер в codes.NotFound, а фоновый воркер использовать для решения о повторе задачи.

switch {
case errors.Is(err, catalog.ErrProductNotFound):
	http.Error(w, "product not found", http.StatusNotFound)
case errors.Is(err, users.ErrUserNotFound):
	http.Error(w, "access denied", http.StatusForbidden)
default:
	http.Error(w, "internal error", http.StatusInternalServerError)
}

Необязательно создавать HTTPError внутри use case. Если код приложения знает про 404, граница транспорта смещается внутрь бизнес-логики. Проще сохранить осмысленную ошибку до внешнего адаптера и выбрать представление уже там.

Когда sentinel недостаточно

Sentinel подходит, когда вызывающему коду нужен ответ на бинарный вопрос: произошла ли известная причина ошибки.

Если обработчику нужны дополнительные данные, лучше использовать собственный тип ошибки. Например, ошибка валидации может хранить имя поля, а конфликт версии сущности может содержать ожидаемую и фактическую версии. Тогда вызывающий код использует errors.As.

Тот же конфликт версии, который в разделе про UPDATE нельзя было выдать за not found:

// internal/catalog/errors.go
package catalog

import "fmt"

type VersionConflictError struct {
	ProductID int64
	Expected  int64
	Actual    int64
}

func (e *VersionConflictError) Error() string {
	return fmt.Sprintf(
		"product %d: version conflict, expected %d, got %d",
		e.ProductID, e.Expected, e.Actual,
	)
}

Репозиторий отдаёт этот тип там, где RowsAffected оказался нулём по неоднозначной причине. Товара может не быть, а может разойтись версия, и различает эти случаи отдельный запрос:

func (r *ProductRepository) UpdatePrice(
	ctx context.Context,
	productID int64,
	expectedVersion int64,
	price int64,
) error {
	result := r.db.WithContext(ctx).
		Model(&productRow{}).
		Where("id = ? AND version = ?", productID, expectedVersion).
		Updates(map[string]any{
			"price":   price,
			"version": expectedVersion + 1,
		})

	if result.Error != nil {
		return fmt.Errorf("update product %d: %w", productID, result.Error)
	}
	if result.RowsAffected > 0 {
		return nil
	}

	var row productRow

	err := r.db.WithContext(ctx).
		Select("version").
		Where("id = ?", productID).
		Take(&row).Error

	if errors.Is(err, gorm.ErrRecordNotFound) {
		return fmt.Errorf("product %d: %w", productID, catalog.ErrProductNotFound)
	}
	if err != nil {
		return fmt.Errorf("select product %d version: %w", productID, err)
	}

	// Товар есть, значит ноль изменённых строк дала разошедшаяся версия.
	return &catalog.VersionConflictError{
		ProductID: productID,
		Expected:  expectedVersion,
		Actual:    row.Version,
	}
}

Второй запрос здесь стоит ради примера: он позволяет показать sentinel и типизированную ошибку в одном методе. В рабочем коде он часто избыточен, потому что добавляет поход в базу на каждое неудачное обновление. Если вызывающему коду достаточно знать сам факт расхождения, фактическую версию можно не читать. Если же реакция на отсутствие товара и на конфликт одинаковая, различать эти случаи не нужно вовсе.

errors.As находит нужный тип в цепочке и записывает его в переменную. Вызывающий код получает не только факт конфликта, но и актуальную версию:

var conflict *catalog.VersionConflictError
if errors.As(err, &conflict) {
	return refetchAndRetry(ctx, conflict.Actual)
}

Не стоит создавать отдельный sentinel для каждого идентификатора:

// Плохо: число глобальных значений растёт вместе с данными.
var ErrProduct100NotFound = errors.New("product 100 not found")

Идентификатор относится к контексту конкретного вызова. Его можно добавить через fmt.Errorf, сохранив общий для пакета sentinel через %w.

Тестируем контракт ошибок

Полезный тест проверяет не текст, а поведение errors.Is на границе пакетов:

func TestNotFoundErrorsDoNotOverlap(t *testing.T) {
	userErr := fmt.Errorf("load user 42: %w", users.ErrUserNotFound)

	if !errors.Is(userErr, users.ErrUserNotFound) {
		t.Fatal("expected user not found error")
	}
	if errors.Is(userErr, catalog.ErrProductNotFound) {
		t.Fatal("user error must not match product error")
	}
}

Для репозитория отдельно стоит проверить перевод ошибок:

  • gorm.ErrRecordNotFound превращается в нужный sentinel;
  • sql.ErrNoRows превращается в тот же sentinel при реализации без ORM;
  • ноль изменённых строк при UPDATE или DELETE считается отсутствием сущности только при поиске по идентификатору;
  • неожиданная ошибка базы данных не маскируется под not found;
  • контекст сохраняется, но не ломает errors.Is.

Тесты, которые сравнивают err.Error() со строкой, фиксируют текст сообщения, но не контракт ошибки. Небольшое изменение контекста сломает такой тест, хотя поведение приложения останется корректным.

Практическое правило

Общие ошибки оправданы только тогда, когда все вызывающие реагируют на эту ошибку одинаково, а пакет готов поддерживать её как часть своего API. Если хотя бы одно из этих условий не выполняется, точной семантики общему значению не хватает: ошибка остаётся у пакета, который определяет её смысл, а технические ошибки переводятся на границе адаптера.

Короткий чек-лист:

  • разные причины получают разные значения ошибок;
  • репозиторий не выпускает наружу gorm.ErrRecordNotFound или sql.ErrNoRows как бизнес-контракт;
  • контекст добавляется через %w, если вызывающий код должен распознавать причину;
  • %v используется, когда внутреннюю ошибку намеренно нельзя раскрывать через errors.Is;
  • HTTP- и gRPC-коды выбираются во внешних адаптерах;
  • общий helper принимает целевой sentinel аргументом и не владеет ошибками приложения;
  • тесты проверяют errors.Is и errors.As, а не текст сообщения.

Материалы