Содержание урока · 10 разделов
Доменная модель тянет на себе validate-теги в каждом втором проекте — и это выглядит разумно: одна структура, один источник правил, ноль лишних сущностей. Проблемы начинаются молча. Первая — зависимость: пакет домена импортирует валидатор, ядро бизнеса больше не собирается без внешней библиотеки, а тест инвариантов тянет HTTP-проблемы туда, где им не место. Вторая — контекст: у регистрации пароль с min=8, у админ-панели min=12, а профиль пароля вообще не касается — тег один, сценариев три, и кто-то клонирует структуры или пишет ветвления на структурном уровне, которые растут с каждым релизом. Третья — обратная сторона сериализации: доменная структура уезжает наружу как есть, и новое поле PasswordHash из миграции незаметно становится ключом JSON-ответа. Решение старо, как сам HTTP: буферные структуры на границе — request-DTO с тегами validate на входе и response-DTO с белым списком полей на выходе, а домен живёт своей жизнью со своими инвариантами. Сразу зафиксируем: DTO здесь — не паттерн ради паттерна, а следствие того, что у транспорта и бизнеса разные владельцы, разные ритмы релизов и разные причины меняться; остальная часть урока просто переводит этот тезис в код. В этом уроке раскладываем слои валидации по ролям, собираем конвейер границы и считаем цену дублирования без романтизации.
Слои валидации: кто проверяет и чем#
Слои валидации — это не три раза одни и те же проверки, а три разных вопроса к данным. Handler спрашивает: формат пригоден для транспорта? Почта похожа на почту, пароль длиннее восьми, ник в пределах длины — это быстрые предикаты по тегам validate на request-DTO, и их место в HTTP-слое, где известен контекст запроса: регистрация это, админ-панель или обновление профиля. Домен спрашивает: объект имеет смысл? Конструктор отказывается собирать пользователя с паролем из одних цифр — не потому, что HTTP так хочет, а потому, что бизнес определяет пароль иначе; это инварианты, и живут они в конструкторе, возвращающем ошибку. Хранилище спрашивает: целостность соблюдена? Уникальный индекс на email и NOT NULL-constraints — последний рубеж, который работает даже мимо вашего кода, когда запись создаёт джоба или админ-скрипт. Три вопроса — три инструмента, и ни один не заменяет другой: пропуск тегов не отменяет инварианты, а конструктор не спасёт от дубликата в базе. Это защита в глубину, где каждая линия рассчитана на то, что предыдущая однажды даст течь — из-за нового хендлера, второго транспорта или чужого скрипта. Практический тест на здравость разделения: если правило можно сформулировать без слов «HTTP», «JSON» и «клиент» — это инвариант или constraint, а не тег; если без слов «бизнес», «пароль», «баланс» — это формат, и место ему на DTO.
Слои валидации и их инструменты#
| Слой | Что проверяет | Чем |
|---|---|---|
| handler | формат транспорта: обязательность, длины, email, url | теги validate на request-DTO и validate.Struct |
| домен | инварианты бизнеса | конструктор NewUser, возвращающий (*User, error) |
| хранилище | целостность и уникальность | NOT NULL, уникальные индексы, constraints |
| response-DTO | что именно уезжает наружу | отдельная структура с белым списком полей |
Request-DTO и путь в домен
GOtype SignupDTO struct {
Email string `json:"email" validate:"required,email"`
Nickname string `json:"nickname" validate:"required,min=2,max=32"`
Password string `json:"password" validate:"required,min=8"`
}
func (s *UserService) Signup(in SignupDTO) (UserView, error) {
in.Normalize() // чистка из пятнадцатого урока
if err := validate.Struct(&in); err != nil {
return UserView{}, err // ValidationErrors: 400 по полям
}
u, err := domain.NewUser(in.Email, in.Nickname, in.Password)
if err != nil {
return UserView{}, err // инвариант домена отклонил
}
return u.View(), nil
}Request-DTO живёт в транспортном пакете: теги описывают формат конкретной операции, Normalize чистит значения до проверок, validate.Struct исполняет правила экземпляром из четырнадцатого урока. Маппинг — единственное место встречи DTO с доменом: NewUser получает проверенные поля и отвечает за смысл. Ошибки двух уровней разных типов, и хендлер разведёт их в разные ответы.
Проверка: где живут теги
Где живут теги validate в проекте с разделёнными слоями?
Request-DTO: от JSON до домена#
Request-DTO — это договор с клиентом на одну операцию, и в этом его сила. Регистрация получает SignupDTO с min=8 на пароле; смена профиля — ProfileDTO без пароля вовсе; админ-панель — свою структуру с собственными правилами. Контекст операции выражен типом, а не ветвлением внутри универсальной проверки: чтение хендлера начинается с полей, а не с разбора условий, когда какой тег сейчас действует. Конвейер на границе вы уже собирали: bind декодирует тело, Normalize из пятнадцатого урока чистит пробелы и регистр, validate.Struct возвращает ValidationErrors — и из них пятый урок собирает ответ 400 по полям, чтобы клиент увидел, что именно не так. Сразу после проверок — маппинг: каждая строка DTO копируется в вызов конструктора домена. Маппинг пишется руками, и это честно: три поля — три строки, которые компилятор проверит при переименовании; автомапперы через reflect прячут соответствие от компилятора и добавляют магию туда, где нужна явность. Если полей много, делите маппинг на функции по агрегатам, но не заводите общий универсальный копировщик: он рано или поздно скопирует то, что копировать было нельзя, — и узнаете вы об этом из баг-репорта, а не из теста. Отдельный вопрос — поля, которых у операции нет: ProfileDTO без пароля и означает, что смены пароля по этому маршруту не существует даже при взломе маршрута — контекст, выраженный отсутствием поля, надёжнее флага AllowPasswordChange в общей структуре.
Что вернёт маппинг DTO в домен
Прочитайте код и выберите, что он выведет:
type SignupDTO struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
in := SignupDTO{Email: "ada@example.com", Password: "12345678"}
// validate.Struct(&in) прошёл: длина восемь, формат почты ок
u, err := domain.NewUser(in.Email, in.Password)
// NewUser: пароль обязан содержать буквы, не только цифрыДомен: инварианты в конструкторе#
Доменная структура отвечает на вопрос, что делает объект осмысленным, и ответы живут в конструкторе: NewUser принимает проверенные на границе поля и возвращает (*User, error). Что проверять здесь, если формат уже закрыли теги? Инварианты: пароль не из одних цифр, счёт открывается с неотрицательным балансом, у заказа есть хотя бы одна позиция. Это правила смысла, а не транспорта: они не меняются вместе с API и не зависят от того, пришёл объект из HTTP, из джобы или из консольной команды. Конструктор отклоняет бессмысленное состояние до того, как оно обретёт ссылки: нулевой User не гуляет по коду, и вызывающему не нужно помнить, что «пароль надо бы ещё проверить». Честная оговорка о границах приёма: Go не запрещает литерал User{...} внутри того же пакета, и коллега в спешке им воспользуется. Защита делается unexported-полями с геттерами либо соглашением, зафиксированным ревью и тестом, который строит объект только через конструктор. Громоздкость — реальная цена, и окупается она один раз: когда неправильное состояние домена невозможно создать в принципе, класс багов про «а вдруг там нули» закрывается с обеих сторон пакета. Называйте ошибки конструктора доменными именами — ErrWeakPassword, ErrEmptyOrder — и не оборачивайте их в HTTP-тексты внутри пакета: домен отвечает, что не так по смыслу, а перевод на язык HTTP — работа границы.
Сервисы перевели на NewUser, но в пакете остался один литерал: обработчик импорта из CSV собирал User{Email: row[0]} напрямую — «данные ведь свои». Если хоть один путь обхода конструктора живёт, то нулевые и непроверенные значения едут в базу как данные: через месяц в таблице оказались пользователи без хэша пароля, а первый вход сломался у половины импортированных. Разбор занял день и свёлся к правилу: домен строится только конструктором — запрет литералов структуры на ревью, тест на все пути создания. Если новый код создаёт объект иначе, чем NewUser, то это баг, даже когда тесты зелёные.
Домен с конструктором и response-DTO
GOpackage domain
type User struct {
email string
passwordHash string
}
func NewUser(email, password string) (*User, error) {
if !passwordHasLetter(password) {
return nil, ErrWeakPassword
}
return &User{email: email, passwordHash: hash(password)}, nil
}
type UserView struct {
Email string `json:"email"`
}
func (u *User) View() UserView {
return UserView{Email: u.email}
}Unexported-поля закрывают литерал снаружи пакета: собрать User мимо NewUser физически невозможно, а конструктор отклоняет слабый пароль и хэширует его до хранения. UserView — response-DTO с белым списком: наружу едет только почта, и новое поле домена само не станет ключом JSON-ответа. View — зеркальный буфер к request-DTO.
Response-DTO: наружу без паролей#
Response-DTO решает обратную задачу: не всё, что хранит домен, предназначено клиенту. Хэш пароля, внутренние флаги модерации, себестоимость, счётчики переотправок — доменные поля, которые при прямой сериализации уезжают в JSON автоматически: новое поле в структуре становится новым ключом ответа, и никто этого не заметил до баг-репорта от конкурентов или аудитора. Response-DTO — белый список: наружу собираются только объявленные поля, добавление поля в домен ничего не меняет в API, пока вы сами не решили его показать. Отсюда и дисциплина секретов: дефис в json-теге на доменном поле выглядит решением, но связывает транспортную политику с ядром — как только домен сериализуется где-то ещё, минус приходится вспоминать заново на каждом поле. Белый список надёжнее чёрного по построению: вы перечисляете разрешённое вместо того, чтобы угадывать запрещённое. Для персональных данных это не перестраховка, а прямое требование: наружу едут поля, нужные клиенту, остальное остаётся внутри — двадцать второй урок курса разложит это по закону. Бонус, который оценит фронтенд: response-DTO переименовывает поля в JSON-имена в одном месте, и маппинг FieldError из пятого урока наконец получает парный ответ на выходе. Число полей в response-DTO — тоже договор: десяток лишних полей сегодня — это поверхность атаки и обещание обратной совместимости завтра, потому что убрать разосланное поле так же сложно, как ввести его.
Песочница сайта не поднимет HTTP-сервер и не знает модуля validator: конвейер урока собирайте на локальном Go — go mod init, go get github.com/go-playground/validator/v10, go run. Интерактивы проверяют порядок слоёв и контракты, а живые ответы 400 с ValidationErrors и отказами конструктора — в вашем терминале, на настоящем сервере.
Цена дублирования: когда DTO не нужны#
Честная часть урока: request-DTO и домен дублируют поля, и это реальная цена — на десять структур приходится десять копий, синхронизируемых руками. Оправдание не в моде на чистую архитектуру, а в наблюдаемых эффектах: слои собираются и тестируются независимо, контексты операций выражаются типами, а изменение API не трогает ядро с базой. Дублирование — плата за независимость, и на границе с внешним миром она оправдана с первого дня, пока API и домен меняются в разном ритме. Но масштаб решения обязан совпадать с масштабом проекта. Внутренний тул для трёх коллег, прототип, MVP на неделю: валидировать одну структуру с тегами binding в gin — законное решение, и этот курс его не запрещает; слой один, контекст один, и второй буфер был бы карго-культом. Договоритесь с командой о границе миграции: как только появляется второй потребитель домена — джоба, CLI, другой транспорт, — второй контекст правил или внешний клиент, DTO вводите немедленно, пока имена доменных полей не стали публичным контрактом. Миграция DTO задним числом болезненна: клиенты уже зависят от имён, переезд — правки по всему фронту, версии API и документации. Дешевле ввести буфер до того, как его отсутствие стало контрактом. Правило на флоте проектов звучит просто: каждая лишняя копия структуры должна называться вслух — «это цена независимости слоёв», — и если назвать её нечем, копию не заводите.
Проверка: конфликт двух линий
DTO прошёл validate.Struct, но domain.NewUser вернул ErrWeakPassword. Кто ошибся?
Граница API одним списком#
- request-DTO с тегами validate живёт в транспортном слое и выражает контекст операции
- порядок на границе: bind → Normalize → validate.Struct → маппинг в домен
- маппинг пишется руками: компилятор проверяет каждое поле при переименовании
- инварианты домена — в конструкторе, возвращающем (*T, error); литералы домена — вне закона
- response-DTO — белый список: новое поле API появляется только по вашему решению
- хэши и внутренние флаги не сериализуются вовсе: их нет в response-DTO
- хранилище — последний рубеж: constraints и уникальные индексы работают мимо вашего кода
- маленький проект может валидировать одну структуру — до первого второго контекста или клиента
Верните проверку на границу
Кто-то вычистил валидацию из хендлера. Верните пропущенный вызов — один пропуск.
Соберите путь запроса по слоям
От JSON-тела до ответа — расставьте шаги конвейера границы.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: слои и буферы
Всё из этого урока опирается на соседние темы: нормализация перед валидацией поставит чистку до проверок на границе, ValidationErrors соберёт ответ 400 по полям, а один экземпляр на приложение объяснит, почему validate.Struct зовётся на пакетном валидаторе, а не на validator.New() в хендлере. Следующий урок — валидация JSON-запросов: что ломается до того, как request-DTO получен, и почему две ошибки из одной строки тела требуют разных ответов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Теги validate на домене — зависимость в ядре и невозможность выразить контекст: регистрация и профиль хотят разных правил.
- Request-DTO — буфер на границе: теги формата, Normalize до проверок, ручной маппинг в домен.
- Инварианты домена живут в конструкторе, возвращающем (*T, error): нулевой объект не гуляет по коду.
- Response-DTO — белый список наружу: хэши и внутренние поля не сериализуются, новое поле API появляется по решению.
- Защита в глубину: теги ловят формат, конструктор — смысл, БД — целостность; провал слоя не отменяет остальные.
- Дублирование полей — осознанная цена; маленький проект может валидировать одну структуру до второго контекста.
- Миграция DTO задним числом болезненна: имена доменных полей успевают стать публичным контрактом API.
Домашнее задание
Возьмите доменную структуру из своего проекта с validate-тегами и разделите: request-DTO с тегами под конкретную операцию, конструктор домена с одним инвариантом и ошибкой, response-DTO из двух полей. Соберите конвейер bind → Normalize → validate.Struct → маппинг и напишите тест, который проходит min=8 на DTO, но ловит ErrWeakPassword от конструктора: защита в глубину должна быть видна в тесте.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя повесить теги на домен и просто менять их по операции?
Тег статичен: он разбирается в план структуры при первом validate.Struct и не знает, из какого сценария пришёл вызов. Ветвление правил для регистрации и профиля — это структурный уровень с флагами, который растёт с каждым релизом. Request-DTO решает то же самое отдельными структурами: контекст виден в типе, а не в if внутри универсальной проверки.
Дублировать все поля DTO — не дорого?
Дорого, но дешевле альтернатив: теги на домене склеивают транспорт с ядром и ломают контексты, а прямая сериализация домена выносит внутренние поля в API автоматически. Для маленького проекта допустима одна структура — вводите DTO при первом втором контексте или внешнем клиенте, пока имена домена не стали контрактом.
Конструктор вернул ошибку после пройденных тегов — что показывать клиенту?
Это другой класс ошибки: формат был корректен, но бизнес отклонил смысл. Отвечайте не списком полей по тегам, а кодом с бизнес-причиной, по которой фронт различит «минимум 8 символов» и «пароль не может состоять из одних цифр». Тип ошибки на границе и определяет конверт ответа.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon