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 повсюду), но даёт продукт-инженеру полную предсказуемость отказов; то, что прошло через маппер, видно в наблюдаемости, и понятно, что и почему вернулось клиенту.