Опирается на правила: R-VLD-WHERE-1R-VLD-WHERE-4 и R-VLD-WHERE-X1R-VLD-WHERE-X4 из Validation Style Guide → раздел 1. Где валидируем.

Важно знать

  • В Go нет фреймворкового «до-handler» декоратора — граница строится вручную через httpreq.Decode[T] или chi-middleware.
  • Граница: json.Decode + validate.Struct в одном хелпере; Handler получает уже чистый struct.
  • Конфиг: envconfig.Process + validate.Struct в config.Load(); при ошибке — os.Exit(1) до принятия трафика.
  • Домен: инварианты — в методах агрегата, возвращающих доменную ошибку (apperr.Kind = Domain); не в struct-тегах.
  • Nested struct — рекурсивная валидация через тег dive для слайсов или явный validate.Struct для вложенного объекта.
  • Handler не вызывает validate.Struct — он получает команду, собранную из уже чистого DTO.
  • Ручной if req.X == "" { return 400 } в Handler — антипаттерн: теряется единый формат errors-массива в problem+json.

В Go нет @Valid над параметром, который запустил бы валидацию автоматически. Граница строится явно — и это хорошо: место валидации видно в коде, а не скрыто за AOP-перехватчиком. Главное — соблюдать дисциплину: всё до Handler, ничего после.

Место 1: граница — httpreq.Decode

R-VLD-WHERE-1: декодирование JSON и валидация выносятся в хелпер httpreq.Decode[T]. Handler вызывает его первой строкой и при ошибке сразу отдаёт 400.

// edge/httpreq/decode.go
package httpreq

import (
    "encoding/json"
    "net/http"

    "github.com/go-playground/validator/v10"
    "yourservice/internal/validation"
    "yourservice/core/apperr"
)

var validate = validator.New()

func init() {
    validation.RegisterCustomTags(validate)
    validation.RegisterStructValidations(validate)
}

func Decode[T any](r *http.Request) (T, error) {
    var req T
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        return req, apperr.NewValidation("некорректный JSON")
    }
    if err := validate.Struct(&req); err != nil {
        return req, validation.ToValidationError(err)
    }
    return req, nil
}

Handler принимает уже чистую структуру:

// edge/order/handler.go
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
    req, err := httpreq.Decode[CreateOrderRequest](r)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    result, err := h.uc.Handle(r.Context(), toCreateOrderCommand(req))
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    render.JSON(w, http.StatusCreated, result)
}

Схема CreateOrderRequest:

type CreateOrderRequest struct {
    CustomerID string             `json:"customer_id" validate:"required,uuid4"`
    Items      []OrderItemRequest `json:"items"       validate:"required,min=1,dive"`
}

type OrderItemRequest struct {
    ProductID string `json:"product_id" validate:"required,uuid4"`
    Qty       int    `json:"qty"        validate:"required,min=1"`
}

dive заставляет validator/v10 рекурсивно провалидировать каждый элемент слайса — это R-VLD-WHERE-4.

Место 2: конфиг — fail-fast на старте

R-VLD-WHERE-2: приложение не должно стартовать с битым конфигом. envconfig.Process + validate.Struct вызываются до того, как порт открылся.

// config/config.go
type Config struct {
    DatabaseURL string        `envconfig:"DATABASE_URL" required:"true"`
    RedisAddr   string        `envconfig:"REDIS_ADDR"   required:"true" validate:"hostname_port"`
    HTTPTimeout time.Duration `envconfig:"HTTP_TIMEOUT" default:"5s"   validate:"min=1s,max=60s"`
    Payment     PaymentConfig
}

type PaymentConfig struct {
    BaseURL    string `envconfig:"PAYMENT_BASE_URL" required:"true" validate:"url"`
    APIKey     string `envconfig:"PAYMENT_API_KEY"  required:"true" validate:"min=32"`
    TimeoutSec int    `envconfig:"PAYMENT_TIMEOUT"  default:"10"   validate:"min=1,max=120"`
}

func Load() (*Config, error) {
    var cfg Config
    if err := envconfig.Process("APP", &cfg); err != nil {
        return nil, fmt.Errorf("config: %w", err)
    }
    if err := validate.Struct(&cfg); err != nil {
        return nil, fmt.Errorf("config validate: %w", err)
    }
    return &cfg, nil
}

В main.go:

cfg, err := config.Load()
if err != nil {
    slog.Error("invalid config", "error", err)
    os.Exit(1)
}

os.Exit(1) до http.ListenAndServe — сервис либо стартует корректно, либо не стартует вообще. Healthcheck сразу красный, деплой откатывается.

Место 3: домен — метод агрегата

R-VLD-WHERE-3: доменные инварианты — не struct-теги. Агрегат проверяет состояние в методах и возвращает доменную ошибку.

// core/order/order.go
type Order struct {
    id     OrderID
    status Status
    items  []Item
}

func (o *Order) Confirm() error {
    if o.status != StatusCreated {
        return &AlreadyConfirmedError{ID: o.id, Current: o.status}
    }
    if len(o.items) == 0 {
        return &EmptyOrderError{ID: o.id}
    }
    o.status = StatusConfirmed
    return nil
}

AlreadyConfirmedError.Kind() возвращает apperr.Domain → edge-renderer отдаёт 409 с конкретным code. Struct-тег validate:"..." на поле агрегата — нарушение: поля иммутабельны после конструирования, тег не перепроверяет состояние при каждом вызове метода.

Handler не валидирует

Главное правило: в Handler нет ни validate.Struct, ни if-проверок на входные данные. Команда уже чистая — контракт проверен на границе, инварианты — в агрегате.

// ПЛОХО — ручная валидация в Handler
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*OrderID, error) {
    if cmd.CustomerID == "" {
        return nil, errors.New("customer_id required")
    }
    if len(cmd.Items) == 0 {
        return nil, errors.New("items required")
    }
    // ...
}

// ХОРОШО — Handler доверяет чистоте команды
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*OrderID, error) {
    customer, err := h.customers.FindByID(ctx, cmd.CustomerID)
    if err != nil {
        return nil, err
    }
    order, err := h.factory.Create(customer, cmd.Items)
    if err != nil {
        return nil, err
    }
    return h.orders.Save(ctx, order)
}

Ошибка errors.New("customer_id required") в Handler не несёт Kind-а — httperr.Write не распознает её как 400 и отдаст 500. Формат errors-массива problem+json не применяется. Клиент видит не «поле X отсутствует», а «Internal Server Error».

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

АнтипаттернПравилоЧто взамен
Ручной if req.X == "" в Handler для входной валидацииR-VLD-WHERE-X1httpreq.Decode[T] с validate.Struct на границе
Повторный validate.Struct UseCase-команды после чистого DTOR-VLD-WHERE-X2Команда конструируется из уже чистого DTO; повторная валидация не нужна
envconfig.Process без validate.Struct для конфигаR-VLD-WHERE-X3config.Load() с validate.Struct + os.Exit(1)
validate:"required,min=1" на полях агрегатаR-VLD-WHERE-X4Проверка в методе агрегата, возвращает доменную ошибку
dive забыт на слайсе вложенных structR-VLD-WHERE-4validate:"required,min=1,dive" для рекурсивной валидации элементов

Куда дальше

  • Validation → раздел 1. Где валидируем — нормативные формулировки R-VLD-WHERE-*.
  • Стандартные constraints — теги required, min, email, uuid4 и другие в validator/v10.
  • Custom constraints — RegisterValidation для доменных форматов.
  • Конфигурация — envconfig + validate.Struct подробно.
  • Error Handling → apperr — как apperr.ValidationError превращается в 400 problem+json.