Содержание урока · 13 разделов
- структура проекта gin: зачем резать по слоям
- handler/service/repository: контракты слоёв gin
- Хендлер-тоник: bind, вызов, конверт
- Сервис: правила и интерфейс на стороне потребителя
- Репозиторий: единственный, кто знает SQL
- DI без фреймворков: конструкторы и композиционный корень
- Маршруты по доменам: RegisterRoutes
- Грабли: god-пакеты, циклы и протечки
- Что где живёт: шпаргалка по слоям
- Шесть правил слоёв gin
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
структура проекта gin: зачем резать по слоям#
Учебный хендлер из урока про binding совмещал три работы: распарсить запрос, решить, что делать, и достать данные откуда-то сбоку. На трёх маршрутах это читается, на трёхстах — нет: каждый хендлер дублирует проверку прав, половина повторяет один и тот же SQL, а тест на бизнес-правило требует поднимать HTTP-сервер с базой. Разрез по слоям даёт каждому пакету одну причину для изменения: транспорт меняется, когда меняется контракт API, бизнес — когда меняются правила предметной области, данные — когда меняется хранилище. Три разные причины не живут в одной функции, поэтому PR перестают конфликтовать, а ревью — превращаться в чтение романа. Второй выигрыш — подменяемость: сервис, зависящий от интерфейса, в проде получает Postgres, в тесте — карту в памяти, и ни одна строка логики об этом не знает. Третий — онбординг: новичок находит конец цепочки «запрос → хендлер → сервис → репозиторий» за минуты. Плата — церемония: лишние структуры и функции на каждый маршрут. Для хобби-проекта из пяти ручек она избыточна, для команды и долгой жизни — самая дешёвая страховка из существующих.
handler/service/repository: контракты слоёв gin#
Канон для веб-сервиса на gin — три слоя. Handler — транспортный слой: знает HTTP, gin и всё о конвертах из урока про ошибки; биндит запрос структурой с тегами, зовёт одну функцию сервиса и маппит результат в статус и JSON. Он не содержит ни одного правила домена — только правила протокола. Service — бизнес-слой: лимиты, скидки, статусы заказов, согласованность, транзакционные сценарии; принимает на вход обычные структуры и контекст, возвращает доменные значения или доменные ошибки вроде ErrLimitReached. Repository — слой данных: единственный пакет, который знает SQL, имена таблиц и особенности драйвера; прячет это за простыми методами Create, ByID, List. Ключевое правило направлений: зависимости смотрят строго вниз — хендлер знает сервис, сервис знает интерфейс хранилища, репозиторий не знает никого. Наверх ничего не протекает: сервис не импортирует gin, репозиторий не возвращает DTO с json-тегами. Пока это правило держится, каждый слой тестируется отдельно, а слои gin остаются слоями, а не слоёным пирогом из-за одной «временной» ссылки.
Карта каталога: cmd, internal и три пакета
GOtodoapp/
├── go.mod
├── cmd/
│ └── server/
│ └── main.go // композиционный корень: сборка и старт
└── internal/
├── config/
│ └── config.go // чтение окружения и дефолтов
├── handler/
│ └── todo.go // транспорт: bind, статусы, конверты
├── service/
│ └── todo.go // бизнес-правила + интерфейс хранилища
└── repository/
├── postgres.go // SQL-реализация контракта
└── memory.go // in-memory для тестов и локального пускаcmd/server/main.go — точка входа, в нём нет логики, только сборка. Каталог internal — магия компилятора: пакеты внутри импортируются только вашим модулем, соседний проект их импортировать не сможет, поэтому здесь живёт всё приватное. Пакеты названы по роли, а не по типу: не models/utils/helpers, а handler/service/repository — по имени сразу ясно, что за ответственность внутри.
Проверка: где место правилу скидки
Функция пересчитывает скидку: тариф пользователя, история покупок за месяц, потолок от суммы корзины. Никакого HTTP, никакой базы — чистые правила. В каком пакете ей место?
Хендлер-тоник: bind, вызов, конверт#
Здоровый хендлер читается за пятнадцать секунд и умещается в один экран. Его работа: забиндить вход, вызвать сервис, перевести результат на язык HTTP. Забиндили — ошибку валидации отдали клиенту статусом 400 с понятным сообщением, не доливая стектрейсы. Вызвали сервис — получили либо значение, либо доменную ошибку; маппинг ошибок в статусы стоит в одном месте, лучше в общем хелпере из урока про ошибки, а не размазан по каждому хендлеру. Успех — c.JSON со статусом 200 или 201 и готовой структурой ответа. Обратите внимание на формы: хендлер принимает не gin.Engine, а зависимости через конструктор, и метод Create(c gin.Context) остаётся единственным местом, где упоминается gin. Если внутри хендлера появляется if-лес на тридцать строк — это уже бизнес-логика, и ей место в сервисе; правило большого пальца: хендлер не принимает решений, он переводит. Тогда замена gin на любой другой фреймворк — это переработка одного пакета, а сервисная и репозиторная части переезжают как есть.
Транспортный слой: только перевод
GOtype CreateTodoRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
}
type TodoResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
func (h *TodoHandler) Create(c *gin.Context) {
var in CreateTodoRequest
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
todo, err := h.svc.Create(c.Request.Context(), in.Title)
if err != nil {
c.JSON(http.StatusInternalServerError,
gin.H{"error": "внутренняя ошибка"})
return
}
c.JSON(http.StatusCreated, toTodoResponse(todo))
}Три шага и ничего лишнего: bind со статусом 400, вызов сервиса с контекстом запроса, конверт ответа. Контекст прокидывается именно из запроса — так отмена соединения доезжает до репозитория и обрывает долгий SQL. Структуры с json-тегами живут в пакете handler: сервис не знает про DTO, он оперирует доменным типом Todo. Маппер toTodoResponse — четыре строки, зато домен не засоряется транспортными тегами.
Сервис: правила и интерфейс на стороне потребителя#
Сервис — сердце приложения, и тут живёт самая важная идиома Go: интерфейс объявляет тот, кто им пользуется, а не тот, кто реализует. TodoService нуждается в хранилище — значит, контракт TodoRepository объявляется в пакете service и описывает ровно те методы, которые нужны сервису, а не «универсальный DAL на все случаи». Это переворачивает привычку из других языков, где интерфейсы пишут рядом с реализацией: здесь реализация просто случайно подходит под контракт, и ничего не знает о своём потребителе. Выигрыш в двух местах. Тесты сервиса получают крошечный мок — не БД-фреймворк, а структура с тремя методами. И репозиторий нельзя раздуть: контракт содержит только то, что реально вызывает сервис, а любые попытки тащить лишнее видны в ревью. Доменные ошибки — сентинелы вроде var ErrLimitReached = errors.New(...) — тоже объявляет сервис: именно он решает, какие ситуации в домене вообще существуют. Хендлер ловит их через errors.Is из урока про ошибки и переводит в статусы; репозиторий при желании оборачивает свои ошибки через %w, чтобы цепочка сохранилась.
Бизнес-слой: контракт хранилища и правила
GOpackage service
import (
"context"
"errors"
)
var (
ErrLimitReached = errors.New("лимит задач исчерпан")
ErrNotFound = errors.New("задача не найдена")
)
// Контракт объявлен ПОТРЕБИТЕЛЕМ, а не реализацией.
type TodoRepository interface {
CountOpen(ctx context.Context, ownerID uint) (int, error)
Create(ctx context.Context, t Todo) (Todo, error)
}
const maxOpenTodos = 50
type TodoService struct {
repo TodoRepository
}
func NewTodoService(repo TodoRepository) *TodoService {
return &TodoService{repo: repo}
}
func (s *TodoService) Create(ctx context.Context, ownerID uint, title string) (Todo, error) {
open, err := s.repo.CountOpen(ctx, ownerID)
if err != nil {
return Todo{}, err
}
if open >= maxOpenTodos {
return Todo{}, ErrLimitReached
}
return s.repo.Create(ctx, Todo{OwnerID: ownerID, Title: title})
}maxOpenTodos — бизнес-константа, и она живёт здесь, а не в SQL и не в хендлере. Сервис зависит от интерфейса, объявленного в собственном пакете, — Postgres-реализация напишет те же три метода и подойдёт. Create принимает ownerID и title как обычные аргументы: никаких gin.Context и http.Request, поэтому тест сервиса — это конструктор с моком и пара утверждений.
Проверка: почему интерфейс живёт в service
Интерфейс TodoRepository объявлен в пакете service, а реализация — в repository. Почему не наоборот: контракт в repository, а сервис им пользуется?
Репозиторий: единственный, кто знает SQL#
Repository превращает методы контракта в запросы. Внутри — database/sql или ORM по вкусу, имена таблиц, колоночные маппинги, транзакции. Три правила, которые держат слой в узде. Первое: методы говорят языком домена — Create, OpenByID, ListByOwner, а не ExecQueryWithParams; SQL-детали не прорываются в сигнатуры. Второе: контекст пронизывает всё — первый аргумент ctx context.Context у каждого метода, чтобы отмена запроса клиентом обрывала работу с базой, как мы делали в потоковых хендлерах из урока 19. Третье: никаких gin-типов и json-тегов — репозиторий обменивается с сервисом доменными структурами. Для тестов рядом лежит вторая реализация контракта: карта с мьютексом, fifty строк кода, и весь сервисный слой гоняется без Docker и Postgres. Устройство in-memory стоит вынести в package repository с суффиксом memory — конструктор NewMemory, а prod-сборка просто передаёт в сервис другую реализацию; больше никакой разницы в коде нет. Если сервису нужны транзакции через несколько методов — не тяните sql.Tx наружу, добавьте в контракт метод WithTx(ctx, fn), и детали останутся внутри.
Две реализации одного контракта
GOpackage repository
import (
"context"
"sync"
"todoapp/internal/service"
)
// Заглушка для тестов: тот же контракт, ноль зависимостей.
type Memory struct {
mu sync.Mutex
todos map[uint][]service.Todo
next uint
}
func NewMemory() *Memory {
return &Memory{todos: map[uint][]service.Todo{}, next: 1}
}
func (m *Memory) CountOpen(_ context.Context, ownerID uint) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, t := range m.todos[ownerID] {
if !t.Completed {
n++
}
}
return n, nil
}Память реализует контракт из пакета service — import указывает вниз по зависимостям, циклов нет. Мьютекс обязателен: gin зовёт хендлеры из многих горутин параллельно. Postgres-реализация выглядит так же снаружи, но внутри — sqlx или database/sql с запросами; main решает, какую из двух конструкций скормить сервису, и тесты меняют одну строку в сборке.
DI без фреймворков: конструкторы и композиционный корень#
Dependency injection на слух пугает, на деле в Go это функция New, принимающая зависимости. Сервис получает хранилище, хендлер — сервис, всё собирается в одном месте — в main.go, который за это зовут композиционным корнем. Граф сборки читается сверху вниз: config → db-пул → repository → service → handler → маршруты → сервер. Каждая зависимость — явный аргумент конструктора: ноль магии, ноль рефлексии, IDE показывает, откуда что взялось. Никаких глобальных переменных вида var DB *sql.DB — глобальное состояние несовместимо с тестами и параллельностью. Нужна инициализация с ошибкой? Конструктор возвращает (T, error), и main честно падает на старте, а не на первом запросе. Большие графы в Go принято собирать руками до десятков зависимостей; когда руками становится мучительно — компилятор google/wire сгенерирует тот же код сборки, но принцип не меняется: зависимости — это аргументы, а не поиск по имени из аннотаций. Правило ленивых проверок: если в main появились две сборки одного сервиса с разными репозиториями — это нормально, это и есть конфигурация; если в самом сервисе появился if по окружению — вы уже сломали слои.
main.go: весь граф в двадцати строках
GOfunc main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
repo := repository.NewPostgres(pool)
svc := service.NewTodoService(repo)
h := handler.NewTodoHandler(svc)
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api/v1")
h.RegisterRoutes(api)
if err := r.Run(cfg.Addr); err != nil {
log.Fatalf("server: %v", err)
}
}Читаем сверху вниз: конфиг, пул, репозиторий, сервис, хендлер, роутер — каждый уровень получает предыдущий как аргумент. Хотите in-memory для локального запуска — замените одну строку на repository.NewMemory(), весь остальной код не сдвинется. Роутер-группа из урока 10 приходит к хендлеру как обычный аргумент: пакет handler регистрирует свои маршруты, main лишь даёт ему дверь.
Что случится на первом POST
Прочитайте код и выберите, что он выведет:
var h *TodoHandler // забыли собрать: должен был быть NewTodoHandler(svc)
r := gin.New()
r.Use(gin.Recovery())
r.POST("/todos", h.Create)
r.Run(":8080")Маршруты по доменам: RegisterRoutes#
Когда хендлеров больше пяти, регистрация маршрутов в main превращается в свалку. Решение — по одному методу RegisterRoutes на доменный хендлер: TodoHandler регистрирует свою пятёрку маршрутов на переданной группе, UserHandler — свою, main перечисляет домены, а не endpoints. Группы gin из урока про маршруты идеально ложатся на этот приём: главная группа /api/v1 создаётся в main с общими звеньями из урока 06, подгруппы по доменам получают свои лимиты и гварды. Версионирование тоже встаёт на место: v2 — та же сборка хендлеров с другой группой, и только те домены, которые реально изменились, регистрируются второй раз. Такая схема делает структуру проекта gin самодокументирующейся: список доменов виден в main, детали каждого — в своём файле. Альтернатива «одна огромная функция Routes()» работает ровно до первого конфликта слияний: десять человек правят один файл, и ревью диффа становится археологией. Плюс доменная регистрация упрощает вынос сервиса в отдельное приложение — вы отрезаете домен вместе с его RegisterRoutes.
Домен сам знает свои маршруты
GOfunc (h *TodoHandler) RegisterRoutes(g *gin.RouterGroup) {
todos := g.Group("/todos")
{
todos.GET("", h.List)
todos.POST("", h.Create)
todos.GET("/:id", h.Get)
todos.PATCH("/:id", h.Update)
todos.DELETE("/:id", h.Delete)
}
}
// в main.go:
api := r.Group("/api/v1", requestID(), rateLimit(cfg))
{
h.RegisterRoutes(api)
uh.RegisterRoutes(api) // users
sh.RegisterRoutes(api) // stats
}Один домен — один метод регистрации: пути, глаголы и подгруппы лежат рядом с их хендлерами. main остаётся каталогом: три строки вместо тридцати, конфликты слияний исчезают, потому что домены правят разные файлы. Общие звенья вешаются на группу в main один раз, домен-специфичные — внутри RegisterRoutes на своей подгруппе.
Проверка: что сервису делать с контекстом gin
Хендлеру нужно передать в сервис данные аутентифицированного пользователя. Почему нельзя просто отдать сервису весь c *gin.Context и читать из него claims?
Грабли: god-пакеты, циклы и протечки#
Грабль первый — god-пакет: service вырастает на два тысяч строк, и команда плодит sub-пакеты по артефактной логике — service/v2, service/helpers. Делите по доменам, а не по размеру: пакет todo и пакет user с независимыми правилами здоровее одного общего. Второй — циклические импорты: репозиторий захотел доменную ошибку из сервиса, сервис уже импортирует репозиторий — Go такое не прощает. Лечится направлением: доменные типы и ошибки живут в самом нижнем пакете домена или в service, а repository импортирует их; противохода нет. Третий — протечка gin вглубь: *gin.Context в сигнатурах сервиса, json-теги на доменных структурах, c.Next() из бизнес-функции. Каждый такой leak делает слой нетестируемым вне HTTP. Четвёртый — DTO-дубляж: не пытайтесь переиспользовать структуру запроса как доменную модель и как SQL-модель одновременно; три маленькие структуры с чёткими границами дешевле одной раздутой с тегами binding, json и db одновременно. Пятый — преждевременная абстракция: если у проекта один репозиторий и один сервис, интерфейс ради интерфейса — церемония ради церемонии; вводите контракт, когда появляется вторая реализация или тест-мок. Шестой — тесты через HTTP только: тестируйте сервис напрямую, а httptest из урока 21 оставьте для транспортного слоя.
Что где живёт: шпаргалка по слоям#
| Слой | Живёт здесь | Здесь запрещено |
|---|---|---|
| handler | bind и валидация, DTO, статусы, маппинг ошибок, вызов сервиса | SQL, бизнес-правила, работа с БД напрямую |
| service | правила домена, лимиты, оркестрация, доменные ошибки, контракт хранилища | *gin.Context, json-теги, парсинг запроса |
| repository | SQL/ORM, транзакции, маппинг колонок, пул соединений | статусы HTTP, знание о бизнес-правилах |
| config | чтение окружения, дефолты, валидация настроек | доступ к сети и базе, логика |
| main | сборка графа зависимостей, группы маршрутов, старт и остановка сервера | любая логика кроме композиции |
Шесть правил слоёв gin#
- Зависимости смотрят строго вниз: handler → service → repository; никаких обратных импортов и циклов.
- Интерфейс объявляет потребитель: контракт TodoRepository живёт в service, реализация о нём не знает.
- Хендлер — переводчик: bind, один вызов сервиса, конверт ответа; решения принимает сервис.
- Сервис принимает обычные аргументы и context.Context — никогда *gin.Context.
- main — композиционный корень: весь граф собирается конструкторами в одном читаемом месте, глобальных переменных нет.
- Домен регистрирует свои маршруты сам: RegisterRoutes на группе, main перечисляет домены.
Соберите хендлер по канону
Допишите транспортный слой: привязка, вызов сервиса и статус успеха.
Соберите путь POST /todos через слои
Разложите жизнь одного запроса: от матча маршрута до байт в сокете.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: структура и слои
Возьмите любой прежний пример курса — CRUD из уроков про JSON-ответы или задачи из урока про валидацию — и перенесите в канон: cmd/server/main.go, internal/handler, internal/service, internal/repository. Сервис с лимитом задач, контракт в service, две реализации хранилища: карта и «Postgres»-заглушка. Переключайте сборку одной строкой в main и убедитесь, что ни один другой файл не меняется. Прогоните тест сервиса с моком напрямую, без сервера — это и есть дивиденд слоёв. В браузерной песочнице сайта сетевой код не исполняется, поэтому интерактивы здесь quiz, predict, fill, order и флешкарты, а проект живёт в локальном go run, как и весь курс gin. Дальше — 23-optimizaciya: Release mode, охота на аллокации и pprof — слои дают тестируемость, а профилировщик покажет, где на самом деле тратятся миллисекунды.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Слои gin — handler/service/repository: транспорт, правила домена, данные; каждая функция меняется по одной причине, зависимости смотрят строго вниз.
- Интерфейс хранилища объявляет потребитель: контракт живёт в пакете service, репозиторий подходит под него неявно, мок для тестов — пять строк.
- Хендлер-тоник: ShouldBindJSON, один вызов сервиса с context.Context, маппинг доменных ошибок в статусы; никаких решений и SQL.
- DI в Go — конструкторы: граф собирается в main.go, композиционный корень делает замену реализаций вопросом одной строки.
- Маршруты по доменам: RegisterRoutes на группе из урока 10, main перечисляет домены и общие звенья, конфликты слияний исчезают.
- Грабли: god-пакеты, циклические импорты, *gin.Context в сервисе, DTO-дубляж и интерфейсы ради интерфейса — лечится направлением зависимостей и вторым потребителем как триггером.
Домашнее задание
Соберите todo-сервис по канону из урока: cmd/server/main.go, internal/{config,handler,service,repository}. Сервис TodoService с лимитом открытых задач (константа, ErrLimitReached), контракт TodoRepository объявлен в пакете service и включает CountOpen и Create. Две реализации: Memory на карте с мьютексом и Postgres-заглушка с тем же контрактом. Хендлер Create/List/Delete с DTO и маппингом ErrLimitReached в 409 через errors.Is. RegisterRoutes на группе /api/v1, общие звенья — Logger и Recovery в main. Проверьте: тест сервиса гоняется без HTTP с моком-картой; замена repo := repository.NewPostgres(pool) на repository.NewMemory() меняет ровно одну строку; grep -r "gin" internal/service и internal/repository не находит ничего — слои чисты.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужны ли слои маленькому проекту?
Правило простое: до пяти маршрутов и одного разработчика — честный main.go без церемоний; как только появился второй домен, второй разработчик или первый тест на бизнес-правило — нарезка окупается за неделю. Начинайте с разделения handler/service, а repository вводите, когда появляется реальное хранилище или мок.
Где должны лежать доменные структуры — с json-тегами или без?
Домен живёт без транспортных тегов: json — в DTO пакета handler, db-теги — в моделях репозитория. Мапперы по три строки дешевле, чем структура, связывающая HTTP-контракт, SQL-схему и бизнес-правила в один тип: любое изменение тогда ломает всё сразу.
Как передавать пользователя из JWT в сервис?
Гвард из урока 15 кладёт claims в контекст gin, хендлер читает userID и передаёт аргументом: svc.Create(ctx, ownerID, title). Сервис остаётся чистым от gin и тестируется конструктором с числом вместо фейкового контекста.
Wire, fx, dig — нужен ли DI-фреймворк?
До десятка-двух зависимостей — нет: конструкторы и main читаются мгновенно и дебажатся без магии. google/wire полезен, когда граф стал большим и ошибки сборки хочется ловить на компиляции, а не на старте; но это генератор тех же конструкторов, а не контейнер с рефлексией. Начинайте руками — миграция на wire потом механическая.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию