Опирается на правила: R-ERR-HIER-1R-ERR-HIER-5 и R-ERR-HIER-X1R-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-лог).

Где живут ошибки каждой категории:

КатегорияПакетКто бросает
Domaincore/<bounded-context>/агрегат, domain service
Validationedge/ (chi-handler или middleware)валидатор input
Integrationadapters/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-X2return &OrderAlreadyShippedError{...}
type BusinessError struct{ msg string } — одна ошибка на всёR-ERR-HIER-X1отдельный тип на каждое бизнес-правило
log.Fatal(err) в доменном кодеR-ERR-HIER-X2return 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-X2panic для бизнес-правила:

// ПЛОХО
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 — нормативные формулировки.