В Go разговор про ORM почти всегда получается сложнее, чем в экосистемах вроде .NET или Java. Язык исторически тяготеет к явному коду, простым зависимостям и понятному SQL. Поэтому вопрос обычно звучит не “какой ORM взять”, а “нужен ли здесь ORM вообще и где проходит граница его пользы”.
Эта статья — не спор ORM vs raw SQL, а практический разбор на одном домене: покажем один и тот же набор операций в GORM и go-jet, где каждый удобен, где кусается, и как выбирать под команду и тип системы.
Пятая статья серии. В предыдущей разбирали уровень драйвера (pgx). ORM и SQL-builder’ы стоят этажом выше — над драйвером, — и вопрос здесь не «какой ORM», а «нужен ли он вообще».
В статье
- Насколько ORM свойственны Go
- Где ORM действительно помогает
- Сквозной домен для примеров
- Один и тот же кейс: GORM и go-jet
- Почему GORM — не EF .NET
- Где GORM кусается в production
- go-jet: настоящая сила SQL-first
- Где go-jet платит сложностью
- Производительность: где что и как мерить
- GORM против go-jet: таблица критериев
- Какие ещё есть альтернативы
- Выводы
Насколько 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 1import (
. "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 и
PreloadvsJoins.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 уходит в базу.
Комментарии