
Go не признаёт сложных развесистых фреймворков — и это правильно. Любой инструмент должен быть настолько очевидным, чтобы разработчики сразу видели пользу от его применения.
И если вы сталкивались на больших legacy-проектах с хаосом в обработке и логировании ошибок — когда одна и та же ошибка дублируется в логах на каждом уровне, когда непонятно где обрабатывать ошибку, а где передавать дальше, когда тесты превращаются в кромешный ад из-за необходимости мокать каждый возможный путь ошибки — тогда возможно пригодится то, что я хочу предложить.
Проблема error-hell
Go славится своей прямолинейностью в обработке ошибок (при этом отпугивая новичков). Но в реальных приложениях такая простота оборачивается избыточностью, которая часто засоряет код и лишь усложняет сопровождение.
Представьте: вы пишете обработку списка элементов в цикле. Один или несколько элементов не удалось обработать — что делать? По канонам Go нужно накопить ошибки в цикле и вернуть их вверх. Но зачем, если вы хотите всего лишь продолжить обработку остальных элементов? Ошибка не влияет на логику программы, но заставляет писать лишний код передачи ошибок и дублировать логирование на каждом уровне.
Или горутины: как элегантно обработать ошибку в фоновой задаче? Канал ошибок? Контекст? А если ошибка не критична и нужна только для отладки?
А ещё игра в испорченный телефон, как следствие от повторных обработок ошибок: return fmt.Errorf("level1: %w", err), return fmt.Errorf("level2: %w", err), return fmt.Errorf("level3: %w", err). В итоге получаем многословные цепочки, которые дублируют путь ошибки вверх по стеку.
И главное — возврат ошибок служит двум целям: управление ветвлением программы и обеспечение тестируемости. Но что если эти цели можно разделить? Структурированное логирование с инструментами вроде comerc/spylog решает проблему тестирования без необходимости возвращать ошибки вверх по стеку.
Решение: минимальная надстройка в соглашениях, которая сохраняет дух Go — "явное лучше скрытого" и "обрабатывай ошибки по месту", но убирает механическую избыточность.
Принципы
- Обрабатывай ошибку по месту — если ошибка не влияет на ветвление программы
- Передавай ошибку вверх — только если нужно изменить ход выполнения программы
- Не дублируй логи — либо логируй ошибку, либо передавай её вверх
Паттерны
✅ Обработка по месту (не влияет на ветвление)
// В циклах
for _, item := range items {
    if err := processItem(item); err != nil {
        log.Error("failed to process item", "item", item.ID, "error", err)
        continue // продолжаем работу
    }
}
// В горутинах
go func() {
    if err := backgroundTask(); err != nil {
        log.Error("background task failed", "error", err)
        // не возвращаем ошибку - обработали по месту
    }
}()✅ Передача вверх (влияет на ветвление)
func validateUser(id string) error {
    user, err := db.GetUser(id)
    if err != nil {
        return err // передаём как есть, без обёртки
    }
    if user.Status != "active" {
        return ErrUserInactive // возвращаем типизированную ошибку
    }
    if user.Info == nil {
        return fmt.Errorf("user.Info is nil: %w", &ErrUserInfo{data: "data"}) 
        // уточняем ошибку - добавляем data
    }
    return nil
}
// Использование
func handleRequest(userID string) {
    if err := validateUser(userID); err != nil {
        if errors.Is(err, ErrUserInactive) {
            // специальная обработка
        }
        var userInfoErr *ErrUserInfo
        if errors.As(err, &userInfoErr) {
            // обработка для ErrUserInfo
        }
        log.Error("validation failed", "error", err) 
        // логируем ошибку, только когда не передаём выше по стеку
        return
    }
}❌ Избегаем обёртывания без цели
// Плохо - создаём информационный шум
func getConfig() (*Config, error) {
    data, err := os.ReadFile("config.json")
    if err != nil {
        return nil, fmt.Errorf("failed to read config: %w", err) // лишняя обёртка
    }
    // ...
}
// Хорошо - передаём как есть
func getConfig() (*Config, error) {
    data, err := os.ReadFile("config.json")
    if err != nil {
        return nil, err // передаём оригинальную ошибку
    }
    // ...
}Типизированные ошибки для ветвления
var (
    ErrUserNotFound = errors.New("user not found")
    ErrUserInactive = errors.New("user inactive")
    ErrInvalidInput = errors.New("invalid input")
)
// Проверка типа ошибки
if errors.Is(err, ErrUserNotFound) {
    // специальная обработка
}Структурированные логи вместо возврата ошибок
// Вместо возврата ошибки из горутины
func processInBackground(items []Item) {
    for _, item := range items {
        if err := process(item); err != nil {
            log.Error("item processing failed", 
                "item_id", item.ID,
                "error", err,
                "retry_count", item.RetryCount)
            // продолжаем обработку других элементов
        }
    }
}Правило принятия решения
Спроси себя: "Изменит ли эта ошибка ход выполнения программы?"
- Да → передай ошибку вверх, типизируя по необходимости
- Нет → залогируй и продолжай выполнение
Это соглашение сохраняет простоту Go и убирает избыточность без введения сложных абстракций.
Тестирование с применением структурированных логов
Зачем spylog?
Когда мы обрабатываем ошибки по месту через логирование, возникает вопрос: как тестировать такой код? Вместо проверки возвращённой ошибки нам нужен способ убедиться, что логирование действительно произошло с правильными данными.
Библиотека comerc/spylog перехватывает записи в лог для конкретного модуля в рамках теста, позволяя проверить:
- Случилось ли логирование
- Правильное ли сообщение
- Корректные ли атрибуты (включая тип ошибки)
Пример с spylog
// Код модуля
type UserService struct {
    log *slog.Logger
}
func NewUserService() *UserService {
    return &UserService{
        log: slog.With("module", "user_service"),
    }
}
func (s *UserService) ProcessUsers(users []User) {
    for _, user := range users {
        if err := s.validateUser(user); err != nil {
            // Логируем ошибку по месту, не возвращаем вверх
            s.log.Error("user validation failed", 
                "user_id", user.ID,
                "email", user.Email,
                "error", err,
            )
            continue // продолжаем обработку других пользователей
        }
        // обрабатываем валидного пользователя
    }
}
// Тест
func TestProcessUsers(t *testing.T) {
    var service *UserService
    logHandler := spylog.GetModuleLogHandler("user_service", t.Name(), func() {
        service = NewUserService()
    })
    users := []User{
        {ID: "1", Email: "valid@example.com"},
        {ID: "2", Email: "invalid-email"}, // невалидный
    }
    service.ProcessUsers(users)
    // Проверяем что залогирована одна ошибка
    require.Len(t, logHandler.Records, 1)
    record := logHandler.Records[0]
    assert.Equal(t, "user validation failed", record.Message)
    assert.Equal(t, "2", spylog.GetAttrValue(record, "user_id"))
    assert.Equal(t, "invalid-email", spylog.GetAttrValue(record, "email"))
    assert.Equal(t, ErrInvalidEmail, errors.Is(spylog.GetAttrValue(record, "error"))
}Преимущества подхода
- Разделение ответственности: ошибки для ветвления vs ошибки для отладки
- Простота тестирования: проверяем логи вместо возвращаемых ошибок
- Меньше шаблонного кода: не нужно передавать ошибки вверх по стеку
P.S. проблема тестирования асинхронного кода так же решается с применением этого подхода через другую надстройку — synctest-experiment.
Комментарии (4)
 - RunSoFun26.05.2025 19:35- func validateUser(id string) error { ... userInfo, err := db.GetUserInfo(id) if err != nil { return errors.As(err, &ErrUserInfo) // уточняем ошибку } ... }- Ошибка будет, так как - errors.As(err, &ErrUserInfo)возвращает- true|false.
 
           
 
blind_oracle
Жалко что пропозал с улучшением обработки ошибок не прошёл.
Механизм хотя бы как в Расте уже был бы большим шагом вперёд...
comerc Автор
Любитесь как хотите с тем что есть. Если я правильно понимаю дзен Go.