Архитектура обработки ошибок в Go: границы и паттерны
Обрабатывайте ошибки на правильном уровне.
Обработка ошибок в Go часто вызывает критику. Каждый разработчик на Go сотни раз писал этот код:
if err != nil {
return err
}
Но это не самая интересная часть. Самое интересное — это то, что означает ошибка, где её следует обрабатывать, где оборачивать, где переводить в другой контекст, где логировать и что показывать вызывающему коду. Это вопрос архитектуры.
В Go ошибки рассматриваются как значения. Это делает сбои явными. Это также означает, что вашей кодовой базе нужен чёткий дизайн обработки ошибок. Без него ошибки превращаются в случайные строки, обработчики HTTP раскрывают детали базы данных, логи дублируют одни и те же сбои пять раз, повторные попытки происходят по неправильным причинам, а вызывающий код анализирует текст вместо поведения.

Эта статья — не вводное руководство для начинающих по if err != nil.
Это практическое руководство по архитектуре обработки ошибок в Go: оборачивание, сигнальные ошибки (sentinels), пользовательские типы ошибок, errors.Is, errors.As, границы ошибок, маппинг API, логирование, повторные попытки, безопасность и паттерны для продакшена.
Чуть более категоричная версия: не пытайтесь заставить ошибки в 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
- Повторить операцию
- Записать в лог с уровнем warning
- Показать безопасное для пользователя сообщение
- Прервать транзакцию
- Отправить ошибку в систему мониторинга
- Игнорировать отмену
Это решение обычно должно основываться на идентичности или типе ошибки, а не на сопоставлении строк.
Основные инструменты обработки ошибок в современном 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)
}
Это лучше, чем парсинг строки ошибки.
Когда использовать пользовательские типы ошибок
Используйте пользовательские типы ошибок, когда:
- Вызывающему коду нужны структурированные данные.
- Ошибка имеет значимые поля.
- Тип является частью контракта вашего пакета.
- Вызывающему коду может потребоваться обрабатывать несколько значений по-разному.
Примеры:
- Ошибка валидации с именем поля
- Ошибка ограничения частоты запросов со временем повторной попытки
- 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 worthwhile.
Оборачивайте с полезным контекстом
Хорошее оборачивание говорит, какая операция завершилась сбоем:
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) {
// повторить с backoff
}
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,
)
}
Это даёт одно событие лога с полной цепочкой ошибок. Для готового к продакшену структурированного логирования, 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 стабильные коды ошибок часто лучше, чем reliance только на сообщения.
Пример ответа:
{
"code": "user_not_found",
"message": "user not found"
}
Сообщение может измениться. Код должен быть стабильным.
Используйте коды ошибок для:
- поведения клиента
- документации
- SDK
- локализации
- диагностики поддержки
Не заставляйте клиентов парсить английские сообщения об ошибках.
Практический слоистый дизайн ошибок
Вот чистый паттерн для многих бэкенд-сервисов на 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. Сохраняйте ошибки контекста, чтобы вызывающий код мог отличать настоящую ошибку операции от отменённого или истёкшего по времени запроса и соответствующим образом реагировать на каждую. Для тщательного обзора того, как отмена контекста и распространение таймаутов работают через слои сервиса, см. Go context.Context Done Right.
Чек-лист проверки ошибок
Используйте этот чек-лист при код-ревью.
Создание ошибок
- Это известное условие?
- Должна ли это быть сигнальная ошибка?
- Нужны ли ей структурированные данные?
- Должна ли это быть пользовательский тип?
- Ясно ли сообщение об ошибке?
Оборачивание ошибок
- Добавляет ли оборачивание полезный контекст операции?
- Сохраняет ли
%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 или событийный воркер.
Финальные мысли
Обработка ошибок в Go — это не о том, чтобы писать if err != nil вечно, — это о том, чтобы сделать сбой явным и понятным на каждой границе.
Механика проста:
return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter
Архитектура — более сложная часть:
translate at boundaries
preserve causes
hide internals from users
log once
test known failures
Вот так и выглядит хорошо выполненная обработка ошибок в Go — не хитрая, не магическая, но достаточно понятная, чтобы следующий разработчик, оператор, клиент API и ваше будущее “я” могли понять, что пошло не так и что должно произойти дальше. Для более широкого взгляда на продакшен-паттерны Go через интеграцию, тестирование и доступ к данным, см. App Architecture in Production.