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

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

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

Урок 22 из 25 ~22 мин +50 XP

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

До сих пор наши 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-сервера, а хендлеры — с моком вместо базы.

Содержание урока · 13 разделов

структура проекта 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 и три пакета

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

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

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

Репозиторий: единственный, кто знает 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), и детали останутся внутри.

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

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

Прочитайте код и выберите, что он выведет:

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.

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

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?

Грабли: 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 оставьте для транспортного слоя.

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

СлойЖивёт здесьЗдесь запрещено
handlerbind и валидация, DTO, статусы, маппинг ошибок, вызов сервисаSQL, бизнес-правила, работа с БД напрямую
serviceправила домена, лимиты, оркестрация, доменные ошибки, контракт хранилища*gin.Context, json-теги, парсинг запроса
repositorySQL/ORM, транзакции, маппинг колонок, пул соединенийстатусы HTTP, знание о бизнес-правилах
configчтение окружения, дефолты, валидация настроекдоступ к сети и базе, логика
mainсборка графа зависимостей, группы маршрутов, старт и остановка сервералюбая логика кроме композиции

Шесть правил слоёв gin#

  • Зависимости смотрят строго вниз: handler → service → repository; никаких обратных импортов и циклов.
  • Интерфейс объявляет потребитель: контракт TodoRepository живёт в service, реализация о нём не знает.
  • Хендлер — переводчик: bind, один вызов сервиса, конверт ответа; решения принимает сервис.
  • Сервис принимает обычные аргументы и context.Context — никогда *gin.Context.
  • main — композиционный корень: весь граф собирается конструкторами в одном читаемом месте, глобальных переменных нет.
  • Домен регистрирует свои маршруты сам: RegisterRoutes на группе, main перечисляет домены.

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

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

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)) }

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

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

Строки

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

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

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

Термин

Слои gin: три пакета

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

handler — транспорт: bind, статусы, конверты, маппинг ошибок. service — бизнес-правила, доменные ошибки, контракт хранилища. repository — SQL, транзакции, маппинг колонок. Зависимости только вниз, наверх не протекает ни gin, ни json, ни SQL.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 не находит ничего — слои чисты.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Структура проекта и слои)

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

Нужны ли слои маленькому проекту?

Правило простое: до пяти маршрутов и одного разработчика — честный 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 потом механическая.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

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

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию