Опирается на правила:
R-VLD-WHERE-1…R-VLD-WHERE-4иR-VLD-WHERE-X1…R-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-X1 | httpreq.Decode[T] с validate.Struct на границе |
Повторный validate.Struct UseCase-команды после чистого DTO | R-VLD-WHERE-X2 | Команда конструируется из уже чистого DTO; повторная валидация не нужна |
envconfig.Process без validate.Struct для конфига | R-VLD-WHERE-X3 | config.Load() с validate.Struct + os.Exit(1) |
validate:"required,min=1" на полях агрегата | R-VLD-WHERE-X4 | Проверка в методе агрегата, возвращает доменную ошибку |
dive забыт на слайсе вложенных struct | R-VLD-WHERE-4 | validate:"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.