---
title: "Структура проекта и слои"
description: "Структура проекта gin: слои handler/service/repository, интерфейсы и DI без фреймворков, сборка зависимостей в main, маршруты по доменам и тестируемость."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/22-struktura/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Структура проекта и слои

*Курс «Gin: самый популярный веб-фреймворк Go», урок 22 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/22-struktura/*

До сих пор наши gin-проекты жили в одном main.go — и это было честно: [первый сервер из урока 02](/courses/gin/lessons/02-pervyj-server/) помещался на один экран. Но настоящий сервис так не выглядит: хендлеры обрастают бизнес-правилами, правила — SQL-запросами, и через полгода файл перестаёт помещаться в голову. Структура проекта gin отвечает на вопрос «где что искать», а слои gin — на вопрос «где что менять»: правка скидочной логики не должна трогать маршруты, а переезд с Postgres на что-то ещё — переписывать хендлеры. Разберём канон: handler/service/repository, интерфейсы на стороне потребителя, dependency injection без фреймворков — голыми конструкторами, сборку всего графа в main и маршруты по доменам. Заодно увидим, почему такая нарезка делает [тесты из урока 21](/courses/gin/lessons/21-testirovanie/) дешёвыми: сервис проверяется без HTTP-сервера, а хендлеры — с моком вместо базы.

### структура проекта gin: зачем резать по слоям

Учебный хендлер из урока про [binding](/courses/gin/lessons/04-binding/) совмещал три работы: распарсить запрос, решить, что делать, и достать данные откуда-то сбоку. На трёх маршрутах это читается, на трёхстах — нет: каждый хендлер дублирует проверку прав, половина повторяет один и тот же SQL, а тест на бизнес-правило требует поднимать HTTP-сервер с базой. Разрез по слоям даёт каждому пакету одну причину для изменения: транспорт меняется, когда меняется контракт API, бизнес — когда меняются правила предметной области, данные — когда меняется хранилище. Три разные причины не живут в одной функции, поэтому PR перестают конфликтовать, а ревью — превращаться в чтение романа. Второй выигрыш — подменяемость: сервис, зависящий от интерфейса, в проде получает Postgres, в тесте — карту в памяти, и ни одна строка логики об этом не знает. Третий — онбординг: новичок находит конец цепочки «запрос → хендлер → сервис → репозиторий» за минуты. Плата — церемония: лишние структуры и функции на каждый маршрут. Для хобби-проекта из пяти ручек она избыточна, для команды и долгой жизни — самая дешёвая страховка из существующих.

### handler/service/repository: контракты слоёв gin

Канон для веб-сервиса на gin — три слоя. Handler — транспортный слой: знает HTTP, gin и всё о конвертах из [урока про ошибки](/courses/gin/lessons/17-oshibki/); биндит запрос [структурой с тегами](/courses/gin/lessons/04-binding/), зовёт одну функцию сервиса и маппит результат в статус и JSON. Он не содержит ни одного правила домена — только правила протокола. Service — бизнес-слой: лимиты, скидки, статусы заказов, согласованность, транзакционные сценарии; принимает на вход обычные структуры и контекст, возвращает доменные значения или доменные ошибки вроде ErrLimitReached. Repository — слой данных: единственный пакет, который знает SQL, имена таблиц и особенности драйвера; прячет это за простыми методами Create, ByID, List. Ключевое правило направлений: зависимости смотрят строго вниз — хендлер знает сервис, сервис знает интерфейс хранилища, репозиторий не знает никого. Наверх ничего не протекает: сервис не импортирует gin, репозиторий не возвращает DTO с json-тегами. Пока это правило держится, каждый слой тестируется отдельно, а слои gin остаются слоями, а не слоёным пирогом из-за одной «временной» ссылки.

### Карта каталога: cmd, internal и три пакета

```go
todoapp/
├── 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, никакой базы — чистые правила. В каком пакете ей место?

- handler — скидка считается в момент запроса, значит это транспорт
- ✅ service — это бизнес-правило: оно зависит от домена, а не от HTTP и не от SQL
- repository — правила считаются рядом с данными, чтобы не гонять лишний трафик
- в util — раз функция универсальная, пусть лежит в общей папке

Тест на принадлежность слою простой: меняется ли функция, если мы поменяем HTTP на gRPC? Если нет — это не handler. Меняется ли, если сменим Postgres на ClickHouse? Если нет — это не repository. Скидка зависит только от предметной области, значит service. Пакет util — красный флаг: через полгода там свалка из тридцати несвязанных функций, а импорты образуют паутину.

### Хендлер-тоник: bind, вызов, конверт

Здоровый хендлер читается за пятнадцать секунд и умещается в один экран. Его работа: забиндить вход, вызвать сервис, перевести результат на язык HTTP. Забиндили — [ошибку валидации](/courses/gin/lessons/09-validaciya/) отдали клиенту статусом 400 с понятным сообщением, не доливая стектрейсы. Вызвали сервис — получили либо значение, либо доменную ошибку; маппинг ошибок в статусы стоит в одном месте, лучше в общем хелпере из урока про ошибки, а не размазан по каждому хендлеру. Успех — c.JSON со статусом 200 или 201 и готовой структурой ответа. Обратите внимание на формы: хендлер принимает не *gin.Engine, а зависимости через конструктор, и метод Create(c *gin.Context) остаётся единственным местом, где упоминается gin. Если внутри хендлера появляется if-лес на тридцать строк — это уже бизнес-логика, и ей место в сервисе; правило большого пальца: хендлер не принимает решений, он переводит. Тогда замена gin на любой другой фреймворк — это переработка одного пакета, а сервисная и репозиторная части переезжают как есть.

### Транспортный слой: только перевод

```go
type 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, чтобы цепочка сохранилась.

### Бизнес-слой: контракт хранилища и правила

```go
package 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, а сервис им пользуется?

- Так требует компилятор: интерфейс обязан лежать в том же пакете, что и реализация
- ✅ Потребитель описывает ровно нужные ему методы — контракт не раздувается, реализация остаётся независимой
- Так быстрее: компилятор девиртуализирует вызовы, когда интерфейс рядом с потребителем
- Разницы нет, просто историческая привычка Go-сообщества

Интерфейс у потребителя — принцип из стандартной библиотеки: io.Reader описан рядом с кодом, который читает, а не рядом с сотнями его реализаций. Сервис декларирует лишь те методы, что нужны его логике, — и мок для теста занимает пять строк. Компилятору всё равно, а скорости это не меняет; разница в архитектуре: реализация не навязывает потребителю свои детали. Попутно заметьте: неявная реализация интерфейсов — именно то, что делает такой разрез возможным без boilerplate.

### Репозиторий: единственный, кто знает SQL

Repository превращает методы контракта в запросы. Внутри — database/sql или ORM по вкусу, имена таблиц, колоночные маппинги, транзакции. Три правила, которые держат слой в узде. Первое: методы говорят языком домена — Create, OpenByID, ListByOwner, а не ExecQueryWithParams; SQL-детали не прорываются в сигнатуры. Второе: контекст пронизывает всё — первый аргумент ctx context.Context у каждого метода, чтобы отмена запроса клиентом обрывала работу с базой, как мы делали в [потоковых хендлерах из урока 19](/courses/gin/lessons/19-proksi-streaming/). Третье: никаких gin-типов и json-тегов — репозиторий обменивается с сервисом доменными структурами. Для тестов рядом лежит вторая реализация контракта: карта с мьютексом, fifty строк кода, и весь сервисный слой гоняется без Docker и Postgres. Устройство in-memory стоит вынести в package repository с суффиксом memory — конструктор NewMemory, а prod-сборка просто передаёт в сервис другую реализацию; больше никакой разницы в коде нет. Если сервису нужны транзакции через несколько методов — не тяните sql.Tx наружу, добавьте в контракт метод WithTx(ctx, fn), и детали останутся внутри.

### Две реализации одного контракта

```go
package 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: весь граф в двадцати строках

```go
func 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

```go
var h *TodoHandler // забыли собрать: должен был быть NewTodoHandler(svc)

r := gin.New()
r.Use(gin.Recovery())
r.POST("/todos", h.Create)
r.Run(":8080")
```

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

- Не скомпилируется: компилятор не даст зарегистрировать метод nil-указателя
- Сервер не стартует: Run проверит зависимости и вернёт ошибку конфигурации
- ✅ Сервер стартует молча, а первый POST упадёт в панику и получит 500 от Recovery
- POST вернёт 200 с пустым телом: gin подставит пустой хендлер

Метод с nil-получателем легален в Go ровно до первого обращения к полям: bind отработает, а h.svc.Create разыменует nil — паника. Recovery из урока про ошибки превратит её в 500, и без логов вы будете искать призрака в SQL. Мораль двойная: собирайте граф явно через конструкторы (var h = New...) и падайте на старте, а не на трафике — конструкторы с проверкой nil в тестовом режиме окупаются за один такой инцидент.

### Маршруты по доменам: RegisterRoutes

Когда хендлеров больше пяти, регистрация маршрутов в main превращается в свалку. Решение — по одному методу RegisterRoutes на доменный хендлер: TodoHandler регистрирует свою пятёрку маршрутов на переданной группе, UserHandler — свою, main перечисляет домены, а не endpoints. Группы gin из [урока про маршруты](/courses/gin/lessons/10-gruppy/) идеально ложатся на этот приём: главная группа /api/v1 создаётся в main с общими звеньями из [урока 06](/courses/gin/lessons/06-middleware/), подгруппы по доменам получают свои лимиты и гварды. Версионирование тоже встаёт на место: v2 — та же сборка хендлеров с другой группой, и только те домены, которые реально изменились, регистрируются второй раз. Такая схема делает структуру проекта gin самодокументирующейся: список доменов виден в main, детали каждого — в своём файле. Альтернатива «одна огромная функция Routes()» работает ровно до первого конфликта слияний: десять человек правят один файл, и ревью диффа становится археологией. Плюс доменная регистрация упрощает вынос сервиса в отдельное приложение — вы отрезаете домен вместе с его RegisterRoutes.

### Домен сам знает свои маршруты

```go
func (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?

- Можно, если интерфейс сервиса объявлен с параметром *gin.Context — это самый короткий путь
- ✅ Сервис зависнет от фреймворка, станет нетестируемым без HTTP и получит чужие обязанности — данные передают аргументом
- Нельзя, потому что gin.Context не потокобезопасен и сервис упадёт под нагрузкой
- Можно, но только в фоне через goroutine — иначе контекст протухнет

Точнее всего — читать userID из клеймов в хендлере или в звене, вытащить значение и передать в сервис обычным аргументом: svc.Create(ctx, ownerID, title). Сервис, принимающий *gin.Context, невозможно вызвать из воркера, cron-задачи или теста без выдумывания фейкового контекста gin — а это ровно то, ради чего строились слои. Потокобезопасность здесь ни при чём: дело в границе слоёв. Контекст запроса передаётся отдельно — он нужен для отмены, а не для данных.

### Грабли: 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](/courses/gin/lessons/21-testirovanie/) оставьте для транспортного слоя.

### Что где живёт: шпаргалка по слоям

| Слой | Живёт здесь | Здесь запрещено |
| --- | --- | --- |
| 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 перечисляет домены.

### Соберите хендлер по канону

Допишите транспортный слой: привязка, вызов сервиса и статус успеха.

```go
func (h *TodoHandler) Create(c *gin.Context) {
        var in CreateTodoRequest
        if err := c.___(&in); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }

        todo, err := h.svc.___(c.Request.Context(), in.Title)
        if errors.Is(err, service.ErrLimitReached) {
                c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
                return
        }
        if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": "внутренняя ошибка"})
                return
        }

        c.JSON(http.Status___, toTodoResponse(todo))
}
```

Подсказка: Привязка JSON — ShouldBindJSON; метод сервиса называется так же, как действие — Create; статус при успешном создании — 201 Created.

Правильные ответы в пропусках: `ShouldBindJSON`, `Create`, `Created`

### Соберите путь POST /todos через слои

Разложите жизнь одного запроса: от матча маршрута до байт в сокете.

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

1. `gin матчит POST /api/v1/todos и пускает запрос через звенья группы`
2. `Хендлер биндит тело в CreateTodoRequest — ошибка валидации ушла бы как 400`
3. `Хендлер зовёт svc.Create(c.Request.Context(), ownerID, in.Title) — и больше ничего не решает`
4. `Сервис считает открытые задачи против лимита maxOpenTodos`
5. `Лимит превышен — сервис возвращает ErrLimitReached, репозиторий Create не зовётся`
6. `Репозиторий исполняет INSERT и возвращает доменную структуру Todo`
7. `Сервис отдаёт Todo хендлеру — HTTP-типов в ответе нет и не было`
8. `Хендлер маппит: успех — 201, ErrLimitReached — 409, прочее — 500`
9. `c.JSON сериализует DTO, логгер пишет строку, Recovery стоит на страже`

### Карточки: структура и слои

- **Слои gin: три пакета** — handler — транспорт: bind, статусы, конверты, маппинг ошибок. service — бизнес-правила, доменные ошибки, контракт хранилища. repository — SQL, транзакции, маппинг колонок. Зависимости только вниз, наверх не протекает ни gin, ни json, ни SQL.
- **Интерфейс у потребителя** — Идиома Go: контракт объявляет тот, кто пользуется. TodoRepository объявлен в пакете service и содержит только нужные ему методы; реализация подходит неявно. Мок для теста — пять строк, а реализацию нельзя раздуть лишним.
- **Композиционный корень** — main.go, где весь граф зависимостей собирается конструкторами: config → пул → repository → service → handler → маршруты → сервер. Замена Postgres на in-memory — одна строка в сборке. Глобальные переменные и поиск зависимостей запрещены.
- **Почему не *gin.Context в сервисе** — Сервис с *gin.Context нельзя вызвать из теста, воркера или cron без фейкового контекста gin; он тянет фреймворк в бизнес-слой и меняет контракт при каждом апгрейде gin. Данные — аргументами, отмена — context.Context из запроса.
- **RegisterRoutes** — Метод доменного хендлера: регистрирует свои маршруты на переданной *gin.RouterGroup. main создаёт общую группу с общими звеньями и перечисляет домены по одной строке. Меньше конфликтов слияний и простой вынос домена в отдельный сервис.
- **Доменные ошибки** — Сентинелы в пакете service: var ErrLimitReached = errors.New(...). Репозиторий оборачивает свои ошибки через %w, хендлер ловит через errors.Is и маппит в статусы в одном месте. Так правила о том, что вообще бывает в домене, не расползаются по слоям.

> **Разрежьте свой учебный проект за вечер.** Возьмите любой прежний пример курса — CRUD из уроков про [JSON-ответы](/courses/gin/lessons/05-json-otvety/) или задачи из урока про [валидацию](/courses/gin/lessons/09-validaciya/) — и перенесите в канон: cmd/server/main.go, internal/handler, internal/service, internal/repository. Сервис с лимитом задач, контракт в service, две реализации хранилища: карта и «Postgres»-заглушка. Переключайте сборку одной строкой в main и убедитесь, что ни один другой файл не меняется. Прогоните тест сервиса с моком напрямую, без сервера — это и есть дивиденд слоёв. В браузерной песочнице сайта сетевой код не исполняется, поэтому интерактивы здесь quiz, predict, fill, order и флешкарты, а проект живёт в локальном go run, как и весь [курс gin](/courses/gin/). Дальше — 23-optimizaciya: Release mode, охота на аллокации и pprof — слои дают тестируемость, а профилировщик покажет, где на самом деле тратятся миллисекунды.

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

- Слои 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 потом механическая.
