Язык Go создавался для сетевых служб, но голый пакет net/http это лишь фундамент: маршрутизация по методам, разбор JSON и работа с базой остаются на совести разработчика. Джентльменский набор для быстрого старта в экосистеме сложился давно, и состоит он из фреймворка Gin для HTTP-части и GORM для базы. Актуальные версии вышли в 2026 году: Gin 1.12.0 в феврале, GORM 1.31.2 в июне. Разберём связку от каркаса сервиса до транзакций, с рабочим кодом и объяснением каждого решения, от разметки маршрутов до пула соединений.

Gin держит маршруты на префиксном дереве и обрабатывает запросы цепочкой

Gin выбирают за скорость и компактность. Маршрутизатор построен на префиксном дереве, в котором общие начала путей хранятся один раз, поэтому разбор адреса не зависит от количества зарегистрированных маршрутов и почти не выделяет память. На типовых тестах фреймворк год за годом держится в верхней части рейтингов, и для сервиса, принимающем тысячи запросов в секунду, запас виден даже без замеров.

Каркас сервера умещается в несколько строк:

func main() {
    r := gin.Default()

    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "ok"})
    })

    r.Run(":8080")
}

Функция Default собирает движок с двумя штатными прослойками: Logger пишет строку на каждый запрос с временем и кодом ответа, Recovery перехватывает панику обработчика и возвращает клиенту 500 вместо падения всего процесса. Ничего из этого не работает магией: каждая прослойка это обычная функция, которая получает контекст, делает своё дело и передаёт управление дальше через цепочку вызовов.

Стандартная обвязка сервиса набирается из готовых прослоек:

  1. Logger пишет строки запросов с кодом ответа и временем;
  2. Recovery гасит панику обработчика и отвечает 500;
  3. CORS добавляет заголовки для браузерных клиентов;
  4. лимитер частоты режет всплески и ботов;
  5. проверка JWT достаёт пользователя из заголовка;
  6. собственная прослойка журналирует бизнес-события.

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

func authRequired() gin.HandlerFunc {
    return func(c *gin.Context) {
        user, err := parseToken(c.GetHeader("Authorization"))
        if err != nil {
            c.AbortWithStatusJSON(401, gin.H{"error": "нет токена"})
            return
        }
        c.Set("user", user)
        c.Next()
    }
}

Значение, положенное в контекст через Set, достаётся в любом обработчике ниже по цепочке, и пользователь путешествует с запросом до конца, не требуя глобальных переменных.

Контекст запроса несёт параметры привязку и ответ в одном объекте

Объект gin.Context это сердце обработчика, его швейцарский нож. В нём лежат параметры пути, строка запроса, тело, заголовки, куки, механизм ответа и даже способ отменить работу, если клиент ушёл. Обработчики не изобретают интерфейсы, все получают одинаковую сигнатуру, иmiddleware в смысле Go кода не отличается от обычной функции-обработчика.

Маршруты с параметрами и группировка по префиксам выглядят так:

api := r.Group("/api/v1", authRequired())
{
    api.GET("/books", listBooks)
    api.GET("/books/:id", getBook)
    api.POST("/books", createBook)
    api.DELETE("/books/:id", deleteBook)
}

Сегмент пути с двоеточием становится параметром и достаётся через c.Param. Группа наследует прослойки, заданные при создании, поэтому защита авторизацией вешается один раз на весь блок, а не повторяется в каждом обработчике.

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

type CreateBookInput struct {
    Title  string `json:"title" binding:"required"`
    Author string `json:"author" binding:"required"`
    Price  int64  `json:"price" binding:"gte=0"`
}

func createBook(c *gin.Context) {
    var in CreateBookInput
    if err := c.ShouldBindJSON(&in); err != nil {
        c.AbortWithStatusJSON(400, gin.H{"error": err.Error()})
        return
    }
    c.JSON(201, saveBook(in))
}

Семейство методов ShouldBindJSON, ShouldBindQuery и ShouldBindURI закрывает все источники значений, а правило gte=0 проверяет неотрицательность цены без единой своей строки. Ответ собирается методом JSON, и gin.H это просто псевдоним для словаря, чтобы literals читались короче.

GORM описывает модели тегами и ведёт миграцию схемы сам

GORM это самый известный объектно-реляционный слой Go. Он связывает структуры с таблицами по соглашениям: имя структуры во множественном числе становится таблицей, поле ID ключевиком, CreatedAt и UpdatedAt заполняются сами, а DeletedAt превращает удаление в мягкое, помечая строку временем вместо физического стирания.

type Book struct {
    ID        uint           `gorm:"primaryKey"`
    Title     string         `gorm:"size:200;not null"`
    Author    string         `gorm:"size:100"`
    Price     int64
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

Теги дорисовывают то, чего нет в соглашениях: длина строки для схемы базы, запрет пустых значений, индекс на поле мягкого удаления. Подключение к PostgreSQL занимает несколько строк, а AutoMigrate сверяет структуру со схемой и добавляет недостающие столбцы и индексы, что удобно на старте проекта. Крупные команды позже переезжают на версионные миграции ради контроля и ревью, но на этапе прототипа автоматическая сверка экономит часы.

Для сквозной логики у моделей есть перехватчики жизненного цикла. Хук BeforeCreate проверяет значения перед записью, и бизнес-правила типа неотрицательной цены живут рядом с моделью, а не размазаны по обработчикам:

func (b *Book) BeforeCreate(tx *gorm.DB) error {
    if b.Price < 0 {
        return errors.New("цена отрицательная")
    }
    return nil
}

Хуки AfterFind и AfterUpdate закрывают чтение и изменение, а тяжёлую логику в них класть не стоит, иначе каждый запрос обрастёт скрытой работой.

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
    log.Fatal(err)
}
db.AutoMigrate(&Book{}, &Order{})

Запросы GORM читаются почти как фразы на английском

Ключевая идея библиотеки в цепочках методов, которые складываются в предложения: где, порядок, сколько, вернуть. Каждое звено возвращает тот же тип, поэтому построение условия и исполнение разделяются, а условие можно собирать по веткам логики.

var books []Book
result := db.Where("price > ?", 100).
    Where("author = ?", "Пелевин").
    Order("created_at desc").
    Limit(20).Offset(page * 20).
    Find(&books)

if result.Error != nil {
    log.Fatal(result.Error)
}

Вопросительные знаки в условиях это параметризованные запросы, значения уходят в драйвер отдельно от текста SQL, и это закрывает целый класс внедрений через строку. Одиночная запись ищется методом First, счётчик через Count, создание через Create, изменение через Model.Update, имена методов говорят сами за себя.

Атомарные изменения, где нельзя читать и потом писать, делаются через выражения:

db.Model(&Stock{}).
    Where("product_id = ?", id).
    Update("count", gorm.Expr("count - ?", qty))

Уменьшение остатка выполняется в базе одним предложением, поэтому два одновременных заказа не перехватят остаток друг у друга. Для экзотических случаев остаётся Raw с чистым SQL и Scan в структуру, и этот люфт спасает там, где ORM упирается в выразительность. Повторяемые наборы условий выносят в скоупы, функции-фильтры, которые подмешиваются в цепочку по имени, и типовые срезы вроде только-активные или только-за-месяц собираются из готовых блоков.

Связи и предзагрузка собирают связанные записи без ручных JOIN

Связи между моделями GORM описывает полями с типами срезов и указателей: принадлежность, один-ко-многим и многие-ко-многим распознаются по структуре, а для точности добавляются теги с внешним ключом. Группа загружается отдельным обращением, но ручные JOIN писать не нужно.

type Author struct {
    ID    uint
    Name  string
    Books []Book `gorm:"foreignKey:AuthorID"`
}

var author Author
db.Preload("Books").Preload("Books.Orders").
    First(&author, 3)

Предзагрузка Preload делает дополнительные SELECT по списку ключей и заполняет связанные срезы, поэтому ответ API собирается целиком без кружев из ручных запросов. Для условий на предзагрузку есть вариант с функцией, где задаются ограничение и порядок, а ленивый и жадный выбор это осознанное решение: жадная загрузка экономит круги до базы, ленивая не тащит лишнее.

Транзакции и пул соединений держат целостность под нагрузкой

Классика ошибок начинающих: списать остаток одним запросом, создать заказ другим и получить минус на складе при падении между ними. Транзакция в GORM это функция, принимающая отдельный объект базы, и по её результату библиотека подтверждает или откатывает всё разом:

err := db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&order).Error; err != nil {
        return err
    }
    res := tx.Model(&Stock{}).
        Where("product_id = ? AND count >= ?", id, qty).
        Update("count", gorm.Expr("count - ?", qty))
    if res.Error != nil {
        return res.Error
    }
    if res.RowsAffected == 0 {
        return errors.New("остатка не хватает")
    }
    return nil
})

Условие count >= ? прямо в тексте изменения превращает проверку остатка в часть самой операции, а счётчик затронутых строк ловит случай, когда товара просто не хватило. Возврат ошибки из замыкания откатывает транзакцию целиком, и заказ не останется без списания.

Под соединениями GORM скрывает стандартный sql.DB, который настраивается явно:

sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute)

Пул по умолчанию не ограничен сверху, и сервис под нагрузкой легко открывает сотни соединений, пока база не ответит отказом. Практика простая: потолок подбирается под лимиты базы, время жизни соединения держат в минутах, чтобы балансировщик и сервер базы успевали перебросить сессии.

Второй рычаг под нагрузкой это дедлайн запроса. Контекст с таймаутом передаётся в GORM одной строкой, и зависшая база перестаёт копить ожидающие горутины:

ctx, cancel := context.WithTimeout(
    c.Request.Context(), 3*time.Second)
defer cancel()

if err := db.WithContext(ctx).Find(&books).Error; err != nil {
    c.AbortWithStatusJSON(500, gin.H{"error": "база не успела"})
}

Три секунды на список книг достаточны, а обратившемуся клиенту честно вернётся ошибка вместо бесконечного ожидания. связка контекста запроса и контекста базы отменит и запрос, и всё вокруг него, если пользователь закрыл вкладку.

Сборка сервиса от структуры каталогов до запуска

Проект на Gin с GORM раскладывается просто: пакет handlers хранит обработчики, models описывает таблицы, storage прячет работу с базой за интерфейсом, main собирает движок. Зависимость обработчика от хранилища передаётся через структуру, поля которой назначаются при создании, и для тестов хранилище подменяется фиктивной структурой без базы.

type Server struct {
    store  Store
    router *gin.Engine
}

func New(s Store) *Server {
    r := gin.Default()
    api := r.Group("/api/v1")
    {
        api.GET("/books", s.ListBooks)
        api.POST("/books", s.CreateBook)
    }
    return &Server{store: s, router: r}
}

Запуск в контейнере умещается в статический бинарник без внешних зависимостей, сборка командой go build занимает секунды, а образ получается размером в десятки мегабайт. Стандартная библиотека даёт встроенный профайлер и метрики через пакет по желанию, для наблюдения достаточно включить экспорт счётчиков, и у сервиса сразу видны время ответа и ошибки.

Остановку сервиса делают аккуратной: серверу дают доить текущие запросы в пределах таймаута, базу закрывают последней, и обновление версии проходит без всплеска ошибок на клиентах, которые просто попадают на новый экземпляр за балансировщиком.

Прагматичный итог. Связка Gin с GORM закрывает типовой бэкенд среднего размера целиком: маршрутизация и прослойки с одной стороны, модели, связи и транзакции с другой, а чистый SQL остаётся запасным выходом, а не основным путём. Сервисы, которым нужны предельный контроль над каждым запросом к базе, пишут на чистом sql-пакете, но для каталогов, учётных систем и API к ним скорость разработки решает, и здесь джентльменский набор Go почти всегда оказывается быстрее при равной читаемости.