---
title: "DTO: границы API и внутренние модели"
description: "dto api go: request-структуры с тегами validate на границе, домен с инвариантами в конструкторе и слои валидации от handler до БД с примерами."
course: "Валидация в Go: go-playground/validator"
url: "https://golangmaster.ru/courses/validator/lessons/16-dto-granicy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

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

*Курс «Валидация в Go: go-playground/validator», урок 16 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/validator/lessons/16-dto-granicy/*

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

Доменная модель тянет на себе 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 транспортного слоя: формат конкретной операции
- На доменных структурах в ядре: один источник всех правил
- В SQL-скриптах схемы: constraints вместо тегов
- Нигде: каждый слой зовёт валидатор вручную на any

Теги validate — форматная проверка границы, и место им на DTO конкретной операции: домен остаётся без зависимости от валидатора, а контекст выбирается структурой. Инварианты держит конструктор домена, целостность — база; у каждого слоя свой инструмент.

### Request-DTO: от JSON до домена

Request-DTO — это договор с клиентом на одну операцию, и в этом его сила. Регистрация получает SignupDTO с min=8 на пароле; смена профиля — ProfileDTO без пароля вовсе; админ-панель — свою структуру с собственными правилами. Контекст операции выражен типом, а не ветвлением внутри универсальной проверки: чтение хендлера начинается с полей, а не с разбора условий, когда какой тег сейчас действует. Конвейер на границе вы уже собирали: bind декодирует тело, Normalize из пятнадцатого урока чистит пробелы и регистр, validate.Struct возвращает ValidationErrors — и из них пятый урок собирает ответ 400 по полям, чтобы клиент увидел, что именно не так. Сразу после проверок — маппинг: каждая строка DTO копируется в вызов конструктора домена. Маппинг пишется руками, и это честно: три поля — три строки, которые компилятор проверит при переименовании; автомапперы через reflect прячут соответствие от компилятора и добавляют магию туда, где нужна явность. Если полей много, делите маппинг на функции по агрегатам, но не заводите общий универсальный копировщик: он рано или поздно скопирует то, что копировать было нельзя, — и узнаете вы об этом из баг-репорта, а не из теста. Отдельный вопрос — поля, которых у операции нет: ProfileDTO без пароля и означает, что смены пароля по этому маршруту не существует даже при взломе маршрута — контекст, выраженный отсутствием поля, надёжнее флага AllowPasswordChange в общей структуре.

### Что вернёт маппинг DTO в домен

```go
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: пароль обязан содержать буквы, не только цифры
```

**Что выведет программа?**

- u и nil: обе проверки пройдены
- ✅ err не nil: конструктор отклонил пароль из одних цифр
- Панику: NewUser не может вернуть ошибку
- ValidationErrors: Password по тегу min

Тег min=8 знает про длину и пропускает восемь цифр; требование «в пароле есть буквы» — знание домена, и у тега его нет. Конструктор возвращает error — это защита в глубину: слой формата и слой смысла ловят разные классы проблем, и провал первого не снимает второй.

### Домен: инварианты в конструкторе

Доменная структура отвечает на вопрос, что делает объект осмысленным, и ответы живут в конструкторе: NewUser принимает проверенные на границе поля и возвращает (*User, error). Что проверять здесь, если формат уже закрыли теги? Инварианты: пароль не из одних цифр, счёт открывается с неотрицательным балансом, у заказа есть хотя бы одна позиция. Это правила смысла, а не транспорта: они не меняются вместе с API и не зависят от того, пришёл объект из HTTP, из джобы или из консольной команды. Конструктор отклоняет бессмысленное состояние до того, как оно обретёт ссылки: нулевой User не гуляет по коду, и вызывающему не нужно помнить, что «пароль надо бы ещё проверить». Честная оговорка о границах приёма: Go не запрещает литерал User{...} внутри того же пакета, и коллега в спешке им воспользуется. Защита делается unexported-полями с геттерами либо соглашением, зафиксированным ревью и тестом, который строит объект только через конструктор. Громоздкость — реальная цена, и окупается она один раз: когда неправильное состояние домена невозможно создать в принципе, класс багов про «а вдруг там нули» закрывается с обеих сторон пакета. Называйте ошибки конструктора доменными именами — ErrWeakPassword, ErrEmptyOrder — и не оборачивайте их в HTTP-тексты внутри пакета: домен отвечает, что не так по смыслу, а перевод на язык HTTP — работа границы.

> **Живое место: литерал мимо конструктора.** Сервисы перевели на NewUser, но в пакете остался один литерал: обработчик импорта из CSV собирал User{Email: row[0]} напрямую — «данные ведь свои». Если хоть один путь обхода конструктора живёт, то нулевые и непроверенные значения едут в базу как данные: через месяц в таблице оказались пользователи без хэша пароля, а первый вход сломался у половины импортированных. Разбор занял день и свёлся к правилу: домен строится только конструктором — запрет литералов структуры на ревью, тест на все пути создания. Если новый код создаёт объект иначе, чем NewUser, то это баг, даже когда тесты зелёные.

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

> **Про песочницу: HTTP-конвейер — локально.** Песочница сайта не поднимет 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. Кто ошибся?

- ✅ Никто: слои проверяют разное, и домен вправе отказать
- Дублирование правил: один из слоёв надо удалить
- Конструктор: он обязан доверять проверкам границы
- Теги: min=8 настроен слишком слабо, уберём его

Теги закрывают формат, конструктор — инварианты: пароль из восьми цифр корректен по длине и бессмыслен как пароль. Отказ домена после пройденных тегов — признак работающей защиты в глубину; пересматривать стоит тег, если он противоречит политике, а не слой.

### Граница API одним списком

- request-DTO с тегами validate живёт в транспортном слое и выражает контекст операции
- порядок на границе: bind → Normalize → validate.Struct → маппинг в домен
- маппинг пишется руками: компилятор проверяет каждое поле при переименовании
- инварианты домена — в конструкторе, возвращающем (*T, error); литералы домена — вне закона
- response-DTO — белый список: новое поле API появляется только по вашему решению
- хэши и внутренние флаги не сериализуются вовсе: их нет в response-DTO
- хранилище — последний рубеж: constraints и уникальные индексы работают мимо вашего кода
- маленький проект может валидировать одну структуру — до первого второго контекста или клиента

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

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

```go
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)
        // ...
}
```

Подсказка: После Normalize правила тегов исполняет validate.Struct(&in) — без него домен получит непроверенный формат.

Правильные ответы в пропусках: `validate.Struct(&in)`

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

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

Правильный порядок строк:

1. `Тело запроса декодировано в SignupDTO`
2. `in.Normalize() очистила пробелы и регистр`
3. `validate.Struct исполнил теги формата`
4. `ValidationErrors превратились в ответ 400 по полям`
5. `domain.NewUser проверил инвариант и вернул *User`
6. `Сервис вызвал бизнес-операцию с доменным объектом`
7. `UserView собрал белый список полей наружу`
8. `База данных удержала constraints и уникальный индекс`

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

- **request-DTO** — Буфер на входе: поля одной операции и теги validate, выражающие контекст. Регистрация и профиль получают разные структуры, а не ветвления в одной проверке.
- **конструктор домена** — NewUser принимает проверенные поля и возвращает (*User, error): инварианты отклоняются до появления объекта. Поля unexported — литерал мимо конструктора невозможен снаружи пакета.
- **response-DTO** — Белый список на выходе: наружу едут только объявленные поля. Новое поле домена само не становится ключом JSON-ответа, а секреты не покидают слой.
- **защита в глубину** — Формат ловят теги, смысл — конструктор, целостность — constraints БД. Три линии, каждая рассчитана на провал предыдущей.
- **цена дублирования** — Поля DTO повторяют домен — осознанная плата за независимые слои. Маленький проект может валидировать одну структуру, но миграция DTO потом болезненна.
- **маппинг DTO → домен** — Ручное копирование полей в вызов конструктора: компилятор проверяет соответствие при переименовании. Автомапперы прячут соответствие от компилятора.

Всё из этого урока опирается на соседние темы: [нормализация перед валидацией](/courses/validator/lessons/15-normalizaciya/) поставит чистку до проверок на границе, [ValidationErrors](/courses/validator/lessons/05-oshibki-validacii/) соберёт ответ 400 по полям, а [один экземпляр на приложение](/courses/validator/lessons/14-odin-ekzemplar/) объяснит, почему validate.Struct зовётся на пакетном валидаторе, а не на validator.New() в хендлере. Следующий урок — [валидация JSON-запросов](/courses/validator/lessons/17-json-zaprosy/): что ломается до того, как request-DTO получен, и почему две ошибки из одной строки тела требуют разных ответов.

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

- Теги 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 символов» и «пароль не может состоять из одних цифр». Тип ошибки на границе и определяет конверт ответа.
