Опирается на правила:
R-ERR-WHERE-1…R-ERR-WHERE-3иR-ERR-WHERE-X1…R-ERR-WHERE-X3из Error Handling Style Guide → раздел 2. Где throw, где catch.
Важно знать
return err— везде где нужно. Добавляй контекстfmt.Errorf("load order %s: %w", id, err), сохраняй%wдляerrors.As.- Обработка — ровно в трёх местах:
- Edge —
httperr.Writeв chi-handler (или тонком адаптере). Превращает ошибку в HTTP-ответ.- Out-adapter — маппит транспорт/4xx/5xx → port-specific ошибку с
Kind().- Retry/CB-обёртка —
retry.Do/gobreaker.Executeвокруг adapter-вызова.- В UseCase Handler / Domain Service / Aggregate — ноль
recover(). Ошибки только возвращаются вверх.if err != nil { slog.Error(...); return nil }— главный антипаттерн. Глушит ошибку, возвращает «успех».fmt.Errorf("%v", err)без%w— теряет цепочку,errors.Asперестаёт работать.return SomeStruct{}, nilпри фактической ошибке — нулевое значение скрывает проблему.
Главный принцип совпадает с любым языком: ошибка — часть контракта, не неожиданность. Разница с Java в форме: return err вместо throw, httperr.Write вместо @RestControllerAdvice, errors.As вместо catch(SomeException). Но три точки обработки — ровно те же. Раскрытие правил R-ERR-WHERE-* ниже.
Return — без церемоний
R-ERR-WHERE-1: возвращаем ошибку там, где обнаружили проблему. Добавляем контекст через %w.
// core/order/order.go — домен возвращает типизированную ошибку
func (o *Order) Cancel(reason string) error {
if o.status == StatusShipped {
return &OrderAlreadyShippedError{OrderID: o.id}
}
o.status = StatusCancelled
o.cancellationReason = reason
return nil
}
// core/order/handler.go — UseCase Handler добавляет контекст и пробрасывает
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
ord, err := h.repo.FindByID(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("cancel order %s: find: %w", cmd.OrderID, err)
}
if err := ord.Cancel(cmd.Reason); err != nil {
return fmt.Errorf("cancel order %s: %w", cmd.OrderID, err)
}
return h.repo.Save(ctx, ord)
}
fmt.Errorf("...: %w", err) сохраняет цепочку. errors.As на edge достанет *OrderAlreadyShippedError сквозь обёртки.
Точка 1 — edge-renderer в chi
R-ERR-WHERE-2 (edge): единый httperr.Write вызывается в chi-handler (или тонком адаптере поверх handler'а). Никаких w.WriteHeader и json.Encode напрямую при ошибке.
// edge/httperr/render.go
package httperr
import (
"encoding/json"
"errors"
"net/http"
"git.example.ru/sber/core/apperr"
orderDom "git.example.ru/sber/core/order"
)
func Write(w http.ResponseWriter, r *http.Request, err error) {
kind := apperr.KindOf(err)
status, title := mapKind(kind)
logByKind(r.Context(), kind, err)
incrementMetric(err)
detail := sanitize(err)
body := map[string]any{
"type": typeURL(err),
"title": title,
"status": status,
"detail": detail,
"traceId": traceID(r),
}
var insuf *orderDom.InsufficientFundsError
if errors.As(err, &insuf) {
body["customerId"] = insuf.CustomerID
body["available"] = insuf.Available
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
Chi-handler выглядит так:
// edge/handler/order_handler.go
func (h *OrderHandler) CancelOrder(w http.ResponseWriter, r *http.Request) {
cmd := CancelOrderCommand{
OrderID: chi.URLParam(r, "id"),
Reason: r.URL.Query().Get("reason"),
}
if err := h.cancelHandler.Handle(r.Context(), cmd); err != nil {
httperr.Write(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
Что важно:
- catch-all —
recover-middleware, не отдельный handler. Он живёт выше в цепочке chi. - Один
httperr.Writeна сервис — разные обработчики путей, одна точка маппинга в HTTP. logByKindвызывается внутриWrite— логируем один раз, на edge (R-ERR-LOG-4).
recover-middleware как catch-all для panic (R-ERR-MAP-5):
// edge/middleware/recover.go
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
"git.example.ru/sber/edge/httperr"
)
func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
slog.ErrorContext(r.Context(), "panic recovered",
"panic", v,
"stack", string(debug.Stack()),
)
httperr.WritePanic(w, r)
}
}()
next.ServeHTTP(w, r)
})
}
Регистрация в роутере:
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.TraceID)
r.Post("/orders/{id}/cancel", orderHandler.CancelOrder)
Точка 2 — out-adapter (integration boundary)
R-ERR-WHERE-2 (out-adapter): HTTP-клиент маппит транспорт/4xx/5xx в port-specific ошибку с Kind().
// adapters/out/payment/client.go
package payment
import (
"context"
"errors"
"fmt"
"net/http"
)
func (c *Client) Register(ctx context.Context, cmd RegisterCommand) (RegisterResult, error) {
resp, err := c.do(ctx, cmd)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return RegisterResult{}, &GatewayError{Op: "register", Err: fmt.Errorf("timeout: %w", err)}
}
return RegisterResult{}, &GatewayError{Op: "register", Err: err}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return RegisterResult{}, &InvalidPaymentRequestError{OrderID: cmd.OrderID}
}
if resp.StatusCode >= 500 {
return RegisterResult{}, &GatewayError{
Op: "register",
Err: fmt.Errorf("upstream status %d", resp.StatusCode),
}
}
return decode(resp)
}
Что важно:
- 4xx →
InvalidPaymentRequestErrorсKind() Domain— no-retry, edge вернёт 422. - 5xx / timeout →
GatewayErrorсKind() Integration— retry-safe при идемпотентности. - Транспортная ошибка обёрнута в
GatewayErrorчерезUnwrap()— цепочка%wсохранена,errors.Is(err, context.DeadlineExceeded)работает.
Аналогично для каталога:
// adapters/out/catalog/client.go
func (c *Client) FindProduct(ctx context.Context, productID string) (Product, error) {
resp, err := c.do(ctx, productID)
if err != nil {
return Product{}, &PortError{Op: "find-product", Err: err}
}
if resp.StatusCode == http.StatusNotFound {
return Product{}, &ProductNotFoundError{ProductID: productID}
}
if resp.StatusCode >= 500 {
return Product{}, &PortError{Op: "find-product", Err: fmt.Errorf("status %d", resp.StatusCode)}
}
return decode(resp)
}
Точка 3 — retry/CB-обёртка
R-ERR-WHERE-2 (retry/CB): retry.Do / gobreaker.Execute вокруг adapter-вызова — формальная точка обработки через конфиг.
// core/order/handler.go — UseCase Handler оборачивает port-вызов
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
// retry только на Integration-ошибках, детали — в retry-semantics.md
var result RegisterResult
err := retry.Do(
func() error {
var e error
result, e = h.paymentPort.Register(ctx, toPaymentCmd(cmd))
return e
},
retry.RetryIf(func(err error) bool {
var g *payment.GatewayError
return errors.As(err, &g)
}),
retry.Attempts(3),
retry.DelayType(retry.BackOffDelay),
retry.Context(ctx),
)
if err != nil {
return fmt.Errorf("register payment for order %s: %w", cmd.OrderID, err)
}
return nil
}
Нигде больше — ноль recover() в домене
R-ERR-WHERE-3: в UseCase Handler / Domain Service / Aggregate — только return err. Никаких recover(), никаких вложенных if err != nil { ... } кроме propagation.
// ХОРОШО
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (Order, error) {
customer, err := h.customerRepo.FindByID(ctx, cmd.CustomerID)
if err != nil {
return Order{}, fmt.Errorf("create order: find customer %s: %w", cmd.CustomerID, err)
}
product, err := h.productPort.FindProduct(ctx, cmd.ProductID)
if err != nil {
return Order{}, fmt.Errorf("create order: find product %s: %w", cmd.ProductID, err)
}
ord, err := order.New(customer, product, cmd.Quantity)
if err != nil {
return Order{}, fmt.Errorf("create order: %w", err)
}
if err := h.orderRepo.Save(ctx, ord); err != nil {
return Order{}, fmt.Errorf("create order: save: %w", err)
}
return ord, nil
}
order.New бросит &InsufficientFundsError{...} если правило нарушено — handler пробросит его вверх с контекстом. Edge вызовет httperr.Write, который через apperr.KindOf → Domain → 422.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
if err != nil { slog.Error(...); return nil } | R-ERR-WHERE-X1 | return fmt.Errorf("...: %w", err) |
_ = doSomething() (игнор возвращённой ошибки) | R-ERR-WHERE-X1 | всегда проверять и проброс |
fmt.Errorf("%v", err) — без %w | R-ERR-WHERE-X2 | fmt.Errorf("...: %w", err) |
return errors.New(err.Error()) | R-ERR-WHERE-X2 | return fmt.Errorf("...: %w", err) |
return Order{}, nil при фактической ошибке | R-ERR-WHERE-X3 | return Order{}, err |
recover() в UseCase Handler или домене | R-ERR-WHERE-3 | только в Recoverer-middleware на edge |
R-ERR-WHERE-X1 — проглатывание ошибки:
// ПЛОХО
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
ord, err := order.New(cmd.CustomerID, cmd.Items)
if err != nil {
slog.Error("failed to create order", "error", err) // ← логируем
return nil, nil // ← возвращаем «успех» с nil
}
return ord, nil
}
Вызывающий получит nil, nil — интерпретирует как «заказ не найден» или упадёт с nil-pointer через несколько строк. Stacktrace в логах не привязан к trace — origin потерян.
R-ERR-WHERE-X2 — разрыв цепочки %w:
// ПЛОХО
func (r *orderRepo) FindByID(ctx context.Context, id string) (Order, error) {
row, err := r.db.QueryRowContext(ctx, queryFindOrder, id)
if err != nil {
return Order{}, errors.New(err.Error()) // ← новая ошибка без цепочки
}
// ...
}
errors.As(err, &pgErr) на edge перестаёт работать — тип из драйвера недостижим. Нужно fmt.Errorf("find order %s: %w", id, err).
Куда дальше
- Иерархия ошибок — какие типы с каким
Kind()возвращать. - Mapping в ProblemDetails — что делает
httperr.Writeс пойманными. - Логирование ошибок — где
slog.WarnContext, гдеslog.ErrorContext. - Retry-семантика — какие ошибки retry-safe.
- Errors-as-values vs panic — когда
(T, error)и когдаpanic. - Observability ошибок — метрики и трейсинг.
- Shared-контракт R-ERR-WHERE — нормативные формулировки.