Опирается на правила:
R-ERR-HIER-1…R-ERR-HIER-5иR-ERR-HIER-X1…R-ERR-HIER-X2из Error Handling Style Guide → раздел 1. Иерархия исключений.
Важно знать
- Четыре категории:
Domain(409/422, no-retry),Validation(400, no-retry),Integration(502/503/504, retry-safe),Technical(500, retry-возможно).- Иерархии классов нет — Go использует маркер-метод
Kind() apperr.Kindна типизированных структурах. Edge распознаёт категорию черезerrors.As.- Имена по бизнес-смыслу:
InsufficientFundsError,OrderAlreadyShippedError. НеBusinessError, неfmt.Errorf("failed").- Integration-ошибки с префиксом системы:
PaymentGatewayError,CatalogPortError— edge видит «у платёжки» vs «у каталога».- Конструктор с контекстом:
&InsufficientFundsError{CustomerID: id, Requested: 5000, Available: 200}. Поля доступны edge-renderer'у для extension-полей в ProblemDetails.errors.New("something failed")без типа → edge классифицирует какTechnical→ 500. Для бизнес-правил — типизированная структура сKind() Domain.panic(...)в бизнес-правиле — запрещён.panicтолько для невосстановимых программистских ошибок.
В Go нет исключений — ошибки это значения типа error. Это меняет форму, но не суть контракта: каждая ошибка должна иметь категорию и контекст, иначе edge не сможет вернуть осмысленный HTTP-статус и метрику. Раскрытие правил R-ERR-HIER-* ниже.
Маркер категории — apperr.Kind
R-ERR-HIER-1 / R-ERR-HIER-2: четыре категории реализуются через Kind() — маркер-метод интерфейса Categorized. Базовый пакет:
// core/apperr/apperr.go
package apperr
import "errors"
type Kind int
const (
Domain Kind = iota + 1
Validation
Integration
Technical
)
type Categorized interface {
Kind() Kind
}
func KindOf(err error) Kind {
var c Categorized
if errors.As(err, &c) {
return c.Kind()
}
return Technical
}
KindOf разворачивает цепочку %w-обёрток и достаёт категорию. Если цепочка не содержит Categorized — ошибка неизвестного происхождения, классифицируется как Technical (→ 500, ERROR-лог).
Где живут ошибки каждой категории:
| Категория | Пакет | Кто бросает |
|---|---|---|
Domain | core/<bounded-context>/ | агрегат, domain service |
Validation | edge/ (chi-handler или middleware) | валидатор input |
Integration | adapters/out/<system>/ | HTTP-клиент, gRPC-клиент |
Technical | любой, редко явно | OOM, nil-map, proxy |
Доменные ошибки — по бизнес-смыслу
R-ERR-HIER-3: имя отвечает на вопрос «что нарушено», а не «что упало технически».
// core/order/errors.go
package order
import (
"fmt"
"git.example.ru/sber/core/apperr"
)
type InsufficientFundsError struct {
CustomerID string
Requested int64
Available int64
}
func (e *InsufficientFundsError) Error() string {
return fmt.Sprintf("insufficient funds: customer=%s requested=%d available=%d",
e.CustomerID, e.Requested, e.Available)
}
func (e *InsufficientFundsError) Kind() apperr.Kind { return apperr.Domain }
type OrderAlreadyShippedError struct {
OrderID string
}
func (e *OrderAlreadyShippedError) Error() string {
return fmt.Sprintf("order already shipped: order=%s", e.OrderID)
}
func (e *OrderAlreadyShippedError) Kind() apperr.Kind { return apperr.Domain }
R-ERR-HIER-5: конструктор фиксирует контекст. Поля доступны edge-renderer'у:
// edge/httperr/render.go — достаём контекст для ProblemDetails extension
var insuf *order.InsufficientFundsError
if errors.As(err, &insuf) {
body["customerId"] = insuf.CustomerID
body["requested"] = insuf.Requested
body["available"] = insuf.Available
}
Без полей — renderer не может добавить структурированный контекст в ответ. getMessage() в виде строки теряет типизацию при маппинге.
Другие доменные ошибки по модели:
// core/product/errors.go
type ProductOutOfStockError struct {
ProductID string
Available int
}
func (e *ProductOutOfStockError) Error() string {
return fmt.Sprintf("product out of stock: product=%s available=%d", e.ProductID, e.Available)
}
func (e *ProductOutOfStockError) Kind() apperr.Kind { return apperr.Domain }
// core/customer/errors.go
type CustomerNotEligibleForRefundError struct {
CustomerID string
Reason string
}
func (e *CustomerNotEligibleForRefundError) Error() string {
return fmt.Sprintf("customer not eligible for refund: customer=%s reason=%s",
e.CustomerID, e.Reason)
}
func (e *CustomerNotEligibleForRefundError) Kind() apperr.Kind { return apperr.Domain }
Integration-ошибки с префиксом системы
R-ERR-HIER-4: каждый out-adapter объявляет свои ошибки с именем системы. Edge различает источник сбоя.
// adapters/out/payment/errors.go
package payment
import (
"fmt"
"git.example.ru/sber/core/apperr"
)
type GatewayError struct {
Op string
Err error
}
func (e *GatewayError) Error() string {
return fmt.Sprintf("payment gateway %s: %v", e.Op, e.Err)
}
func (e *GatewayError) Unwrap() error { return e.Err }
func (e *GatewayError) Kind() apperr.Kind { return apperr.Integration }
type InvalidPaymentRequestError struct {
OrderID string
}
func (e *InvalidPaymentRequestError) Error() string {
return fmt.Sprintf("invalid payment request: order=%s", e.OrderID)
}
func (e *InvalidPaymentRequestError) Kind() apperr.Kind { return apperr.Domain }
// adapters/out/catalog/errors.go
package catalog
import (
"fmt"
"git.example.ru/sber/core/apperr"
)
type PortError struct {
Op string
Err error
}
func (e *PortError) Error() string { return fmt.Sprintf("catalog port %s: %v", e.Op, e.Err) }
func (e *PortError) Unwrap() error { return e.Err }
func (e *PortError) Kind() apperr.Kind { return apperr.Integration }
Edge-renderer возвращает разные сообщения:
var gw *payment.GatewayError
var cp *catalog.PortError
switch {
case errors.As(err, &gw):
detail = "платёжная система временно недоступна"
case errors.As(err, &cp):
detail = "каталог продуктов временно недоступен"
}
Наблюдаемость: метрика app_errors_total{type="integration", exception="payment.GatewayError"} и app_errors_total{type="integration", exception="catalog.PortError"} — отдельные серии в Prometheus. Алёрт настраивается на каждую систему независимо.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
fmt.Errorf("order shipped") без типа в бизнес-правиле | R-ERR-HIER-X1 | &OrderAlreadyShippedError{OrderID: id} с Kind() Domain |
errors.New("insufficient funds") без структуры | R-ERR-HIER-X1 | типизированная структура с полями контекста |
panic("cannot cancel shipped order") в агрегате | R-ERR-HIER-X2 | return &OrderAlreadyShippedError{...} |
type BusinessError struct{ msg string } — одна ошибка на всё | R-ERR-HIER-X1 | отдельный тип на каждое бизнес-правило |
log.Fatal(err) в доменном коде | R-ERR-HIER-X2 | return nil, err до edge |
R-ERR-HIER-X1 — возврат нетипизированной ошибки туда, где edge ждёт категорию:
// ПЛОХО
func (o *Order) cancel() error {
if o.status == StatusShipped {
return fmt.Errorf("cannot cancel shipped order") // ← категории нет → Technical → 500
}
o.status = StatusCancelled
return nil
}
// ХОРОШО
func (o *Order) cancel() error {
if o.status == StatusShipped {
return &OrderAlreadyShippedError{OrderID: o.id}
}
o.status = StatusCancelled
return nil
}
R-ERR-HIER-X2 — panic для бизнес-правила:
// ПЛОХО
func (o *Order) ship() {
if o.status != StatusConfirmed {
panic("cannot ship non-confirmed order") // ← recover-middleware поймает → 500, стектрейс в логах
}
o.status = StatusShipped
}
// ХОРОШО
func (o *Order) ship() error {
if o.status != StatusConfirmed {
return &OrderNotConfirmedError{OrderID: o.id, Status: o.status}
}
o.status = StatusShipped
return nil
}
panic остаётся только для настоящих программистских ошибок — nil-pointer в конструкторе с инвариантом, которые unit-тест должен поймать до прода.
Куда дальше
- Где return, где обрабатывать — что куда идёт после
return err. - Mapping в ProblemDetails — как
apperr.KindOfпревращается в HTTP-статус. - Логирование ошибок — WARN для Domain, ERROR для Technical, один раз на edge.
- Retry-семантика — какие категории retry-safe.
- Errors-as-values vs panic — когда
(T, error)и когдаpanicдопустим. - Observability ошибок — метрики и трейсинг по категории.
- Shared-контракт R-ERR-HIER — нормативные формулировки.