Go обходится без исключений: ошибка — это значение, которое функция возвращает, а вызывающий проверяет. Многословно — if err != nil на каждом шагу, — зато путь ошибки виден в коде, а не прячется в невидимых try/catch. Для UCP-сервиса это значит: доменные ошибки выражаются явно, а перевод их в HTTP-коды собран в одном месте.

Ошибка — это значение

Функция, которая может не справиться, возвращает error последним значением; вызывающий обязан его проверить.

product, err := h.repo.Find(ctx, id)
if err != nil {
    return Product{}, err
}

Это не церемония ради церемонии: каждая точка отказа видна. Игнорировать ошибку приходится осознанно (_ = ...), а не по забывчивости.

Обёртка: %w

Возвращая ошибку выше, её оборачивают через fmt.Errorf с %w — это добавляет контекст, сохраняя исходную ошибку для проверок.

func (r *ProductRepository) Find(ctx context.Context, id int) (Product, error) {
    row := r.pool.QueryRow(ctx, `SELECT id, name, price FROM products WHERE id = $1`, id)
    var p Product
    if err := row.Scan(&p.ID, &p.Name, &p.Price); err != nil {
        return Product{}, fmt.Errorf("find product %d: %w", id, err)
    }
    return p, nil
}

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

Sentinel и типизированные ошибки

Чтобы вызывающий мог различать виды ошибок, домен объявляет их явно — как значения (sentinel) или типы.

var ErrProductNotFound = errors.New("product not found")
if errors.Is(err, pgx.ErrNoRows) {
    return Product{}, ErrProductNotFound
}

Теперь Handler возвращает доменную ErrProductNotFound, ничего не зная про HTTP. Если ошибке нужны данные (какой id), её делают типом и достают через errors.As.

Перевод в HTTP

Доменные ошибки переводят в коды в одном месте — функцией-маппером, которую зовёт обработчик.

func mapError(err error) int {
    switch {
    case errors.Is(err, ErrProductNotFound):
        return http.StatusNotFound
    case errors.Is(err, ErrForbidden):
        return http.StatusForbidden
    default:
        return http.StatusInternalServerError
    }
}
product, err := h.createProduct.Handle(r.Context(), cmd)
if err != nil {
    writeError(w, mapError(err), err)
    return
}

Так домен бросает доменное, а маппер переводит в протокол — единый формат ответа об ошибке в одной точке. Это аналог @ControllerAdvice в Spring-биндинге, только явный.

Panic — не для управления

panic в Go — для невосстановимого (программная ошибка, испорченное состояние), а не для бизнес-случаев вроде «не найдено». Бизнес-отказы — это error, возвращаемый значением. На случай непредвиденной паники в обработчике ставят recover в middleware, чтобы один сбойный запрос не уронил сервер. Использовать panic/recover как замену исключений для control flow — антипаттерн.

Где это в UCP

Явные ошибки — это видимый контракт отказа: Handler и домен возвращают доменные ошибки (ErrProductNotFound), маппер переводит их в HTTP, panic остаётся для настоящих катастроф. Никакой скрытой раскрутки стека — путь каждой ошибки читается в коде. Это требует дисциплины (if err != nil повсюду), но даёт продукт-инженеру полную предсказуемость отказов; то, что прошло через маппер, видно в наблюдаемости, и понятно, что и почему вернулось клиенту.