Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 23 из 25 ~20 мин +20 XP

Бизнес-правила против тегов

Бизнес-правила валидации начинаются там, где заканчиваются теги: тег описывает форму данных — длину, формат, обязательность, — а смысл данных тегом не выразить. Баланс не меньше суммы списания, возраст восемнадцать лет, не больше пяти заявок в сутки, промокод существует и активен — это запросы к состоянию, времени и базе, и ни один из них не отвечает чистая функция от значения поля. Урок раскладывает правила по слоям: формат остаётся тегам на request-DTO, раскладка полей одной структуры — StructLevel, а всё, что требует состояния, — сервисному слою. Примеры идут от простого к сложному: сначала возраст, где нужен только календарь, потом раскладка дат, где хватает полей одной структуры, потом баланс и промокоды, где без базы не обойтись. Отдельно разбираем статусы: где формат, где смысл и почему выбор 400 против 422 фиксируется в проекте один раз, одной строкой в описании API.

Содержание урока · 10 разделов

Тег — чистая функция от значения поля: валидатор берёт строку или число и сверяет с правилом, не заглядывая наружу. Как только правило формулируется с «не меньше, чем на счётчике», «относительно текущей даты» или «если такой промокод ещё жив», чистота кончается: значение само по себе ответа не даёт, нужен контекст — база, время, другой запрос. Сложная валидация go-проектов обычно состоит из десятка тегов и трёх-четырёх бизнес-правил, и вся практика в том, чтобы не смешивать эти два сорта правил в одном месте. Смешение выглядит безобидно и проявляется позже: функция-валидатор делает запрос к базе, тесты валидации требуют тестовую базу, транзакция обходит валидатор стороной, а формат ответа разъезжается между хендлерами. Правильная раскладка проста: теги — форма, StructLevel — раскладка полей одной структуры, сервис — состояние и смысл. Проверка на словах одна, а на деле у каждого проекта она своя: где-то хендлер проверяет всё руками, где-то сервис верит данным с границы, где-то правило продублировано в двух слоях и разъехалось. Разметка ниже — способ обсудить это на ревью общими словами, а не через «а у нас по-другому». Ниже три правила-примера, каждое в своём слое, а таблица в конце сводит разметку на один экран.

Возраст 18+: правило, которому нужен календарь#

Первый пример — возраст. Форма хранит дату рождения строкой, и тег datetime с макетом 2006-01-02 честно проверяет формат: это работа тегов. А вот «восемнадцать лет» — уже арифметика с текущей датой: валидатор времени не знает, тег параметров времени не принимает, и раз в год правило ломается на границе — «18 включительно» означает, что человек, которому сегодня исполнилось восемнадцать, проходит. Считать возраст надо в коде с именем и тестом: полные годы от даты рождения до сегодня, с поправкой на ещё не наступивший в этом году день рождения — без поправки граница сдвигается на год, и тридцать первого декабря система считает взрослым того, кто взрослым станет завтра. Проверка живёт в сервисном слое, рядом с остальными правилами смысла, — после validate.Struct, до записи в базу. Ошибка возвращается доменной, со своим кодом, а не FieldError: у правила возраста нет тега — нет и места в ValidationErrors. Дата рождения — удобный учебный случай, но в проектах таких правил десятки: срок действия токена, окно бронирования, день списания подписки. У всех одна природа — календарь, — и все они не хотят жить в тегах.

Возраст: тег проверяет формат, сервис — смысл

GO
type 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: форма корректна, не корректна сделка. Отсюда тест на ревью: если правило формулируется без слов «база», «сейчас» или «счётчик» — возможно, это вообще тег; если без них никак — тянуть его в валидатор тем более не стоит: валидатор не умеет ни откатывать, ни ждать, ни повторять.

Перевод: сравнение внутри транзакции

GO
func (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.

Промокод и суточный лимит: правила состояния#

Остальные примеры из начала урока раскладываются по той же схеме. «Промокод существует и ещё активен» — два запроса к базе: существование и срок действия; форма при этом корректна, промокода нет — и клиенту нужен статус с текстом «промокод не найден», а не список полей с тегами: нечего подсвечивать, поле заполнено. «Не больше пяти заявок в сутки» — счётчик по пользователю в окне времени: правило читает состояние и само его меняет, заявка ведь записывается; такие правила живут в сервисе рядом с записью и не терпят дублирования в хендлере. Общий признак — цена повторного прохода: правило, которое ходит в базу, нельзя вызывать «на всякий случай» дважды — каждый лишний вызов это лишний запрос и лишняя пауза в ответе. Заметьте асимметрию: неверный формат — ошибка клиента, которую он исправит и повторит запрос; выбранный лимит — тоже ошибка клиента, но её место в интерфейсе и документации, а не в сообщении валидатора. Разные получатели — разные ветки конверта. Таблица ниже сводит разметку: какой сорт правил где живёт и почему.

Карта правил: где живёт и почему#

ПравилоГде живётПочему
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-правило раскладки

Один пропуск: метод, который регистрирует структурную проверку.

v := validator.New() v.("Period", func(sl validator.StructLevel) { p := sl.Current().Interface().(Period) if p.End.Before(p.Start) { sl.ReportError(p.End, "End", "End", "after_start", "") } })

Слои проверки: от формы до сервиса

Расставьте конвейер проверки запроса с переводом.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Проверка: где правило про промокод

Форма с промокодом прошла все теги. Где проверять «промокод существует и активен»?

Карточки: правила против тегов

Термин

тег против сервиса

нажмите, чтобы перевернуть
Определение

Тег — форма данных: длина, формат, обязательность, чистая функция от значения. Сервис — смысл: возраст, баланс, лимиты — всё, что требует состояния или времени.

1 / 6

Разметка «тег — StructLevel — сервис» — не догма о слоях, а вопрос владельца: у каждого правила есть одно место, где оно живёт, и признак простой — что правило должно знать, чтобы ответить. Значение поля — тег. Соседние поля той же структуры — StructLevel. Мир за пределами структуры — сервис. Пройдитесь по своим бизнес-правилам с этим вопросом: смешение чаще всего видно по тестам — правило с IO в валидаторе не тестируется без базы, а правило смысла в теге не тестируется без подмены системных часов. Если сомневаетесь, к какому слою отнести правило, напишите его словами и подчеркните подлежащее: «значение короче десяти» — тег, «поля не согласованы» — StructLevel, «остатка не хватает» — сервис. Следующий урок берёт то, что накопили слои, и складывает в один ответ: единый формат ошибок API, где Field(), Tag() и Param() становятся деталями JSON-конверта, который клиент разбирает без сюрпризов.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Тег описывает форму, сервис — смысл: баланс, возраст, лимиты и промокоды тегом не выразить.
  • Раскладка полей одной структуры — StructLevel: RegisterStructValidation и ReportError.
  • Правилам состояния место в сервисном слое: транзакция, один проход, рядом с записью.
  • Функции-валидаторы без IO: запрос к базе из валидатора ломает тесты и транзакцию.
  • Возраст считается от календаря в сервисе; тег проверяет только формат даты.
  • 400 — формат, 422 и 409 — смысл; статусы зафиксированы в проекте единообразно.
  • Вопрос разметки: что правило должно знать — значение, соседние поля или мир.

Домашнее задание

Разберите три правила из своего проекта. Первое: найдите бизнес-правило, спрятанное в теге или функции-валидаторе, — проверьте, ходит ли оно в базу или к системным часам, и перенесите в сервис. Второе: правило раскладки полей — если оно размазано по if после валидации, соберите в RegisterStructValidation с ReportError и кейсом в таблице тестов. Третье: выпишите свои правила состояния — баланс, лимиты, промокоды — и проверьте, что каждое проверяется один раз, внутри транзакции. Четвёртое: зафиксируйте в описании проекта, какие ошибки дают 400, а какие 422 или 409, и что именно кладётся в конверт ответа.

Личные заметкиПусто
Хранятся только в вашем браузере (Validator · Бизнес-правила против тегов)

Вопросы и ответы по уроку

Можно ли выразить возраст тегом, если в форме уже есть числовое поле Age?

Можно перенести проблему, а не решить: кто заполняет Age и чему тогда верит валидатор? Клиент пришлёт любое число, а дата рождения останется непроверенной. Формат даты держит тег datetime, возраст считает сервис от календаря; числовое поле формы — либо производное на клиенте для мгновенной подсказки, либо его не должно быть в DTO вовсе.

gtefield и eqfield разве не бизнес-правила? Почему они в тегах?

Это раскладка внутри одной структуры: eqfield сравнивает поле с соседним полем, gtefield с другим полем той же структуры, и оба не выходят за её пределы. Состояния у них нет, IO не нужен — поэтому они теги. «Баланс не меньше суммы» похоже на gtefield, но вторая величина живёт в базе, и это уже сервис.

Куда класть проверку «email не занят»: в валидатор или сервис?

В сервис, рядом с записью и в той же транзакции: уникальность — состояние базы, и проверка вне транзакции теряет гонку. Надёжный рубеж — уникальный индекс, сервис маппит нарушение в 409. Валидатор про существование адреса не отвечает: email-тег проверяет синтаксис, не занятость.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Asynq25 уроков~11 чпрактика25 уроков уже доступны

Asynq в Go: очередь фоновых задач на Redis

Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.

  • Строить клиент и воркер asynq и подключать Redis
  • Отправлять отложенные, периодические и уникальные задачи
  • Настраивать ретраи, таймауты и приоритеты очередей
  • Мониторить задачи через Inspector и asynqmon