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

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

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

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

DTO: границы API и внутренние модели

Слои валидации — главный источник споров вокруг DTO: зачем дублировать поля, если тег validate можно повесить прямо на доменную структуру? В этом уроке разбираем, почему валидатор в домене — это зависимость в ядре и тупик контекста, строим request-DTO с тегами, Normalize из пятнадцатого урока и маппинг в доменную структуру с конструктором, отделяем response-DTO от внутренних полей и честно считаем цену дублирования, включая случай, когда проект ещё маленький.

Содержание урока · 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 и путь в домен

GO
type 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 — работа границы.

Домен с конструктором и response-DTO

GO
package 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 — тоже договор: десяток лишних полей сегодня — это поверхность атаки и обещание обратной совместимости завтра, потому что убрать разосланное поле так же сложно, как ввести его.

Цена дублирования: когда 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 и уникальные индексы работают мимо вашего кода
  • маленький проект может валидировать одну структуру — до первого второго контекста или клиента

Верните проверку на границу

Кто-то вычистил валидацию из хендлера. Верните пропущенный вызов — один пропуск.

func signup(c *gin.Context) { var in SignupDTO if err := c.ShouldBindJSON(&in); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "битое тело"}) return } in.Normalize() if err := ; err != nil { c.JSON(http.StatusBadRequest, gin.H{"errors": "проверьте поля"}) return } u, err := domain.NewUser(in.Email, in.Password) // ... }

Соберите путь запроса по слоям

От JSON-тела до ответа — расставьте шаги конвейера границы.

Строки

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

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

Карточки: слои и буферы

Термин

request-DTO

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

Буфер на входе: поля одной операции и теги validate, выражающие контекст. Регистрация и профиль получают разные структуры, а не ветвления в одной проверке.

1 / 6

Всё из этого урока опирается на соседние темы: нормализация перед валидацией поставит чистку до проверок на границе, 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 от конструктора: защита в глубину должна быть видна в тесте.

Личные заметкиПусто
Хранятся только в вашем браузере (Validator · DTO: границы API и внутренние модели)

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

Почему нельзя повесить теги на домен и просто менять их по операции?

Тег статичен: он разбирается в план структуры при первом validate.Struct и не знает, из какого сценария пришёл вызов. Ветвление правил для регистрации и профиля — это структурный уровень с флагами, который растёт с каждым релизом. Request-DTO решает то же самое отдельными структурами: контекст виден в типе, а не в if внутри универсальной проверки.

Дублировать все поля DTO — не дорого?

Дорого, но дешевле альтернатив: теги на домене склеивают транспорт с ядром и ломают контексты, а прямая сериализация домена выносит внутренние поля в API автоматически. Для маленького проекта допустима одна структура — вводите DTO при первом втором контексте или внешнем клиенте, пока имена домена не стали контрактом.

Конструктор вернул ошибку после пройденных тегов — что показывать клиенту?

Это другой класс ошибки: формат был корректен, но бизнес отклонил смысл. Отвечайте не списком полей по тегам, а кодом с бизнес-причиной, по которой фронт различит «минимум 8 символов» и «пароль не может состоять из одних цифр». Тип ошибки на границе и определяет конверт ответа.

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

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

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

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

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

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

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