Содержание урока · 10 разделов
- Возраст 18+: правило, которому нужен календарь
- Раскладка полей одной структуры: StructLevel
- Баланс и лимиты: проверка в сервисном слое
- Промокод и суточный лимит: правила состояния
- Карта правил: где живёт и почему
- 400, 422 или 409: формат против смысла
- Разметка правил одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Тег — чистая функция от значения поля: валидатор берёт строку или число и сверяет с правилом, не заглядывая наружу. Как только правило формулируется с «не меньше, чем на счётчике», «относительно текущей даты» или «если такой промокод ещё жив», чистота кончается: значение само по себе ответа не даёт, нужен контекст — база, время, другой запрос. Сложная валидация go-проектов обычно состоит из десятка тегов и трёх-четырёх бизнес-правил, и вся практика в том, чтобы не смешивать эти два сорта правил в одном месте. Смешение выглядит безобидно и проявляется позже: функция-валидатор делает запрос к базе, тесты валидации требуют тестовую базу, транзакция обходит валидатор стороной, а формат ответа разъезжается между хендлерами. Правильная раскладка проста: теги — форма, StructLevel — раскладка полей одной структуры, сервис — состояние и смысл. Проверка на словах одна, а на деле у каждого проекта она своя: где-то хендлер проверяет всё руками, где-то сервис верит данным с границы, где-то правило продублировано в двух слоях и разъехалось. Разметка ниже — способ обсудить это на ревью общими словами, а не через «а у нас по-другому». Ниже три правила-примера, каждое в своём слое, а таблица в конце сводит разметку на один экран.
Возраст 18+: правило, которому нужен календарь#
Первый пример — возраст. Форма хранит дату рождения строкой, и тег datetime с макетом 2006-01-02 честно проверяет формат: это работа тегов. А вот «восемнадцать лет» — уже арифметика с текущей датой: валидатор времени не знает, тег параметров времени не принимает, и раз в год правило ломается на границе — «18 включительно» означает, что человек, которому сегодня исполнилось восемнадцать, проходит. Считать возраст надо в коде с именем и тестом: полные годы от даты рождения до сегодня, с поправкой на ещё не наступивший в этом году день рождения — без поправки граница сдвигается на год, и тридцать первого декабря система считает взрослым того, кто взрослым станет завтра. Проверка живёт в сервисном слое, рядом с остальными правилами смысла, — после validate.Struct, до записи в базу. Ошибка возвращается доменной, со своим кодом, а не FieldError: у правила возраста нет тега — нет и места в ValidationErrors. Дата рождения — удобный учебный случай, но в проектах таких правил десятки: срок действия токена, окно бронирования, день списания подписки. У всех одна природа — календарь, — и все они не хотят жить в тегах.
Возраст: тег проверяет формат, сервис — смысл
GOtype SignupForm struct {
BirthDate string `validate:"required,datetime=2006-01-02"`
}
// возраст в полных годах: 18 включительно
func age(bd time.Time) int {
now := time.Now()
years := now.Year() - bd.Year()
if m, d := now.Month(), now.Day(); m < bd.Month() ||
(m == bd.Month() && d < bd.Day()) {
years--
}
return years
}
// сервис: теги уже прошли на границе API
func (s *Signup) Register(f SignupForm) error {
bd, _ := time.Parse("2006-01-02", f.BirthDate)
if age(bd) < 18 { // ровно 18 проходит
return ErrUnderage // доменная ошибка, не FieldError
}
return s.users.Create(f)
}Тег datetime проверяет формат строки — это его предел: календаря у тега нет. Функция age считает полные годы с поправкой на день рождения, который в этом году ещё не наступил: без поправки «18 включительно» сдвигается на год. Проверка живёт в сервисном методе после валидации формы и возвращает доменную ошибку ErrUnderage, а не FieldError, — у правила возраста нет тега и параметра.
Проверка: возраст — тег или сервис
«Регистрация с 18 лет» на поле BirthDate, где форма хранит дату строкой: где этому правилу место?
Раскладка полей одной структуры: StructLevel#
Второй сорт правил — раскладка: конец периода после начала, подтверждение пароля совпадает с паролем, диапазон дат не пересекает другой диапазон той же формы. Значения все внутри одной структуры, состояние не нужно — но тег поля сам по себе ответить не может: сравнение двух полей не принадлежит ни одному из них. Для этого у валидатора есть StructLevel: RegisterStructValidation вешает функцию на тип, функция получает всю структуру целиком и репортит ошибки по конкретным полям через ReportError. Это стык одиннадцатого урока — StructLevel и правила на всю структуру — здесь важна граница приёма: StructLevel правит только внутри одной структуры. Сравнивать форму с записью в базе, с балансом или с курсом валют ему нечем и не положено: как только правилу нужен запрос наружу, оно уезжает в сервис. «Конец после начала» — внутри; «даты не заняты в календаре» — сервис. Функция получает значение всей структуры и тестируется отдельно: собрали структуру, прогнали функцию, сверили репорты — база для такого теста не нужна, и это признак правильного слоя.
Где поймается «конец после начала»
Прочитайте код и выберите, что он выведет:
type Period struct {
Start string `validate:"required,datetime=2006-01-02"`
End string `validate:"required,datetime=2006-01-02"`
}
v.RegisterStructValidation(checkPeriod, Period{})
// checkPeriod репортит End, если End.Before(Start)
// форма: Start = 2025-05-10, End = 2025-05-01
err := v.Struct(p)Баланс и лимиты: проверка в сервисном слое#
Третий сорт — правила состояния: «баланс не меньше суммы списания», «не больше пяти заявок в сутки». Здесь нужны данные, которых в запросе нет: актуальный баланс со счёта, счётчик заявок за окно времени. Такой проверке место в сервисном слое — после того как validate.Struct отработал на границе и DTO прошёл теги; слоистая рамка DTO и границ API ставит сервис после хендлера и до базы. Порядок строгий: сервис открыл транзакцию, достал баланс, сравнил, вернул доменную ошибку или записал перевод. Сравнение внутри той же транзакции принципиально: проверка вне транзакции теряет гонку, два параллельных перевода видят один и тот же остаток и оба проходят. Ошибка — доменная: свой тип со своим местом в API-конверте, и статус у неё не 400: форма корректна, не корректна сделка. Отсюда тест на ревью: если правило формулируется без слов «база», «сейчас» или «счётчик» — возможно, это вообще тег; если без них никак — тянуть его в валидатор тем более не стоит: валидатор не умеет ни откатывать, ни ждать, ни повторять.
Перевод: сравнение внутри транзакции
GOfunc (s *Wallet) Send(from, to string, amount int64) error {
return s.db.Transaction(func(tx *Tx) error {
balance, err := tx.Balance(from) // состояние достаёт сервис
if err != nil {
return err
}
if balance < amount { // «баланс не меньше списания»
return ErrInsufficientFunds
}
return tx.Move(from, to, amount)
})
}Сервис открывает транзакцию, читает баланс и сравнивает с суммой в одном замке: два параллельных перевода не увидят один и тот же остаток. Правило живёт не в теге — ему нужна база, которой у валидатора нет. Ошибка доменная: ErrInsufficientFunds маппится в 409 или 422, а не в 400 с разбором ValidationErrors.
Команда зарегистрировала функцию-валидатор promo, которая внутри делала SELECT в таблицу промокодов: тег promo на поле — и «правило в валидаторе». Через месяц тесты валидации требовали тестовую базу, а один запрос формы в сезон распродаж держал соединение дольше, чем весь хендлер. Хуже: проверка шла мимо транзакции сервиса, и промокод успевал протухнуть между валидацией и записью. Если правило зависит от состояния — сервис, после validate.Struct; если это раскладка полей одной структуры — StructLevel; если функция-валидатор делает запрос к базе — антипаттерн, переносите правило в сервис. О собственных функциях без IO — десятый урок.
Промокод и суточный лимит: правила состояния#
Остальные примеры из начала урока раскладываются по той же схеме. «Промокод существует и ещё активен» — два запроса к базе: существование и срок действия; форма при этом корректна, промокода нет — и клиенту нужен статус с текстом «промокод не найден», а не список полей с тегами: нечего подсвечивать, поле заполнено. «Не больше пяти заявок в сутки» — счётчик по пользователю в окне времени: правило читает состояние и само его меняет, заявка ведь записывается; такие правила живут в сервисе рядом с записью и не терпят дублирования в хендлере. Общий признак — цена повторного прохода: правило, которое ходит в базу, нельзя вызывать «на всякий случай» дважды — каждый лишний вызов это лишний запрос и лишняя пауза в ответе. Заметьте асимметрию: неверный формат — ошибка клиента, которую он исправит и повторит запрос; выбранный лимит — тоже ошибка клиента, но её место в интерфейсе и документации, а не в сообщении валидатора. Разные получатели — разные ветки конверта. Таблица ниже сводит разметку: какой сорт правил где живёт и почему.
Карта правил: где живёт и почему#
| Правило | Где живёт | Почему |
|---|---|---|
| required, email, max | теги на request-DTO | чистая функция от значения поля: рефлексия, без IO |
| Конец периода после начала | StructLevel | раскладка полей одной структуры, состояние не нужно |
| Возраст 18+ | сервис после validate.Struct | нужен текущий календарь, теги времени не знают |
| Баланс, лимит заявок, промокод | сервис внутри транзакции | запрос к состоянию: сравнение и запись в одном замке |
| Статус ответа: 400 или 422 | конвенция проекта | единый маппинг доменных ошибок на статусы |
400, 422 или 409: формат против смысла#
Разметка слоёв доезжает до клиента в статусах. 400 — формат: теги не прошли, ValidationErrors разобраны, клиенту список полей; это ветка валидатора. 422 и 409 — смысл: форма корректна, но сделку она не образует — денег не хватает, лимит выбран, промокод протух. Номер конкретного статуса — выбор проекта, и проекту важно выбрать один раз: где 409, а где 422, и не путать от хендлера к хендлеру, потому что клиент пишет разбор ровно под один вариант. Конверт ответа при этом общий: и на 400, и на 422 клиент получает один JSON с кодом и деталями — собирать его из ValidationErrors и доменных ошибок умеет одна функция, её устройство — следующий урок. Честное место: валидатор знает только 400 — статусы смысла раздаёт сервис, потому что только он знает, что именно не сложилось. Практика проста: конвенция пишется одной строкой рядом с описанием API — «теги дают 400, домен даёт 422, конфликт даёт 409» — и живёт там до первого инцидента, который напомнит, почему её нельзя было менять молча.
Разметка правил одним списком#
- теги — форма: длина, формат, обязательность; чистая функция от значения поля
- StructLevel — раскладка полей одной структуры: конец после начала, совпадение полей
- сервис — смысл и состояние: возраст, баланс, лимиты, промокоды
- функции-валидаторы не ходят в базу: IO уезжает в сервис, транзакция не делится
- правила состояния проверяются один раз, внутри транзакции, рядом с записью
- ошибка тегов — FieldError и 400; ошибка смысла — доменная и 422 или 409
- статусы зафиксированы в проекте один раз: клиент пишет разбор под один вариант
- тесты бизнес-правил — юнит-тесты сервиса; таблицы кейсов остаются тегам
Правила состояния в песочнице не оживут: нет базы и часов, которые можно подвинуть. Песочница исполнит интерактивы урока; возраст, баланс и лимиты проверяйте локально — сервисный слой живёт в вашем проекте и в ваших тестах.
Соберите StructLevel-правило раскладки
Один пропуск: метод, который регистрирует структурную проверку.
Слои проверки: от формы до сервиса
Расставьте конвейер проверки запроса с переводом.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Проверка: где правило про промокод
Форма с промокодом прошла все теги. Где проверять «промокод существует и активен»?
Карточки: правила против тегов
Разметка «тег — StructLevel — сервис» — не догма о слоях, а вопрос владельца: у каждого правила есть одно место, где оно живёт, и признак простой — что правило должно знать, чтобы ответить. Значение поля — тег. Соседние поля той же структуры — StructLevel. Мир за пределами структуры — сервис. Пройдитесь по своим бизнес-правилам с этим вопросом: смешение чаще всего видно по тестам — правило с IO в валидаторе не тестируется без базы, а правило смысла в теге не тестируется без подмены системных часов. Если сомневаетесь, к какому слою отнести правило, напишите его словами и подчеркните подлежащее: «значение короче десяти» — тег, «поля не согласованы» — StructLevel, «остатка не хватает» — сервис. Следующий урок берёт то, что накопили слои, и складывает в один ответ: единый формат ошибок API, где Field(), Tag() и Param() становятся деталями JSON-конверта, который клиент разбирает без сюрпризов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тег описывает форму, сервис — смысл: баланс, возраст, лимиты и промокоды тегом не выразить.
- Раскладка полей одной структуры — StructLevel: RegisterStructValidation и ReportError.
- Правилам состояния место в сервисном слое: транзакция, один проход, рядом с записью.
- Функции-валидаторы без IO: запрос к базе из валидатора ломает тесты и транзакцию.
- Возраст считается от календаря в сервисе; тег проверяет только формат даты.
- 400 — формат, 422 и 409 — смысл; статусы зафиксированы в проекте единообразно.
- Вопрос разметки: что правило должно знать — значение, соседние поля или мир.
Домашнее задание
Разберите три правила из своего проекта. Первое: найдите бизнес-правило, спрятанное в теге или функции-валидаторе, — проверьте, ходит ли оно в базу или к системным часам, и перенесите в сервис. Второе: правило раскладки полей — если оно размазано по if после валидации, соберите в RegisterStructValidation с ReportError и кейсом в таблице тестов. Третье: выпишите свои правила состояния — баланс, лимиты, промокоды — и проверьте, что каждое проверяется один раз, внутри транзакции. Четвёртое: зафиксируйте в описании проекта, какие ошибки дают 400, а какие 422 или 409, и что именно кладётся в конверт ответа.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли выразить возраст тегом, если в форме уже есть числовое поле Age?
Можно перенести проблему, а не решить: кто заполняет Age и чему тогда верит валидатор? Клиент пришлёт любое число, а дата рождения останется непроверенной. Формат даты держит тег datetime, возраст считает сервис от календаря; числовое поле формы — либо производное на клиенте для мгновенной подсказки, либо его не должно быть в DTO вовсе.
gtefield и eqfield разве не бизнес-правила? Почему они в тегах?
Это раскладка внутри одной структуры: eqfield сравнивает поле с соседним полем, gtefield с другим полем той же структуры, и оба не выходят за её пределы. Состояния у них нет, IO не нужен — поэтому они теги. «Баланс не меньше суммы» похоже на gtefield, но вторая величина живёт в базе, и это уже сервис.
Куда класть проверку «email не занят»: в валидатор или сервис?
В сервис, рядом с записью и в той же транзакции: уникальность — состояние базы, и проверка вне транзакции теряет гонку. Надёжный рубеж — уникальный индекс, сервис маппит нарушение в 409. Валидатор про существование адреса не отвечает: email-тег проверяет синтаксис, не занятость.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon