ORM в Go: где он уместен и что выбрать между GORM и go-jet

ORM в Go: где такой подход оправдан, чем GORM отличается от EF/Hibernate, когда практичнее SQL-first go-jet, и какие есть альтернативы (sqlc, ent, bun)

В Go разговор про ORM почти всегда получается сложнее, чем в экосистемах вроде .NET или Java. Язык исторически тяготеет к явному коду, простым зависимостям и понятному SQL. Поэтому вопрос обычно звучит не “какой ORM взять”, а “нужен ли здесь ORM вообще и где проходит граница его пользы”.

Эта статья — не спор ORM vs raw SQL, а практический разбор на одном домене: покажем один и тот же набор операций в GORM и go-jet, где каждый удобен, где кусается, и как выбирать под команду и тип системы.

Пятая статья серии. В предыдущей разбирали уровень драйвера (pgx). ORM и SQL-builder’ы стоят этажом выше — над драйвером, — и вопрос здесь не «какой ORM», а «нужен ли он вообще».

ORM против SQL-first: граф объектов и типизированная схема-таблица сходятся к одной базе

В статье

Насколько ORM свойственны Go

Go исторически тяготеет к явному коду, простым зависимостям и понятному SQL — отсюда популярность database/sql, sqlx, query builders и code generation, а не «большого ORM». У разработчика с опытом в .NET (Entity Framework) или Java (Hibernate) ожидание другое: ORM как менеджер графа объектов с identity map, change tracking и ленивой загрузкой. В Go такого почти нет — и это не недоработка, а следствие культуры: явный SQL даёт контроль над запросом, который в backend часто важнее «магии».

При этом ORM в Go не зло — в части задач он реально экономит время. Просто граница его пользы проходит раньше, чем привыкли пришедшие из EF/Hibernate.

Где ORM действительно помогает

  • быстрый CRUD для административных и внутренних сервисов;
  • типовые сущности с предсказуемой моделью данных;
  • небольшие продукты с маленькой командой;
  • прототипы, backoffice, внутренние панели, support-тулинг.

Общее у этих сценариев — много однотипных простых операций и мало сложных запросов. Там, где наоборот (аналитика, сложные JOIN, агрегаты, PostgreSQL-специфика), ORM начинает мешать и давать ложное чувство простоты.

Сквозной домен для примеров

Чтобы сравнение было предметным, возьмём один маленький домен и будем гонять по нему одни и те же операции:

  • users — авторы;
  • posts — публикации: принадлежат пользователю, имеют metadata jsonb, счётчики likes и comments_count;
  • tags и post_tags — теги many-to-many.
CREATE TABLE users (
    id   bigserial PRIMARY KEY,
    name text NOT NULL
);

CREATE TABLE posts (
    id             bigserial PRIMARY KEY,
    user_id        bigint NOT NULL REFERENCES users(id),
    title          text   NOT NULL,
    metadata       jsonb  NOT NULL DEFAULT '{}',
    likes          int    NOT NULL DEFAULT 0,
    comments_count int    NOT NULL DEFAULT 0
);

CREATE TABLE tags (
    id   bigserial PRIMARY KEY,
    name text NOT NULL UNIQUE
);

CREATE TABLE post_tags (
    post_id bigint REFERENCES posts(id),
    tag_id  bigint REFERENCES tags(id),
    PRIMARY KEY (post_id, tag_id)
);

Пять операций, на которых видна разница подходов: простой CRUD, список пользователей с их постами, агрегат COUNT/GROUP BY, фильтр по jsonb и partial update, где важны нулевые значения (likes = 0).

Запускаемый пример к статье — оба стека на этом домене, миграции и все пять кейсов — в digital-cookbook → go/orm-gorm-vs-jet.

Один и тот же кейс: GORM и go-jet

Модели и схема

GORM описывает домен структурами с тегами; для jsonb берём gorm.io/datatypes:

import "gorm.io/datatypes"

type User struct {
    ID    uint
    Name  string
    Posts []Post // has-many, FK по умолчанию UserID
}
type Post struct {
    ID            uint
    UserID        uint
    Title         string
    Metadata      datatypes.JSON // jsonb
    Likes         int
    CommentsCount int
    Tags          []Tag `gorm:"many2many:post_tags"`
}
type Tag struct {
    ID   uint
    Name string
}

go-jet идёт от схемы: jet генерирует из живой БД пакеты table (символы колонок) и model (структуры-приёмники). Модели вы не пишете руками — они всегда синхронны со схемой.

CRUD

post := Post{UserID: 1, Title: "Hello", Metadata: datatypes.JSON(`{"source":"blog"}`)}
db.Create(&post)              // INSERT, post.ID заполнится

var got Post
db.First(&got, post.ID)       // SELECT ... WHERE id = ? LIMIT 1
import (
    . "github.com/go-jet/jet/v2/postgres" // SELECT, INSERT, COUNT, Int, …
    . "myproject/internal/db/jet/table"   // Users, Posts — символы колонок
    "myproject/internal/db/jet/model"     // model.Users, model.Posts
)

insert := Posts.INSERT(Posts.UserID, Posts.Title, Posts.Metadata).
    VALUES(int64(1), "Hello", `{"source":"blog"}`).
    RETURNING(Posts.AllColumns) // вернём всю строку в model.Posts
var created model.Posts
insert.Query(db, &created)

sel := SELECT(Posts.AllColumns).FROM(Posts).WHERE(Posts.ID.EQ(Int(created.ID)))
var got model.Posts
sel.Query(db, &got)

Здесь db — это *sql.DB (go-jet выполняется через database/sql-совместимый драйвер, см. ниже).

Список пользователей с постами

var users []User
// Без Preload связанные Posts не загрузятся; читать их в цикле — это N+1.
db.Preload("Posts").Find(&users)   // 2 запроса: users + posts IN (...)
stmt := SELECT(Users.AllColumns, Posts.AllColumns).
    FROM(Users.LEFT_JOIN(Posts, Posts.UserID.EQ(Users.ID))).
    ORDER_BY(Users.ID.ASC())

var users []struct {
    model.Users
    Posts []model.Posts // go-jet сгруппирует строки JOIN по пользователю
}
stmt.Query(db, &users)

Агрегат: постов на пользователя

type Row struct {
    UserID uint
    Cnt    int
}
var rows []Row
db.Model(&Post{}).
    Select("user_id, count(*) AS cnt").
    Group("user_id").
    Scan(&rows)
cnt := COUNT(Posts.ID).AS("cnt")
// Bare-колонку алиасим (AS), иначе go-jet назовёт её "posts.user_id"
// и не смапит в поле UserID анонимной структуры.
stmt := SELECT(Posts.UserID.AS("user_id"), cnt).
    FROM(Posts).
    GROUP_BY(Posts.UserID)

var rows []struct {
    UserID int64
    Cnt    int64
}
stmt.Query(db, &rows)

Разница видна: в GORM агрегат — это уже «строковый» Select(...) + отдельная структура (Scan), типобезопасность теряется. В go-jet COUNT, GROUP_BY — это выражения, проверяемые компилятором.

Фильтр по jsonb

var posts []Post
db.Where(datatypes.JSONQuery("metadata").Equals("blog", "source")).
    Find(&posts)   // WHERE metadata ->> 'source' = 'blog'
stmt := SELECT(Posts.AllColumns).FROM(Posts).
    WHERE(RawBool("posts.metadata ->> 'source' = #source",
        RawArgs{"#source": "blog"}))
var posts []model.Posts
stmt.Query(db, &posts)

Показательно: jsonb — слабое место go-jet. Типобезопасного DSL для jsonb-операторов у него нет, и такие фильтры уходят в Raw*-выражения. GORM здесь удобнее за счёт datatypes.JSONQuery.

Partial update, где важны нулевые значения

Самая частая production-ловушка GORM. Обнулить счётчик через Updates со структурой не получится — zero-value молча пропускается:

// НЕ обнулит likes: 0 — это zero-value, GORM его пропустит.
db.Model(&post).Updates(Post{Likes: 0})

// Правильно — явно перечислить поле или использовать map:
db.Model(&post).Select("likes").Updates(Post{Likes: 0})
db.Model(&post).Updates(map[string]any{"likes": 0})

В go-jet такого класса ошибок нет — вы пишете SET явно:

upd := Posts.UPDATE(Posts.Likes).
    SET(Int(0)).
    WHERE(Posts.ID.EQ(Int(int64(postID))))
upd.Exec(db)

Почему GORM — не EF .NET

GORM — самый популярный ORM в Go: модели через struct-теги, AutoMigrate, ассоциации с Preload, хуки, чейнинговый API. Но воспринимать его как EF не стоит.

В GORM нет полноценного identity map и change tracking: фреймворк не отслеживает граф объектов и не «сам поймёт», что поменялось, — обновление полей вы задаёте явно (как раз отсюда ловушка с Updates выше). Меньше магии вокруг жизненного цикла сущности, больше ручного участия в запросах, Preload и транзакциях. По сути это удобная абстракция над SQL, а не object graph management. Главное ожидание, которое стоит скорректировать после EF: здесь вы по-прежнему думаете запросами, а не объектами.

Отдельно про Generics API: в свежих версиях GORM продвигается дженерик-интерфейс (gorm.G[User](db).Where(...).First(ctx)) — он убирает часть interface{}/&dest и делает базовые операции типобезопаснее и приятнее. Но важно не переоценивать: дженерики улучшают эргономику вызовов, а не природу фреймворка — identity map и change tracking от них не появляются. GORM остаётся абстракцией над SQL, просто с более аккуратным API.

Где GORM кусается в production

Ниша GORM реальна, но в проде он кусается предсказуемым набором грабель — их лучше знать заранее:

  • AutoMigrate — не система миграций. Он добавляет недостающие таблицы, колонки, индексы и FK, но не удаляет/переименовывает колонки и не откатывается. Для production нужен нормальный мигратор (goose, golang-migrate, atlas), а AutoMigrate — максимум для dev/прототипа.
  • Zero-values в Updates(struct). 0, false, "" молча не пишутся (см. пример выше). Обнуления делайте через Select или map — иначе тихая потеря апдейта.
  • N+1 и Preload vs Joins. Preload тянет ассоциацию отдельным запросом (обычно ок), но обращение к связям в цикле без него — классический N+1. А если нужно фильтровать по связанной таблице, Preload не поможет — нужен Joins.
  • Хуки и scopes — источник скрытой логики. BeforeCreate/AfterUpdate и переиспользуемые scopes удобны, но прячут поведение: запрос делает не то, что видно в месте вызова. На ревью это дорого.
  • Транзакция по умолчанию. GORM оборачивает одиночные write-операции в транзакцию; на горячем пути это лишний overhead — при необходимости отключается (SkipDefaultTransaction).
  • Видимость SQL. Что реально ушло в базу, не всегда очевидно из чейнинга. Включайте логгер запросов и смотрите фактический SQL — особенно для Preload, ассоциаций и апдейтов.

go-jet: настоящая сила SQL-first

go-jet — противоположный подход: он генерирует из схемы БД типобезопасные символы таблиц и колонок, и вы пишете SQL-подобный Go, который компилятор проверяет по реальной схеме. Переименовали колонку в схеме → перегенерировали → код, который её использует, перестаёт компилироваться.

Но сводить go-jet к «типобезопасному JOIN» неправильно — его сила именно в сложном SQL, где ORM пасует:

  • Агрегаты и GROUP BY — как выражения (COUNT, SUM, HAVING), а не строки.
  • Оконные функцииROW_NUMBER(), RANK() через .OVER(...).
  • CTE и подзапросыWITH, коррелированные подзапросы собираются из тех же типизированных кирпичиков.
  • PostgreSQL-специфика — много конструкций покрыто DSL нативно, а экзотику (например, jsonb-операторы) выражаете через Raw*.
  • Вложенный result mapping — строки JOIN группируются в структуры с вложенными слайсами (Posts []model.Posts), без ручной сборки.
// Топ-пост каждого пользователя по лайкам (ROW_NUMBER в подзапросе).
ranked := SELECT(
    Posts.AllColumns,
    ROW_NUMBER().OVER(PARTITION_BY(Posts.UserID).ORDER_BY(Posts.Likes.DESC())).AS("rn"),
).FROM(Posts).AsTable("ranked")

stmt := SELECT(ranked.AllColumns()).
    FROM(ranked).
    WHERE(IntegerColumn("rn").From(ranked).EQ(Int(1)))

Запрос читается как SQL, контроль над ним полный. Цена — шаг кодогенерации и то, что go-jet только строит и исполняет запрос: маппинг и типы — его, а само соединение — обычный database/sql.

Где go-jet платит сложностью

За типобезопасность и предсказуемый SQL go-jet берёт свою цену:

  • Codegen в пайплайне. Символы генерируются jet из живой или схемной БД — значит в CI нужен доступ к схеме (поднятый PostgreSQL из миграций или дамп). Это дополнительный шаг и инфраструктура.
  • Зависимость от схемы и порядок шагов. Источник истины — схема БД, а не Go-код. Поменяли схему → сначала миграция, потом jet generate, потом код. Забыли перегенерировать — компилятор честно поймает, но дисциплина нужна.
  • Onboarding. Новому человеку надо объяснить связку «миграции → codegen → сгенерированные пакеты table/model». Это не сложно, но не «просто пиши структуры».
  • jsonb и совсем экзотика уходят в Raw*-выражения (см. пример) — типобезопасность там теряется.
  • Исполнение — через database/sql. go-jet отдаёт запрос *sql.DB-совместимому драйверу. В связке с pgx это значит database/sql-режим через github.com/jackc/pgx/v5/stdlib (а не нативный pgxpool) — учитывайте это вместе с выводами из статьи про pgx.

Производительность: где что и как мерить

Короткий ответ: на типовом CRUD разница между GORM, go-jet и raw SQL тонет в самом запросе к базе — решают SQL, индексы, сеть и пул, а не «скорость обёртки». Отсюда ориентиры, а не абсолютные числа:

  • GORM добавляет накладные расходы на рефлексию, построение запроса и (по умолчанию) транзакцию на каждую запись. На больших batch-операциях и горячих путях это заметно; лечится CreateInBatches, отключением default-транзакции и, где надо, спуском на raw SQL.
  • go-jet близок к raw: он строит SQL и маппит результат, рантайм-магии минимум. Оверхед — предсказуемый маппинг, а не «ORM-слой».
  • Общее правило: сначала правильный SQL и индексы, потом выбор инструмента. Реальную стоимость измеряйте на своём профиле нагрузки — testing.B + pprof (тема статьи серии про профилирование), а не по синтетическим «драйвер быстрее на N%».

Практический вывод: производительность здесь — не аргумент «за ORM» или «против», а напоминание не прятать SQL от себя.

GORM против go-jet: таблица критериев

Критерий GORM go-jet
Скорость старта Быстрее (модели + CRUD из коробки) Медленнее (нужен codegen)
Простой CRUD Очень удобно Удобно, но многословнее
Сложные запросы/JOIN/агрегаты Уходят в строковый Select/raw Читается как SQL, типобезопасно
Контроль над SQL Частичный (генерирует за вас) Полный
Типобезопасность По строкам ("name = ?") Проверяется компилятором
Миграции AutoMigrate только для dev Нужен внешний мигратор + codegen
Partial update (0/false) Ловушка zero-value Явный SET, безопасно
Транзакции По умолчанию оборачивает write Явно, как в SQL
jsonb / PG-специфика datatypes/raw, есть DSL Нативно для SQL, jsonb — через raw
Dynamic filters Чейнинг удобен Собираются из выражений
Рефакторинг схемы Поймаете в рантайме Ломает компиляцию (это плюс)
Observability SQL SQL не всегда очевиден SQL предсказуем
Тестирование Проще замокать методы Тест ближе к реальному SQL
Onboarding команды Ниже порог Нужно объяснить codegen-связку

Грубо: GORM выигрывает на старте, простом CRUD и низком пороге входа, go-jet — на сложных запросах, рефакторинге и долгом сопровождении за счёт типобезопасности и предсказуемого SQL.

Какие ещё есть альтернативы

Сравнение GORM↔go-jet — не вся картина. Рядом:

  • database/sql — baseline без зависимостей;
  • sqlx — минимальное удобство (скан в структуры) без отказа от SQL (хотя в PostgreSQL-проекте его роль закрывает pgx, см. #4);
  • sqlc — генерирует Go-функции из вашего SQL (вы пишете .sql, получаете типизированные методы) — тема следующей статьи серии;
  • bun, ent — если хочется ORM-подход иначе (ent — схема-как-код с генерацией графа).

Выводы

  • ORM в Go оправдан на CRUD-heavy внутренних сервисах, прототипах, backoffice — там, где простых операций много, а сложных запросов мало.
  • SQL-first (go-jet/sqlc) здоровее в долгую, когда запросы сложные, важны типобезопасность, предсказуемый SQL и безопасный рефакторинг схемы.
  • GORM не нужно демонизировать — для своей ниши он прагматичен, а Generics API делает его приятнее; просто не ждите от него EF и держите в голове его production-ловушки (AutoMigrate, zero-values, N+1, хуки).
  • go-jet не бесплатен — codegen, зависимость от схемы и исполнение через database/sql — это цена за типобезопасность и контроль.
  • Выбирайте под команду и тип системы, а не по идеологии. И помните: всё это живёт поверх драйвера из #4 — выбор ORM не отменяет понимания, какой SQL уходит в базу.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии