Опирается на правила: R-VLD-GRP-1R-VLD-GRP-2 и R-VLD-GRP-X1R-VLD-GRP-X2 из Validation Style Guide → раздел 4. Validation groups.

Важно знать

  • validator/v10 не имеет встроенных validation groups в смысле Jakarta.
  • Идиоматичный Go-ответ на «разные required в разных сценариях» — отдельные struct-типы.
  • CreateOrderRequest и DraftOrderRequest — не дублирование, а явное разграничение контракта.
  • Когда один struct с разными сценариями оправдан (редко) — validate.StructCtx + context-ключ + RegisterValidationCtx.
  • Признак что один struct обслуживает слишком много сценариев: 3+ набора условий типа «поле X обязательно только если поле Y = ...».
  • Разные required-поля для create/update — это два разных API-контракта; лучше два struct, чем один с pointer-флагами.

В Go нет механизма групп как в Jakarta (@NotNull(groups = OnCreate.class)). Философия языка — явность: разные сценарии = разные типы. Раскрытие раздела 4 гайда с учётом идиом Go.

Предпочтительно: отдельные struct-типы

R-VLD-GRP-1 в Go реализуется через разные struct для разных сценариев.

Канонический пример — создание заказа и черновик:

// Полный заказ: customerID и items обязательны
type CreateOrderRequest struct {
    CustomerID string             `json:"customer_id" validate:"required,uuid4"`
    Items      []OrderItemRequest `json:"items"       validate:"required,min=1,dive"`
    Comment    string             `json:"comment"     validate:"max=1000"`
}

// Черновик: customerID и items опциональны
type DraftOrderRequest struct {
    CustomerID *string             `json:"customer_id" validate:"omitempty,uuid4"`
    Items      []OrderItemRequest  `json:"items"       validate:"omitempty,dive"`
    Comment    string              `json:"comment"     validate:"max=1000"`
}

Обработчики:

// POST /orders
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
    }
    // ...
}

// POST /orders/draft
func (h *OrderHandler) SaveDraft(w http.ResponseWriter, r *http.Request) {
    req, err := httpreq.Decode[DraftOrderRequest](r)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }
    // ...
}

Два отдельных struct — два отдельных контракта. Изменение правил для черновика не затрагивает контракт создания заказа.

Пример с PATCH: partial update

Частичное обновление продукта — все поля опциональны, но если переданы, то валидны:

type UpdateProductRequest struct {
    Name        *string `json:"name"        validate:"omitempty,min=1,max=200"`
    Description *string `json:"description" validate:"omitempty,max=2000"`
    PriceCop    *int64  `json:"price_cop"   validate:"omitempty,gt=0"`
    Stock       *int    `json:"stock"       validate:"omitempty,min=0,max=999999"`
}

type CreateProductRequest struct {
    Name        string `json:"name"        validate:"required,min=1,max=200"`
    Description string `json:"description" validate:"max=2000"`
    PriceCop    int64  `json:"price_cop"   validate:"required,gt=0"`
    Stock       int    `json:"stock"       validate:"min=0,max=999999"`
}

Pointer-поля в UpdateProductRequest позволяют отличить «поле не передано» от «поле передано с нулевым значением». omitempty пропускает nil-pointer перед остальными тегами.

Когда один struct с context-флагом

R-VLD-GRP-2: если есть веские основания использовать один struct (например, общая схема для отображения в UI), применяй validate.StructCtxWithOptions или кастомный тег с FieldLevel.Param().

Вариант через context-ключ:

// internal/validation/context.go
type ValidationMode string

const (
    ModeCreate ValidationMode = "create"
    ModeUpdate ValidationMode = "update"
)

func RegisterContextTags(v *validator.Validate) {
    v.RegisterValidationCtx("required_on_create", func(ctx context.Context, fl validator.FieldLevel) bool {
        mode, _ := ctx.Value(ValidationMode("")).(string)
        if mode != string(ModeCreate) {
            return true // не create — пропустить
        }
        return fl.Field().String() != ""
    })
}

Применение:

type OrderRequest struct {
    CustomerID string `json:"customer_id" validate:"required_on_create,uuid4"`
    Comment    string `json:"comment"     validate:"max=1000"`
}

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

Этот подход оправдан когда struct реально один (например, он же используется для ответа), иначе — два отдельных типа проще.

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

АнтипаттернПравилоЧто взамен
Один struct с boolean-полем IsCreate bool для переключения режимовR-VLD-GRP-X1Два отдельных struct: CreateOrderRequest, DraftOrderRequest
Struct обслуживает 3+ разных сценария с разными наборами required-полейR-VLD-GRP-X2Разбить на отдельные типы по сценарию
Pointer-поля везде «на всякий случай» вместо разделения на create/updaterequired поля non-pointer в create-struct; pointer + omitempty в update-struct

Куда дальше

  • Validation → раздел 4. Validation groups — нормативные формулировки R-VLD-GRP-*.
  • Где валидировать — httpreq.Decode[T] как точка валидации.
  • Стандартные constraints — required vs omitempty, pointer-типы.
  • OpenAPI и struct-теги — разные схемы в OpenAPI для create/update как источник правды.