Опирается на правила:
R-VLD-GRP-1…R-VLD-GRP-2иR-VLD-GRP-X1…R-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/update | — | required поля non-pointer в create-struct; pointer + omitempty в update-struct |
Куда дальше
- Validation → раздел 4. Validation groups — нормативные формулировки
R-VLD-GRP-*. - Где валидировать —
httpreq.Decode[T]как точка валидации. - Стандартные constraints —
requiredvsomitempty, pointer-типы. - OpenAPI и struct-теги — разные схемы в OpenAPI для create/update как источник правды.