Архитектура обработки ошибок в Go: границы и паттерны

Обработка ошибок на правильном уровне.

Содержимое страницы

Обработка ошибок в Go — это то, что легко вызывает жалобы. Каждый разработчик на Go писал этот код сотни раз:

if err != nil {
	return err
}

В этом нет ничего интересного. Интересная часть заключается в том, что означает ошибка, где она должна обрабатываться, где оборачиваться, где переводиться, где фиксироваться в логах и что должно быть доступно вызывающему коду — это вопрос архитектуры.

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

Архитектура обработки ошибок в Go: поток ошибок между слоями

Эта статья — не введение для новичков по if err != nil.

Это практическое руководство по архитектуре обработки ошибок в Go: оборачивание, сигнальные ошибки (sentinels), пользовательские типы ошибок, errors.Is, errors.As, границы ошибок, маппинг API, логирование, повторные попытки, безопасность и паттерны для production-среды.

Слегка субъективная версия: не пытайтесь скрыть ошибки в Go. Сделайте их осмысленными на правильной границе.

Что такое ошибки в Go

В Go ошибка — это просто значение, реализующее этот интерфейс:

type error interface {
	Error() string
}

Этот маленький интерфейс — причина того, почему обработка ошибок в Go кажется такой прямой.

Функции явно возвращают ошибки:

func LoadUser(id string) (*User, error) {
	// ...
}

Вызывающий код решает, что делать:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

Нет исключений и нет скрытого разворачивания стека. Сбой — часть сигнатуры функции.

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

Цель — не меньшее количество обработки ошибок, а лучшее осмысление ошибок.

Три задачи ошибки

Полезная ошибка обычно имеет одну или несколько задач.

Задача 1: Объяснить, что пошло не так

Для людей ошибка должна объяснять, какая операция завершилась неудачно.

Пример:

return fmt.Errorf("load user %s: %w", id, err)

Это дает контекст. Это говорит о том, что сбой произошел при загрузке пользователя.

Задача 2: Сохранить причину

Для кода ошибка должна сохранять основную причину, когда эта причина имеет значение.

Пример:

return fmt.Errorf("load user %s: %w", id, err)

%w оборачивает исходную ошибку, чтобы вызывающий код мог исследовать ее с помощью errors.Is или errors.As.

Задача 3: Позволить границе принять решение

На какой-то границе программа должна решить, что делать.

Примеры:

  • Вернуть HTTP 404
  • Вернуть HTTP 409
  • Повторить операцию
  • Записать в лог с уровнем предупреждения
  • Показать безопасное для пользователя сообщение
  • Отменить транзакцию
  • Отправить ошибку в мониторинг
  • Игнорировать отмену

Это решение обычно должно основываться на идентичности или типе ошибки, а не на сравнении строк.

Основные инструменты работы с ошибками в современном Go

Современный Go дает вам небольшой, но мощный набор инструментов.

errors.New

Используйте errors.New для создания простого значения ошибки:

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

Это полезно для сигнальных ошибок.

fmt.Errorf с %w

Используйте fmt.Errorf с %w для оборачивания ошибки:

return fmt.Errorf("query user: %w", err)

Оборачивание добавляет контекст, сохраняя исходную ошибку для исследования.

errors.Is

Используйте errors.Is для проверки, совпадает ли ошибка с конкретной целью где-то в ее цепочке:

if errors.Is(err, ErrNotFound) {
	// обработать отсутствие
}

Используйте это для сигнальных ошибок и известных состояний.

errors.As

Используйте errors.As для извлечения конкретного типа ошибки из цепочки:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// использовать validationErr.Field или validationErr.Reason
}

Используйте это, когда ошибка содержит структурированные данные.

errors.Join

Используйте errors.Join, когда произошло несколько ошибок и все они должны быть сохранены:

return errors.Join(closeErr, flushErr)

Объединенные ошибки все еще можно исследовать с помощью errors.Is и errors.As.

Используйте это осторожно. Объединенная ошибка означает, что несколько сбоев являются частью одного результата.

Сигнальные ошибки (Sentinel errors)

Сигнальная ошибка — это значение ошибки на уровне пакета, представляющее известное состояние.

Пример:

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

Сигнальные ошибки полезны, когда вызывающему коду нужно знать только категорию сбоя.

Пример:

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.queryUser(ctx, id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}
		return nil, fmt.Errorf("query user: %w", err)
	}

	return user, nil
}

Затем сервис или обработчик может проверить:

if errors.Is(err, ErrUserNotFound) {
	// вернуть 404
}

Когда использовать сигнальные ошибки

Используйте сигнальные ошибки, когда:

  • Состояние стабильно.
  • Вызывающему коду нужно разветвление по нему.
  • Дополнительные структурированные данные не нужны.
  • Ошибка принадлежит вашему пакету или домену.

Хорошие примеры:

var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")

Когда не использовать сигнальные ошибки

Не создавайте сигналы для каждого возможного сбоя.

Плохо:

var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")

Если вызывающий код не разветвляется по ним, они могут быть просто сообщениями.

Также будьте осторожны с экспортом слишком большого количества сигналов. Экспортированные сигнальные ошибки становятся частью API вашего пакета.

Пользовательские типы ошибок

Пользовательский тип ошибки полезен, когда ошибка содержит структурированную информацию.

Пример:

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}

Вызывающий код:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

Это лучше, чем парсинг строки ошибки.

Когда использовать пользовательские типы ошибок

Используйте пользовательские типы ошибок, когда:

  • Вызывающему коду нужны структурированные данные.
  • Ошибка имеет значимые поля.
  • Тип является частью контракта вашего пакета.
  • Вызывающему коду может потребоваться обрабатывать несколько значений по-разному.

Примеры:

  • Ошибка валидации с именем поля
  • Ошибка ограничения скорости (rate limit) со временем повторной попытки
  • Ошибка HTTP с кодом состояния
  • Ошибка разбора с номером строки и столбца
  • Ошибка домена с идентификатором ресурса

Когда не использовать пользовательские типы ошибок

Не создавайте пользовательские типы просто для избежания errors.New.

Это излишне:

type NotFoundError struct{}

func (e NotFoundError) Error() string {
	return "not found"
}

Если нет полезных данных, сигнала часто бывает достаточно.

Оборачивание ошибок

Оборачивание добавляет контекст к ошибке, сохраняя исходную ошибку.

Пример:

func LoadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read config %s: %w", path, err)
	}

	if err := parseConfig(data); err != nil {
		return fmt.Errorf("parse config %s: %w", path, err)
	}

	return nil
}

Если os.ReadFile завершается с ошибкой, вызывающий код получает оба:

  • высокоуровневую операцию: чтение конфигурации
  • низкоуровневую причину: отказ в доступе, файл не найден и т. д.

Оба доступны через цепочку ошибок, что делает оборачивание с %w стоящим делом для последовательного применения.

Оборачивание с полезным контекстом

Хорошее оборачивание говорит, какая операция завершилась неудачно:

return fmt.Errorf("create invoice %s: %w", invoiceID, err)

Плохое оборачивание добавляет шум:

return fmt.Errorf("error: %w", err)

Это не сообщает вызывающему коду ничего полезного.

Также избегайте повторения одного и того же существительного на каждом слое:

return fmt.Errorf("user service: get user: user repository: query user: %w", err)

Такая цепочка технически корректна, но практически раздражает.

Оборачивайте там, где контекст меняет значение. Если вы не можете объяснить одной фразой, какая операция завершилась неудачно, вы, вероятно, либо слишком агрессивно оборачиваете, либо недостаточно.

Когда оборачивать и когда не оборачивать

Это одно из самых важных архитектурных решений.

Оборачивайте при переходе через значимую границу

Оборачивайте, когда ошибка переходит от одной операции к операции более высокого уровня.

Пример:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

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

Не оборачивайте, чтобы просто сказать “не удалось”

Плохо:

if err != nil {
	return fmt.Errorf("failed: %w", err)
}

Слово “failed” обычно подразумевается самим фактом наличия ошибки.

Не оборачивайте, если вы переводите

Иногда вы должны перевести одну ошибку в другую ошибку домена.

Пример:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

Это намеренно скрывает детали базы данных и выставляет условие домена.

Вы все еще можете сохранить причину, если это полезно, но делайте это осознанно.

Не раскрывайте детали реализации случайно

Если вы оборачиваете низкоуровневую ошибку с %w, вызывающий код может исследовать ее.

Это обычно хорошо внутри вашего приложения.

Но в публичном API пакета оборачивание может раскрыть детали реализации как часть вашего контракта.

Например, если ваш пакет оборачивает sql.ErrNoRows, вызывающий код может начать зависеть от него:

if errors.Is(err, sql.ErrNoRows) {
	// вызывающий код теперь знает, что вы используете database/sql
}

Если вы можете изменить хранилище позже, предпочтите доменный сигнал:

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

Затем возвращайте его на границе пакета.

Границы ошибок

Самый полезный способ думать об обработке ошибок в Go — через границы.

Граница — это место, где ошибка меняет значение или аудиторию.

Распространенные границы включают:

  • база данных к репозиторию
  • репозиторий к сервису
  • сервис к обработчику HTTP
  • сервис к команде CLI
  • внутренняя ошибка к сообщению для пользователя
  • временный сбой к решению о повторной попытке
  • сбой операции к событию лога
  • ошибка домена к ответу API

Архитектура ошибок — это в основном дизайн границ. Каждая граница — это точка принятия решений, где ошибки либо получают контекст, либо теряют детали реализации, либо переводятся в форму, которую следующий слой может использовать для действий.

Граница репозитория

Репозиторий общается с хранилищем.

Он обычно должен переводить специфичные для базы данных ошибки в ошибки домена.

Пример:

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

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

Репозиторий скрывает sql.ErrNoRows и выставляет ErrUserNotFound — чистую границу, которая означает, что сервису не нужно ничего знать о том, как хранилище представляет “отсутствует”.

Граница сервиса

Сервис владеет бизнес-смыслом.

Он обычно должен добавлять контекст операции и сохранять ошибки домена.

Пример:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get user %s: %w", id, err)
	}

	return user, nil
}

Это сохраняет условие домена, добавляя контекст для неожиданных ошибок.

Для более сложных бизнес-правил сервис может создавать ошибки домена напрямую:

var ErrAccountDisabled = errors.New("account disabled")

func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		return nil, fmt.Errorf("get user by email: %w", err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	// ...
	return session, nil
}

Сервис — правильное место для ошибок бизнес-уровня — созданных напрямую из логики домена, а не переведенных из инфраструктурных состояний.

Граница обработчика HTTP

Обработчик HTTP переводит ошибки приложения в ответы HTTP.

Это граница, где внутренние детали должны стать безопасными для пользователя ответами.

Пример:

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		user, err := svc.GetUser(r.Context(), r.PathValue("id"))
		if err != nil {
			writeHTTPError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

Маппинг ошибок:

func writeHTTPError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrUserNotFound):
		http.Error(w, "user not found", http.StatusNotFound)

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "account disabled", http.StatusForbidden)

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
	}
}

Обработчик маппит ошибки домена в семантику HTTP, а не раскрывает сырые детали базы данных или внутренние ошибки. Здесь многие приложения на Go ошибаются — они либо раскрывают слишком много внутренних деталей, либо сводят все ошибки к HTTP 500. Для полной картины паттернов обработчиков и промежуточного программного обеспечения в Go API Building REST APIs in Go охватывает аутентификацию, маршрутизацию и обработку ошибок в стандартной библиотеке, Gin, Echo и Fiber.

Граница CLI

У CLI другая граница, чем у HTTP API.

В CLI ошибка должна быть полезна человеку, выполняющему команду.

Пример:

func RunImport(ctx context.Context, args []string) error {
	if len(args) == 0 {
		return ErrMissingInputFile
	}

	if err := importFile(ctx, args[0]); err != nil {
		return fmt.Errorf("import %s: %w", args[0], err)
	}

	return nil
}

На границе команды:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, formatCLIError(err))
		os.Exit(exitCode(err))
	}
}

Маппинг известных ошибок в коды выхода:

func exitCode(err error) int {
	switch {
	case errors.Is(err, ErrMissingInputFile):
		return 2
	case errors.Is(err, ErrValidation):
		return 3
	default:
		return 1
	}
}

CLI часто может показывать больше деталей, чем публичный API, но он все равно должен избегать утечки секретов.

Паттерн типа ошибки API

Для HTTP API может быть полезен небольшой тип ошибки уровня приложения.

Пример:

type APIError struct {
	Status  int
	Code    string
	Message string
	Err     error
}

func (e *APIError) Error() string {
	if e.Err == nil {
		return e.Message
	}

	return e.Message + ": " + e.Err.Error()
}

func (e *APIError) Unwrap() error {
	return e.Err
}

Конструктор:

func NewAPIError(status int, code string, message string, err error) *APIError {
	return &APIError{
		Status:  status,
		Code:    code,
		Message: message,
		Err:     err,
	}
}

Использование:

return NewAPIError(
	http.StatusConflict,
	"duplicate_email",
	"email is already registered",
	ErrDuplicateEmail,
)

Обработчик:

func writeAPIError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		writeJSON(w, apiErr.Status, map[string]string{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		})
		return
	}

	writeJSON(w, http.StatusInternalServerError, map[string]string{
		"code":    "internal_error",
		"message": "internal server error",
	})
}

Этот паттерн полезен, когда вы хотите структурированные ошибки API со стабильными кодами.

Используйте его на границе API. Не заставляйте каждый внутренний пакет возвращать ошибки, специфичные для API.

Ошибки домена против ошибок транспорта

Держите ошибки домена отдельно от ошибок транспорта.

Ошибка домена:

var ErrInsufficientBalance = errors.New("insufficient balance")

Маппинг транспорта:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "insufficient balance", http.StatusConflict)
	return
}

Не заставляйте ваш слой домена возвращать коды состояния HTTP:

return &APIError{Status: http.StatusConflict}

Это связывает бизнес-логику с HTTP и предотвращает чистую работу вашего сервисного слоя через HTTP, CLI, воркеры, тесты и будущие адаптеры gRPC. Маппинг транспорта принадлежит границе транспорта, а не коду домена. Для руководства по тому, где определять ошибки домена, сигналы и адаптеры транспорта в структуре вашего проекта, Go Project Structure: Practices & Patterns охватывает конвенции internal/, pkg/ и адаптеров, которые сохраняют эти слои чистыми и разделенными.

Повторяемые ошибки (Retryable errors)

Некоторые ошибки должны вызывать повторную попытку. Некоторые нет.

Не решайте это, сравнивая строки.

Используйте интерфейс-маркер или явную функцию.

Пример:

type RetryableError struct {
	Err error
}

func (e *RetryableError) Error() string {
	return e.Err.Error()
}

func (e *RetryableError) Unwrap() error {
	return e.Err
}

Помощник:

func Retryable(err error) error {
	if err == nil {
		return nil
	}

	return &RetryableError{Err: err}
}

func IsRetryable(err error) bool {
	var retryable *RetryableError
	return errors.As(err, &retryable)
}

Использование:

if err := callRemoteAPI(ctx); err != nil {
	if isTemporaryNetworkError(err) {
		return Retryable(fmt.Errorf("call remote api: %w", err))
	}

	return fmt.Errorf("call remote api: %w", err)
}

Цикл повторной попытки:

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// повторить с экспоненциальной задержкой
	}
	return err
}

Это гораздо лучше, чем проверка, содержит ли строка ошибки “timeout” — сравнение строк молча ломается, когда сообщения меняются, и создает невидимую связь между производителем и потребителем.

Ошибки валидации

Ошибки валидации часто нуждаются в структурированных данных.

Пример:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "validation failed"
}

Использование:

func ValidateCreateUser(req CreateUserRequest) error {
	var fields []FieldError

	if req.Email == "" {
		fields = append(fields, FieldError{
			Field:   "email",
			Message: "email is required",
		})
	}

	if len(fields) > 0 {
		return &ValidationError{Fields: fields}
	}

	return nil
}

Обработчик:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

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

Несколько ошибок

Иногда несколько вещей завершаются неудачно.

Примеры:

  • закрытие нескольких ресурсов
  • валидация многих полей
  • остановка нескольких воркеров
  • выполнение независимых проверок
  • сброс и закрытие вывода

Используйте errors.Join, когда все ошибки должны быть сохранены.

Пример:

func CloseAll(closers ...io.Closer) error {
	var errs []error

	for _, closer := range closers {
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}

	return errors.Join(errs...)
}

Вызывающий код:

if err := CloseAll(a, b, c); err != nil {
	return fmt.Errorf("close resources: %w", err)
}

Оба errors.Is и errors.As могут исследовать объединенные ошибки, что означает, что значения объединенных ошибок остаются полностью совместимыми со стандартными паттернами проверки ошибок.

Когда не использовать errors.Join

Не используйте errors.Join, когда есть одна основная ошибка и некоторый контекст для логирования.

Не используйте его, чтобы избежать решения, какая ошибка важна.

Не возвращайте огромные объединенные ошибки пользователям.

Объединенные ошибки полезны, но они могут быстро стать шумными.

Panic — это не обработка ошибок

В обычном коде приложения не используйте panic для ожидаемых ошибок.

Плохо:

if err != nil {
	panic(err)
}

Используйте panic для ошибок программиста или действительно неотвратимых ситуаций.

Примеры:

  • нарушение невозможного внутреннего инварианта
  • невалидация инициализации пакета
  • сбой помощника теста с t.Fatal или panic в ограниченных случаях
  • неотвратимая ошибка конфигурации при запуске, в зависимости от стиля

Не используйте panic, потому что запрос к базе данных завершился неудачно или пользователь отправил невалидный ввод.

Это нормальные ошибки.

Логирование ошибок

Распространенная ошибка в Go — логирование одной и той же ошибки на каждом слое.

Плохо:

func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		log.Printf("query failed: %v", err)
		return nil, err
	}
	return user, nil
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		log.Printf("service failed: %v", err)
		return nil, err
	}
	return user, nil
}

Это создает дублирующиеся логи для одного сбоя.

Лучше:

  • оборачивать ошибки при их движении вверх
  • логировать один раз на границе, где ошибка обрабатывается
  • включать структурированный контекст в лог

Пример:

func (s *Server) handleError(r *http.Request, err error) {
	s.logger.ErrorContext(
		r.Context(),
		"request failed",
		"method", r.Method,
		"path", r.URL.Path,
		"err", err,
	)
}

Это дает одно событие лога с полной цепочкой ошибок. Для production-ready настройки структурированного логирования, Structured Logging in Go with slog охватывает записи log/slog, JSON-обработчики, корреляцию контекста и маскировку — все это естественно сочетается с логированием ошибок на уровне границ.

Когда логировать внутри нижних слоев

Логировать внутри нижних слоев только тогда, когда слой фактически обрабатывает ошибку или добавляет важный операционный контекст, который не будет виден в другом месте.

Например, цикл повторной попытки может логировать каждую попытку повторения на уровне отладки или предупреждения.

Но репозиторий не должен логировать каждую ошибку запроса, если обработчик будет логировать окончательный сбой запроса.

Ошибки для пользователей против ошибок для операторов

Не показывайте внутренние ошибки напрямую пользователям.

Внутренняя ошибка:

query user by id: dial tcp 10.0.4.12:5432: connection refused

Сообщение для пользователя:

internal server error

Лог оператора:

request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"

Это разные аудитории, и хорошая архитектура ошибок держит их отдельно:

  • внутренняя диагностическая ошибка
  • безопасный для пользователя ответ
  • стабильный код ошибки API
  • контекст лога оператора

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

Безопасная обработка ошибок

Ошибки могут утечь чувствительную информацию.

Избегайте раскрытия:

  • строк подключения к базе данных
  • SQL-запросов с секретами
  • внутренних имен хостов
  • путей к файлам
  • токенов доступа
  • ключей API
  • трасс стека
  • частных данных клиентов
  • деталей политик авторизации

Это особенно важно в HTTP API.

Плохо:

http.Error(w, err.Error(), http.StatusInternalServerError)

Хорошо:

http.Error(w, "internal server error", http.StatusInternalServerError)

Логировать внутреннюю ошибку безопасно для операторов. Возвращать безопасное сообщение пользователю.

Коды ошибок

Для публичных API стабильные коды ошибок часто лучше, чем полагаться только на сообщения.

Пример ответа:

{
  "code": "user_not_found",
  "message": "user not found"
}

Сообщение может измениться. Код должен быть стабильным.

Используйте коды ошибок для:

  • поведения клиента
  • документации
  • SDK
  • локализации
  • диагностики поддержки

Не заставляйте клиентов парсить английские сообщения об ошибках.

Практический многослойный дизайн ошибок

Вот чистый паттерн для многих backend-сервисов на Go.

Слой репозитория

  • Общается с базой данных или внешним хранилищем.
  • Конвертирует специфичные для хранилища ошибки “не найдено” в ошибки домена.
  • Оборачивает неожиданные ошибки хранилища с контекстом операции.
  • Не возвращает ошибки HTTP.
  • Обычно не логгирует.

Пример:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("query user by id: %w", err)

Слой сервиса

  • Владеет бизнес-правилами.
  • Создает ошибки домена.
  • Сохраняет известные ошибки домена.
  • Оборачивает неожиданные ошибки нижних уровней.
  • Не возвращает коды состояния HTTP.
  • Обычно не логгирует.

Пример:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Слой транспорта

  • Маппит ошибки домена в ответы HTTP, gRPC или CLI.
  • Логгирует необработанные или неожиданные ошибки.
  • Скрывает внутренние детали от пользователей.
  • Устанавливает коды состояния и коды ошибок API.

Пример:

switch {
case errors.Is(err, ErrUserNotFound):
	writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
	writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}

Это разделение сохраняет обработку ошибок понятной и позволяет каждому слою развиваться независимо — вы можете изменить технологию хранилища, не касаясь логики сервиса или маппинга транспорта. Многослойный дизайн работает лучше всего, когда зависимости внедряются, а не жестко закодированы; Dependency Injection in Go: Patterns & Best Practices охватывает паттерны конструкторов и интерфейсов, которые делают каждую границу легкой для тестирования в изоляции.

Полный пример

Вот небольшой пример от начала до конца.

Ошибки домена:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("user not found")
	ErrDuplicateEmail = errors.New("duplicate email")
	ErrAccountDisabled = errors.New("account disabled")
)

Репозиторий:

package users

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

type Repository struct {
	db *sql.DB
}

func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name, disabled
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
		&user.Disabled,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("query user by id: %w", err)
	}

	return &user, nil
}

Сервис:

package users

import (
	"context"
	"errors"
	"fmt"
)

type Service struct {
	repo *Repository
}

func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("get profile for user %s: %w", id, err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	return &Profile{
		ID:    user.ID,
		Email: user.Email,
		Name:  user.Name,
	}, nil
}

Обработчик HTTP:

package httpapi

import (
	"context"
	"errors"
	"net/http"

	"example.com/app/users"
)

type Handler struct {
	users *users.Service
}

func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
	profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
	if err != nil {
		h.writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, profile)
}

func (h *Handler) writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, users.ErrUserNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{
			"code":    "user_not_found",
			"message": "user not found",
		})

	case errors.Is(err, users.ErrAccountDisabled):
		writeJSON(w, http.StatusForbidden, map[string]string{
			"code":    "account_disabled",
			"message": "account is disabled",
		})

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		writeJSON(w, http.StatusGatewayTimeout, map[string]string{
			"code":    "request_timeout",
			"message": "request timed out",
		})

	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"code":    "internal_error",
			"message": "internal server error",
		})
	}
}

Эта структура дает вам:

  • ошибки домена
  • перевод хранилища
  • контекст сервиса
  • безопасный маппинг HTTP
  • исследуемые цепочки ошибок
  • отсутствие сравнения строк
  • отсутствие утечки транспорта в код домена

Это та архитектура ошибок, которая масштабируется — достаточно простая для понимания новым участником, но достаточно структурированная, чтобы логика домена никогда не утекала в ответы транспорта.

Тестирование поведения ошибок

Поведение ошибок должно тестироваться так же тщательно, как и успешный сценарий, потому что решения на границах — маппинг сигналов, извлечение типов, коды HTTP — часто там, где баги скрываются дольше всего. Для полного руководства по структуре тестов Go, мокингу и паттернам покрытия, см. Go Unit Testing: Structure & Best Practices.

Тест маппинга сигналов

func TestGetByIDNotFound(t *testing.T) {
	repo := newTestRepository(t)

	_, err := repo.GetByID(t.Context(), "missing")
	if !errors.Is(err, users.ErrUserNotFound) {
		t.Fatalf("got %v, want ErrUserNotFound", err)
	}
}

Тест извлечения пользовательской ошибки

func TestValidationError(t *testing.T) {
	err := ValidateCreateUser(CreateUserRequest{})

	var validationErr *ValidationError
	if !errors.As(err, &validationErr) {
		t.Fatalf("got %T, want ValidationError", err)
	}

	if len(validationErr.Fields) == 0 {
		t.Fatal("expected validation fields")
	}
}

Тест маппинга HTTP

func TestWriteErrorNotFound(t *testing.T) {
	rec := httptest.NewRecorder()

	writeHTTPError(rec, users.ErrUserNotFound)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
	}
}

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

Распространенные антипаттерны

Антипаттерн 1: Сравнение строк

Плохо:

if strings.Contains(err.Error(), "not found") {
	// ...
}

Используйте errors.Is или errors.As вместо этого — оба обрабатывают обернутые цепочки ошибок автоматически и не ломаются, когда сообщения переформатированы или локализованы.

Антипаттерн 2: Потеря причины

Плохо:

return errors.New("query failed")

Лучше:

return fmt.Errorf("query user: %w", err)

Антипаттерн 3: Оборачивание без смысла

Плохо:

return fmt.Errorf("error happened: %w", err)

Оборачивайте с контекстом операции, который объясняет, что предпринималось, например, "create invoice %s: %w", а не с размытым префиксом, который не добавляет диагностической ценности.

Антипаттерн 4: Логирование на каждом слое

Плохо:

log.Println(err)
return err

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

Антипаттерн 5: Возврат ошибок HTTP из кода домена

Плохо:

return &APIError{Status: http.StatusNotFound}

из сервиса домена. Маппинг ошибок домена в коды состояния HTTP и тела ответов на границе обработчика, сохраняя ваш сервисный слой независимым от проблем транспорта.

Антипаттерн 6: Раскрытие внутренних ошибок пользователям

Плохо:

http.Error(w, err.Error(), http.StatusInternalServerError)

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

Антипаттерн 7: Слишком много экспортированных сигналов

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

Антипаттерн 8: Использование panic для ожидаемых сбоев

Плохо:

panic(err)

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

Антипаттерн 9: Игнорирование ошибок контекста

Плохо:

return fmt.Errorf("request failed")

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

Чек-лист ревью ошибок

Используйте этот чек-лист при ревью кода.

Создание ошибок

  • Это известное состояние?
  • Должна ли это быть сигнальная ошибка?
  • Нужны ли структурированные данные?
  • Должен ли это быть пользовательский тип?
  • Сообщение об ошибке понятно?

Оборачивание ошибок

  • Добавляет ли оборачивание полезный контекст операции?
  • Сохраняет ли %w причину там, где это нужно?
  • Код случайно раскрывает детали реализации?
  • Цепочка слишком шумная?

Перевод ошибок

  • Переводится ли низкоуровневая ошибка на правильной границе?
  • Скрыто ли специфичное поведение базы данных от кода сервиса?
  • Независимы ли ошибки домена от HTTP или CLI?

Обработка ошибок

  • Разветвляется ли вызывающий код с errors.Is или errors.As?
  • Правильно ли обрабатываются отмена контекста и дедлайны?
  • Явно ли идентифицированы ошибки для повторной попытки?
  • Структурированы ли ошибки валидации?

Логирование

  • Логгируется ли ошибка один раз на границе обработки?
  • Логи структурированы?
  • Исключены ли чувствительные детали из ответов для пользователей?
  • Достаточно ли контекста для операторов?

Тестирование

  • Тестируются ли известные случаи ошибок?
  • Тестируются ли маппинги HTTP или CLI?
  • Тестируются ли детали валидации?
  • Тестируются ли решения о повторной попытке?

Мои субъективные правила

Правило 1: Ошибки должны пересекать границы со смыслом

Не просто передавайте ошибки. Решайте, что они означают на каждом слое.

Правило 2: Оборачивайте для контекста, а не для украшения

Если оборачивание не добавляет полезной информации о том, какая операция завершилась неудачно, не оборачивайте. Дополнительный слой контекста без смысла делает цепочку ошибок труднее для чтения и не добавляет диагностической ценности.

Правило 3: Переводите ошибки реализации в ошибки домена

Не позволяйте sql.ErrNoRows стать частью вашей бизнес-логики. Переводите ошибки реализации в ошибки домена на границе хранилища, чтобы остальная часть приложения никогда не нуждалась в знании, какая база данных или ORM находится под ней.

Правило 4: Не парсите строки ошибок

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

Правило 5: Логгируйте один раз

Оборачивайте ошибки при движении вверх. Логгируйте там, где ошибка наконец обрабатывается.

Правило 6: Держите сообщения для пользователей безопасными

Внутренние диагностические ошибки — для логов. Сообщения для пользователей — для пользователей.

Правило 7: Держите ошибки транспорта на границе транспорта

Коды состояния HTTP принадлежат обработчикам или адаптерам API, а не сервисам домена. Код домена должен быть повторно используемым через транспорт — сегодня HTTP, завтра CLI, gRPC или event-driven воркер.

Финальные мысли

Обработка ошибок в Go — это не о написании if err != nil вечно — это о том, чтобы сделать сбой явным и понятным на каждой границе.

Механика проста:

возвращать ошибки
оборачивать с %w
проверять с errors.Is
извлекать с errors.As
объединять, когда несколько ошибок важны

Архитектура — более сложная часть:

переводить на границах
сохранять причины
скрывать внутренние детали от пользователей
логгировать один раз
тестировать известные сбои

Это обработка ошибок в Go, сделанная хорошо — не изощренная, не магическая, но достаточно ясная, чтобы следующий разработчик, оператор, клиент API и будущий вы могли понять, что пошло не так и что должно произойти дальше. Для более широкого взгляда на production-паттерны Go в интеграции, тестировании и доступе к данным, см. App Architecture in Production.

Источники

Подписаться

Получайте новые материалы про системы, инфраструктуру и AI engineering.