---
title: "Итоговый проект: регистрационная форма"
description: "validator проект: регистрационная форма целиком — DTO с тегами, Normalize, один экземпляр, русские сообщения, ответ 400, доменный 409 и табличные тесты."
course: "Валидация в Go: go-playground/validator"
url: "https://golangmaster.ru/courses/validator/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Итоговый проект: регистрационная форма

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

Итоговый validator проект собирает конвейер регистрации пользователя целиком: от JSON-запроса до сохранённого аккаунта, из кирпичей, разобранных за двадцать четыре урока. Нового здесь мало, и это честно: финал проверяет умение складывать знакомое в работающую границу API, а не знание свежих функций. Форма регистрации получает DTO с тегами, Normalize чистит почту и ник, один экземпляр валидатора исполняет правила, русский словарь переводит FieldError в человеческие тексты, единый JSON отвечает 400 по полям, сервис отсекает занятый email доменной ошибкой 409, а табличные тесты держат правила под замком. Сборка умещается в три куска: DTO с тегами и Normalize, маппер ValidationErrors в ответ и табличный тест. Каждое решение помечено уроком-источником, так что непонятный шов открывается заново за минуту, а весь конвейер переносится в свой проект почти без правок.

Проект живёт в трёх слоях, и роли из урока про DTO здесь не меняются. Хендлер принимает запрос: декодирует тело, зовёт Normalize, исполняет validate.Struct и собирает ответ — HTTP-код и JSON. Сервис решает бизнес: проверяет, свободен ли email, отображает заявку в доменного пользователя и отвечает за сохранение. Репозиторий говорит с хранилищем, и в уроке он честная заглушка: интерфейс UserStore с Exists и Create, без SQL и драйвера — в реальном проекте за ним стоят база и уникальный индекс. Валидатор приезжает в хендлер зависимостью: экземпляр создан один раз на приложение, RegisterTagNameFunc настроен рядом с созданием, и в тесты идёт тот же экземпляр без хаков. Порядок разбора: сначала конвейер целиком и таблица кирпичей, затем DTO поле за полем, словарь сообщений с единым ответом 400, бизнес-правило с 409, и в конце — табличный тест, который держит все правила под замком.

### validator проект: конвейер из кирпичей

Семь шагов конвейера регистрации — семь уроков курса, и таблица ниже — карта проекта. Декод тела в SignupRequest закрывает семнадцатый урок: битый JSON отделяется до валидации и отвечает без разбора полей. Дальше Normalize, validate.Struct на одном экземпляре, каталог русских сообщений и BuildValidationResponse с ответом 400 по полям. Шестой шаг — сервис: теги не знают, занята ли почта, это вопрос к хранилищу, и отказ звучит иначе — 409, а не 400. Седьмой — табличный тест: на каждый тег своя строка таблицы. Заметьте, чего в конвейере нет: подтверждения почты и согласия на обработку данных. Это не теги, а состояния аккаунта и отдельные шаги процесса — двадцать второй урок раскладывает их по закону, валидатору здесь ловить нечего. Формат заявки и жизнь заявки после ошибки — две разные истории, и конвейер честно держит их по разные стороны ответа.

### Шаг конвейера — урок проекта

| Шаг конвейера | Урок | Что делает в проекте |
| --- | --- | --- |
| DTO с тегами validate | 16 — границы API | SignupRequest: контекст регистрации в полях и тегах |
| Нормализация | 15 | Normalize: TrimSpace, регистр почты; пароль не трогаем |
| Валидация | 14 — один экземпляр | validate.Struct на пакетном валидаторе-зависимости |
| Сообщения | 06 | русский словарь по Tag() и Field(), default-страховка |
| Ответ 400 | 24 | единый JSON: срез «поле — текст», битое тело отдельно |
| Сервис | 23 | email занят — доменная ошибка и 409, не 400 |
| Тесты | 19 | табличные кейсы: Field() и Tag() вместо текста |

### DTO: теги, Normalize и один экземпляр

SignupRequest — request-DTO по правилам [урока про границы API](/courses/validator/lessons/16-dto-granicy/): поля одной операции, теги validate, Normalize методом. Email получает required и email: сначала обязательность, потом формат — порядок в цепочке тегов становится порядком сообщений. Никнейм: min=2 и max=32, границы длины в рунах. Пароль: min=8; подтверждение — required и eqfield=Password. Здесь честная механика: eqfield сравнивает поле с другим полем той же структуры — параметр тега это имя соседнего поля, а не значение; других вариантов у сравнения паролей нет. Если второе поле не найдено или его тип не совпадает с вашим, правило провалено. Возраст — required и gte=18, и это граница тега, о которой договоримся вслух: gte=18 проверяет самозаявленное число из формы; если проект хранит дату рождения и хочет «18 лет на сегодня», тегу нужен сегодняшний день, которого в DTO нет, — правило смысла уезжает в сервис, о чём говорил двадцать третий урок. Normalize чистит email тримом и нижним регистром, ник — тримом; пароль и подтверждение не трогаем — секреты не канонизируем. Экземпляр валидатора создан один раз и приходит в хендлер зависимостью; рядом с созданием — RegisterTagNameFunc, чтобы Field() говорил json-именами, и каталог сообщений сразу знал имена фронта.

### SignupRequest: теги и Normalize

```go
package httpapi

import "strings"

type SignupRequest 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"`
        ConfirmPassword string `json:"confirm_password" validate:"required,eqfield=Password"`
        Age             int    `json:"age" validate:"required,gte=18"`
}

func (in *SignupRequest) Normalize() {
        in.Email = strings.ToLower(strings.TrimSpace(in.Email))
        in.Nickname = strings.TrimSpace(in.Nickname)
        // Password и ConfirmPassword не чистим: секреты не канонизируем
}
```

Пять полей — пять договоров: почта обязательна и по формату, ник в пределах двух и тридцати двух символов, пароль от восьми, подтверждение совпадает с Password через eqfield, возраст от восемнадцати. Normalize живёт методом на DTO и вызывается до validate.Struct: трим и нижний регистр для email, трим для ника, секретные поля не тронуты. Порядок required,eqfield даёт внятное сообщение на пустом подтверждении.

### Проверка: тег для подтверждения пароля

**Вопрос:** Какой тег заставит валидатор сверить пароль с подтверждением?

- ✅ eqfield=Password: сравнить поле с соседним полем структуры
- eq=Password: сравнить значение со строкой Password
- min=8 на обоих полях: равные длины значат равенство
- required_with=Password: подтверждение обязательно при пароле

eqfield принимает имя другого поля той же структуры и сравнивает значения. eq сверяет поле с константой, одинаковые минимумы длины не делают строки равными, а required_with лишь требует заполнения. Подтверждение пароля — самый частый случай eqfield на формах.

### Пустое подтверждение при заполненном пароле

```go
in := SignupRequest{
        Email:    "ada@example.com",
        Nickname: "ada",
        Password: "qwerty12",
        // ConfirmPassword пуст
        Age: 30,
}
err := validate.Struct(&in)
```

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

- ✅ required: цепочка тегов остановилась на первом провале
- eqfield: пустая строка не равна паролю
- nil: eqfield молчит на пустых полях
- InvalidValidationError: структура заполнена не целиком

Пустая строка нулевая, поэтому required проваливается первым — и проверка поля останавливается, до eqfield дело не доходит. eqfield сработал бы при заполненном, но чужом подтверждении. «Обязательное поле» на пустом вводе честнее «пароли не совпадают».

### Русский словарь и единый ответ 400

Сырьё ошибок даёт пятый урок: Field(), Tag(), Param(). Шестой собирает из него каталог: общие тексты по тегам, уточнения по полям, ветка default с ActualTag() на незнакомый тег. В проекте каталог пополняется одной строкой: eqfield заслуживает человеческого текста, и уточнение по полю ConfirmPassword говорит «пароли не совпадают» вместо технического «проверка eqfield не прошла». Для min продолжает работать согласование числительных: минимум 8 символов. Дальше конверт ответа: BuildValidationResponse перебирает ValidationErrors и складывает пары «поле — текст» в срез, а хендлер отдаёт его с кодом 400. Единый формат — тема двадцать четвёртого урока, и здесь берём минимальный честный вид: срез объектов, чтобы два сообщения об одном поле не затирали друг друга в ключах карты, плюс отдельная ветка для битого тела. Русские тексты живут в пакете сообщений рядом с валидатором, как собирает [урок про русские сообщения](/courses/validator/lessons/06-russkie-soobshcheniya/), а хендлер отдаёт один и тот же JSON и для 400, и для остальных ошибок API, как учит [урок про единый формат](/courses/validator/lessons/24-soobshcheniya-api/).

### BuildValidationResponse: ValidationErrors в JSON

```go
type fieldIssue struct {
        Field   string `json:"field"`
        Message string `json:"message"`
}

func BuildValidationResponse(verrs validator.ValidationErrors) []fieldIssue {
        out := make([]fieldIssue, 0, len(verrs))
        for _, e := range verrs {
                out = append(out, fieldIssue{
                        Field:   e.Field(),       // json-имя: RegisterTagNameFunc настроен при создании
                        Message: humanMessage(e), // каталог шестого урока: уточнение, затем тег
                })
        }
        return out
}

func writeValidation(w http.ResponseWriter, verrs validator.ValidationErrors) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusBadRequest)
        _ = json.NewEncoder(w).Encode(map[string]any{"errors": BuildValidationResponse(verrs)})
}
```

Срез вместо карты — сознательный выбор: на одном поле стоит цепочка правил, и карта по имени поля потеряла бы второе сообщение. Field() отдаёт json-имя благодаря RegisterTagNameFunc, humanMessage достаётся проекту из шестого урока вместе с уточнением для ConfirmPassword. writeValidation фиксирует 400 и один конверт для всех полевых ошибок; битое тело декодера уходит отдельной веткой хендлера.

### Бизнес-правило: email уже занят

Теги говорят «форма заполнена правильно», но не «эта почта свободна». За ответ отвечает сервис: UserStore — интерфейс-заглушка с Exists(email) bool и Create(u) error; за ним в реальном проекте база с уникальным индексом. Занятая почта — доменная ошибка ErrEmailTaken, и хендлер переводит её в 409 Conflict, а не в 400: ValidationErrors говорят «исправьте поля заявки», доменный отказ говорит «заявка корректна, но конфликтует с состоянием» — прямой стык с двадцать третьим уроком. Клиент на 409 предлагает взять другую почту, а не подсвечивает поле как форматное. Честная деталь напоследок: между Exists и Create почту может занять параллельная регистрация, и никакая проверка в сервисе этого не закроет — последний рубеж всегда в хранилище, уникальный индекс отвечает ошибкой вставки, которую сервис снова переводит в ErrEmailTaken и 409. Проверка в сервисе — не замена индексу, а быстрый путь к внятному сообщению для большинства запросов.

### Проверка: какой код за какой отказ

**Вопрос:** Теги прошли, сервис вернул ErrEmailTaken. Что отвечает хендлер?

- ✅ 409 Conflict: заявка корректна, но конфликтует с состоянием
- 400 со словарём по полю Email: занятая почта — тоже ошибка поля
- 500: любая ошибка сервиса — внутренняя
- 200 с телом «email занят»: регистрация ведь не состоялась

ValidationErrors и доменные ошибки — разные классы: формат заявки отвечает 400 со словарём полей, конфликт с состоянием — 409. Фронт различает «исправь формат» и «возьми другую почту», а 200 с сообщением об ошибке обманывает клиентов и кэши.

> **Живое место: подтверждение, которое не приходило.** Форму переносили со старого фронта, и поле подтверждения приезжало под именем password2, а в DTO стоял json-тег confirm_password. Декодер клал значение мимо: ConfirmPassword оставалось пустым, и пользователь с двумя заполненными строками получал «обязательное поле». Час разбора свёлся к контракту имён: если json-тег расходится с тем, что шлёт клиент, то значение не доезжает, поле остаётся пустым, цепочка тегов останавливается на required — до eqfield дело не доходит, и «пароли не совпадают» вы не прочитаете. Если поле подозрительно пустое при заполненной форме, сравните json-теги с телом запроса первыми, а не правила валидации.

### Табличный тест конвейера

Тесты правил из [девятнадцатого урока](/courses/validator/lessons/19-testy-validacii/) переносятся в проект один к одному: срез кейсов с именем, кандидатом и ожидаемой парой поле-тег, ветка успеха с пустой парой. Сверяем Field() и Tag(), а не текст Error(): формулировки меняет каталог, имена правил — нет. Кейсы накрывают конвейер целиком: валидная форма с пустой парой; короткий пароль — min; несовпадающие пароли — eqfield; пустое подтверждение — required, потому что цепочка остановилась раньше eqfield; мусорный email — email; пустой ник — required; семнадцать лет — gte. Тест работает на том же пакетном экземпляре, что и хендлер: RegisterTagNameFunc уже настроен, поэтому Field() возвращает json-имена, и таблица ожиданий говорит языком фронта. Запускается всё командой go test в терминале — песочница сайта показывает интерактивы, но каркас testing не исполнит.

### TestSignupRules: пять правил одной таблицей

```go
func TestSignupRules(t *testing.T) {
        tests := []struct {
                name      string
                in        SignupRequest
                wantField string
                wantTag   string
        }{
                {"валидная форма", SignupRequest{
                        Email: "ada@example.com", Nickname: "ada", Age: 30,
                        Password: "qwerty12", ConfirmPassword: "qwerty12",
                }, "", ""},
                {"короткий пароль", SignupRequest{
                        Email: "ada@example.com", Nickname: "ada", Age: 30,
                        Password: "qwerty1", ConfirmPassword: "qwerty1",
                }, "password", "min"},
                {"пароли не совпадают", SignupRequest{
                        Email: "ada@example.com", Nickname: "ada", Age: 30,
                        Password: "qwerty12", ConfirmPassword: "qwerty1213",
                }, "confirm_password", "eqfield"},
                {"пустое подтверждение", SignupRequest{
                        Email: "ada@example.com", Nickname: "ada", Age: 30,
                        Password: "qwerty12",
                }, "confirm_password", "required"},
                {"мусорный email", SignupRequest{
                        Email: "ada-example.com", Nickname: "ada", Age: 30,
                        Password: "qwerty12", ConfirmPassword: "qwerty12",
                }, "email", "email"},
                {"пустой ник", SignupRequest{
                        Email: "ada@example.com", Age: 30,
                        Password: "qwerty12", ConfirmPassword: "qwerty12",
                }, "nickname", "required"},
        }

        for _, tc := range tests {
                t.Run(tc.name, func(t *testing.T) {
                        err := validate.Struct(&tc.in)
                        if tc.wantTag == "" {
                                if err != nil {
                                        t.Fatalf("ожидали успех, получили %v", err)
                                }
                                return
                        }
                        var verrs validator.ValidationErrors
                        if !errors.As(err, &verrs) {
                                t.Fatalf("тип ошибки не ValidationErrors: %v", err)
                        }
                        if got := verrs[0].Field(); got != tc.wantField {
                                t.Fatalf("поле: %s, ожидали %s", got, tc.wantField)
                        }
                        if got := verrs[0].Tag(); got != tc.wantTag {
                                t.Fatalf("тег: %s, ожидали %s", got, tc.wantTag)
                        }
                })
        }
}
```

Каркас из девятнадцатого урока: имя кейса, кандидат, ожидаемая пара и одна ветка на успех и провал. Валидная форма закрывает eqfield и gte разом — все пять правил на честных данных. Пара кейсов подтверждения показывает порядок цепочки: пустое поле даёт required, заполненное чужое — eqfield. wantField в json-именах: экземпляр общий с хендлером, и тест проверяет тот же контракт, что получают клиенты.

> **Про песочницу.** Конвейер урока — три файла с внешним модулем validator, HTTP-хендлером и тестами: песочница сайта такое не собирает, она рассчитана на чистый Go без go.mod. Интерактивы проверяют порядок шагов и имена тегов, а живой прогон локален: go mod init, go get github.com/go-playground/validator/v10, go test — и таблица кейсов покраснеет на первом сломанном правиле.

### Чек-лист сборки конвейера

- SignupRequest в транспортном слое: теги контекста регистрации, не домена
- порядок на границе: декод, Normalize, validate.Struct, ответ
- Normalize до проверок: трим и регистр email, трим ника, пароль не трогаем
- eqfield=Password на подтверждении; required раньше eqfield даёт внятный текст на пустом
- экземпляр валидатора один, RegisterTagNameFunc настроен при создании: Field() говорит json-именами
- 400 — срез «поле — текст» из каталога; битое тело — отдельная ветка без полей
- занятый email — ErrEmailTaken из сервиса и 409; уникальный индекс — последний рубеж
- табличный тест на каждый тег: Field() и Tag() вместо текста ошибки

### Шаги обработки запроса регистрации

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

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

1. `Декодировали JSON-тело в SignupRequest`
2. `in.Normalize() очистила email и ник`
3. `validate.Struct исполнил теги на чистых данных`
4. `ValidationErrors превратились в срез «поле — текст»`
5. `Хендлер ответил 400 с деталями по полям`
6. `Сервис спросил UserStore: свободен ли email`
7. `Занятая почта вернула ErrEmailTaken и ответ 409`
8. `Свободная почта прошла маппинг в домен и сохранение`

### Соберите конвейер signup

Четыре пропуска: чистка, исполнение тегов, код и сборка ответа.

```go
func (h *SignupHandler) Signup(w http.ResponseWriter, r *http.Request) {
        var in SignupRequest
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                writeError(w, http.StatusBadRequest, "битое тело")
                return
        }

        in.___()

        if err := h.validate.___(&in); err != nil {
                var verrs validator.ValidationErrors
                if errors.As(err, &verrs) {
                        writeJSON(w, http.___, map[string]any{"errors": ___(verrs)})
                        return
                }
                writeError(w, http.StatusBadRequest, "проверьте поля")
                return
        }
        // дальше сервис: доменная проверка и сохранение
}
```

Подсказка: До проверок — Normalize, теги исполняет validate.Struct на экземпляре-зависимости; полевые ошибки едут в 400 с BuildValidationResponse.

Правильные ответы в пропусках: `Normalize`, `Struct`, `StatusBadRequest`, `BuildValidationResponse`

### Карточки: проект целиком

- **eqfield** — Сравнивает поле с другим полем той же структуры (eqfield=Password). Чужого поля нет или тип не совпал — правило провалено. Подтверждение пароля выражается только так.
- **Normalize на DTO** — TrimSpace и нижний регистр email, трим ника, пароль не трогаем. Метод на структуре, вызов до validate.Struct — обязанность хендлера.
- **Один экземпляр validate** — Создаётся один раз на приложение, RegisterTagNameFunc вешает json-имена, в хендлер приходит зависимостью. Тесты работают на том же экземпляре.
- **400 против 409** — ValidationErrors — про формат заявки: 400 со срезом «поле — текст». «Email уже занят» — доменная ошибка сервиса: 409. Уникальный индекс — последний рубеж.
- **BuildValidationResponse** — Перебирает ValidationErrors в срез пар «поле — текст»: Field() с json-именем и humanMessage из каталога. Срез, а не карта: правила в цепочке не затирают друг друга.
- **Табличный тест** — Имя кейса, кандидат, wantField и wantTag; пустая пара — успех. Field() и Tag() вместо текста ошибки: формулировки меняет каталог, имена правил — нет.

Курс закрыт, и конвейер этого урока — его сумма: DTO с тегами на границе, нормализация до проверок, один экземпляр с json-именами, русский каталог сообщений, единый ответ 400, доменный 409 из сервиса и табличные тесты на каждое правило. Перенесите файлы в свой проект и доращивайте в стороны, которые курс сознательно не тронул: подтверждение почты письмом и согласие на обработку — отдельные шаги, а не теги; возраст по дате рождения и лимиты — сервис; фреймворки из двенадцатого и тринадцатого уроков возьмут на себя транспорт, но порядок шагов оставят тот же. Шпаргалки по тегам собраны на странице курса — держите её рядом, когда добавляете новое правило. Последний совет финала: любое правило входит в проект одинаково — тег на DTO, строка в каталоге сообщений, строка в таблице тестов. Три места, три минуты, и правило проверяемо так же, как исполняется.

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

- Итоговый конвейер регистрации: декод, Normalize, validate.Struct, 400 по полям, сервис с 409, сохранение.
- SignupRequest — request-DTO: email, ник min=2/max=32, пароль min=8, подтверждение eqfield=Password, возраст gte=18.
- eqfield сравнивает поля одной структуры: подтверждение пароля выражается только так.
- Normalize чистит email тримом и регистром, ник — тримом; пароль не нормализуем.
- Один экземпляр валидатора — зависимость хендлера; RegisterTagNameFunc даёт Field() json-имена.
- BuildValidationResponse собирает срез «поле — текст» из каталога; битое тело отвечает отдельно.
- Занятый email — доменная ошибка: 409 вместо 400; уникальный индекс — последний рубеж.
- Табличные тесты сверяют Field() и Tag(): контракт правил переживёт любые формулировки.

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

Соберите конвейер целиком: SignupRequest с пятью тегами, Normalize, пакетный экземпляр с RegisterTagNameFunc, каталог сообщений с уточнением для подтверждения пароля, BuildValidationResponse и заглушку UserStore на карте занятых почт. Прогоните табличный тест из урока и добавьте свой кейс на возраст: семнадцать лет обязаны дать gte на age. Затем сломайте контракт дважды: уберите eqfield с подтверждения и убедитесь, что кейс «пароли не совпадают» покраснел; отправьте подтверждение под именем password2 и посмотрите, что вернётся required по confirm_password. Дорисуйте ветку 409: два запроса подряд с одной почтой — первый создаёт аккаунт, второй получает 409 с тем же конвертом ошибки, что и 400.

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

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

eqfield сравнивает два поля одной структуры, а второго поля в заявке нет: подтверждение — отдельное значение, которое присылает клиент. Нечего сравнивать на поле Password, и хранить пароль дважды ради проверки — путь к рассинхрону. Поэтому ConfirmPassword — самостоятельное поле с required и eqfield=Password, а другие подтверждения — почта письмом и согласие на обработку — остаются шагами процесса, не тегами.

### Когда отвечать 400, а когда 409?

400 — когда заявку нельзя принять по форме: ValidationErrors по полям с русским текстом из каталога. 409 — когда форма корректна, но конфликтует с состоянием: почта уже занята. Разделение дешевле, чем кажется: клиент смотрит на код и выбирает сценарий — подсветить поля или предложить другую почту, а единый JSON-конверт делает оба ответа разборчивыми одним парсером.

### Чем конвейер в Gin или Echo отличается от этого проекта?

Порядком вызовов, не смыслом: в Gin ShouldBindJSON совмещает декод и проверку, и конвейер из пятнадцатого урока разлепляет их — правила в validate-тегах, Normalize между шагами; в Echo и chi шаги и так пишутся руками. Сервис, словарь сообщений, 400 и 409 не меняются вовсе: валидатор — обычная библиотека, а фреймворк — только транспорт вокруг неё.
