Опирается на правила: R-ERR-WHERE-1R-ERR-WHERE-3 и R-ERR-WHERE-X1R-ERR-WHERE-X3 из Error Handling Style Guide → раздел 2. Где throw, где catch.

Важно знать

  • return err — везде где нужно. Добавляй контекст fmt.Errorf("load order %s: %w", id, err), сохраняй %w для errors.As.
  • Обработка — ровно в трёх местах:
    1. Edge — httperr.Write в chi-handler (или тонком адаптере). Превращает ошибку в HTTP-ответ.
    2. Out-adapter — маппит транспорт/4xx/5xx → port-specific ошибку с Kind().
    3. 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.KindOfDomain → 422.

Что запрещено

АнтипаттернПравилоЧто взамен
if err != nil { slog.Error(...); return nil }R-ERR-WHERE-X1return fmt.Errorf("...: %w", err)
_ = doSomething() (игнор возвращённой ошибки)R-ERR-WHERE-X1всегда проверять и проброс
fmt.Errorf("%v", err) — без %wR-ERR-WHERE-X2fmt.Errorf("...: %w", err)
return errors.New(err.Error())R-ERR-WHERE-X2return fmt.Errorf("...: %w", err)
return Order{}, nil при фактической ошибкеR-ERR-WHERE-X3return 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 — нормативные формулировки.