---
title: "Знакомство с ent и code generation"
description: "Что такое ent: графовая ORM от Facebook с кодогенерацией. Схема как код, типобезопасные предикаты, сравнение ent vs GORM и sqlc, когда ent уместен."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/01-znakomstvo/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 15
---

# Знакомство с ent и code generation

*Курс «ent: графовая ORM с кодогенерацией», урок 1 из 25. ~15 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ent/lessons/01-znakomstvo/*

Третий сервис на ORM, и всё по накатанной: структуры с тегами, Where со строками, имена связей в кавычках — и ни одна из этих строк не видна компилятору. Что такое ent: графовая ORM от Facebook, где вы описываете схему данных обычным Go-кодом, генератор пишет слой запросов, а связи становятся типизированными рёбрами графа. Разберём кодогенерацию ent, сравним подходы с GORM и sqlc и честно определим, когда ent уместен, а когда избыточен.

Поставим задачу, на которой проедет весь курс: микроблог. Пользователи пишут посты, к постам оставляют комментарии, посты лайкают, пользователи подписываются друг на друга. В базе это четыре-пять таблиц со связями «один-ко-многим» и «многие-ко-многим», и даже один запрос «лента подписок» разворачивается в цепочку: подписки → посты авторов → авторы → счётчик лайков. На [GORM](/courses/gorm/) такой запрос собирается из цепочек методов с именами ассоциаций внутри строк: Preload("Posts.Comments"). На чистом database/sql — руками написанный JOIN плюс ручной Scan по колонкам. И там, и там слой данных состоит из строк в кавычках, которые компилятор не читает. ent начинался именно с этого раздражения: если данные — граф, почему описание связей не сделать таким же кодом, как всё остальное в Go? В этом уроке разберём, что такое ent, как устроена его кодогенерация и чем её контракт отличается от подходов GORM и sqlc.

### Связи, невидимые компилятору

```go
// GORM: связи — строки внутри строк
db.Preload("Posts.Comments").
    Preload("Posts.Likes").
    Find(&users, "id = ?", uid)

// database/sql: рёбра собираются руками
rows, err := db.Query(`
        SELECT p.id, p.title, u.name
        FROM posts p
        JOIN users u ON u.id = p.author_id
        WHERE p.author_id = $1`, uid)
```

Оба варианта собираются без предупреждений: для компилятора это строки. Preload("Posts.Commnets") с опечаткой внутри скомпилируется и выстрелит только при запросе, а связь users.id = posts.author_id живёт в тексте SQL, где колонки для Scan идут по порядку. Каждый новый вид связи — ещё одна строка в кавычках, и ни одна не видна ни компилятору, ни рефакторингу, ни автодополнению. В сервисе со сложным доменом именно на этом стыке копятся самые дорогие баги: не синтаксические, а расхождения между кодом и схемой.

### Что такое ent: генератор поверх схемы

ent — библиотека для работы с базой из Go, но слово «ORM» к ней стоит применять с оговоркой. GORM (мы разбирали его в [вводном уроке курса](/courses/gorm/lessons/01-znakomstvo/)) живёт в рантайме: по struct-тегам через рефлексию он в момент запроса сочиняет SQL. sqlc ([вводный урок здесь](/courses/sqlc/lessons/01-znakomstvo/)) уходит в другую крайность: вы пишете SQL руками в .sql-файлах, а инструмент проверяет их по схеме и генерирует типизированную обвязку. ent — третий путь: вы описываете схему данных как обычный Go-код, а слой запросов — сущности, билдеры, предикаты — генерируется из этой схемы командой go generate. Кодогенерация как у sqlc, но источник — не SQL-запросы, а декларативное описание данных, как у ORM.

Проект вырос внутри Facebook: его начал писать Ariel Mashraki примерно в 2016 году, в 2019-м код опубликовали в открытый доступ. Сегодня ent развивается под эгидой Linux Foundation, документация живёт на entgo.io, модуль называется entgo.io/ent, а репозиторий — github.com/facebook/ent. Внутри Meta библиотека работает в продакшене, а через интеграцию entgql из схемы генерируется и GraphQL-слой — до него дойдём в конце курса. Базы: MySQL, PostgreSQL, SQLite и TiDB, все через стандартные драйверы database/sql.

### Данные — это граф

Реляционная база выглядит таблицами, но домен под ней — граф. Пользователь — узел, пост — узел, ребро «написал» соединяет их; комментарии — узлы на ребре от поста, подписки — рёбра «пользователь → пользователь». Внешние ключи и join-таблицы — это те же рёбра, просто спрятанные в колонки. GORM с ними честно работает, но статус у связей второй сорт: ассоциации объявляются тегами на полях, а в запросах — строками. ent ставит ребро в один ряд с полем: в схеме два метода — Fields для колонок и Edges для связей, и у каждой связи есть имя, тип (один-к-одному, один-ко-многим, многие-ко-многим) и поведение при удалении. Из этого решения вырастают все самые приятные вещи библиотеки: жадная загрузка `WithPosts()`, предикаты по связям вида `HasOwnerWith(...)`, обход графа цепочками `client.User.Query().QueryPosts().QueryComments()`. Пока это выглядит как обещания — через пару блоков увидим код.

### Первая схема: пользователь микроблога

```go
package schema

import (
        "entgo.io/ent"
        "entgo.io/ent/schema/field"
)

// User — схема пользователя микроблога.
type User struct {
        ent.Schema
}

// Fields описывает колонки таблицы users.
func (User) Fields() []ent.Field {
        return []ent.Field{
                field.String("name").
                        NotEmpty(),
                field.String("email").
                        Unique(),
                field.Int("age").
                        Positive(),
        }
}
```

Разбор построчно. package schema — все схемы живут в одном пакете ent/schema. Тип User встраивает ent.Schema — это маркер, по которому генератор находит ваши сущности. Fields() возвращает список полей, каждое строится конструктором: field.String("name") — строковая колонка, field.Int("age") — числовая. Цепочки после конструктора — валидаторы и ограничения: NotEmpty() запрещает пустую строку, Unique() ставит уникальный индекс на колонку, Positive() не пустит отрицательный возраст. Проверка происходит в сгенерированном коде ещё до похода в базу: пользователь с пустым email вернёт ошибку валидации, а не ошибку СУБД. И обратите внимание: здесь нет ни одного тега и ни одной строки SQL — обычный Go, который компилируется, автодополняется и рефакторится как любой другой.

### Схема как код: Fields, Edges и go generate

Зачем усложнять, если GORM обходится тегами? Причины практические. Тег — это строка: опечатка в `gorm:"uniqueIndex"` компилятором не проверяется, и опция молча выбрасывается. Схема ent — код: неверный тип аргумента, лишний параметр, забытый валидатор отсекаются компилятором ещё до генерации. Дальше — сама генерация. В ent/generate.go лежит директива `//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema`, и команда `go generate ./ent` разворачивает схему в полноценный пакет. Появляются: ent/user.go — сущность со структурой User; ent/user_create.go, ent/user_update.go, ent/user_query.go — билдеры для каждой операции; ent/user/ — отдельный пакет предикатов для условий; ent/mutation.go — представление изменений, на котором держатся хуки; ent/client.go — входная точка, клиент базы.

Сгенерированный код принято коммитить в репозиторий — это официальная рекомендация ent: сборка не зависит от того, запустил ли разработчик генерацию, а диффы по ent/ видны в code review. Минус подхода честен: в каждом PR появляются сотни сгенерированных строк, и первые дни они пугают. Привыкание занимает день-два, зато дальше вы читаете не весь пакет, а только сигнатуры предикатов и билдеров — остальное стабильнее самого себя.

### Edges: пользователь пишет посты

```go
// Edges of the User: исходящее ребро «один-ко-многим».
func (User) Edges() []ent.Edge {
        return []ent.Edge{
                edge.To("posts", Post.Type),
        }
}

// Post: обратная сторона того же ребра.
func (Post) Edges() []ent.Edge {
        return []ent.Edge{
                edge.From("author", User.Type).
                        Ref("posts").
                        Unique(),
        }
}
```

edge.To("posts", Post.Type) объявляет ребро от пользователя к постам: без Unique() это «один-ко-многим» — у пользователя много постов. С обратной стороны пост ссылается на автора: edge.From("author", User.Type).Ref("posts").Unique() — «многие-к-одному», у поста ровно один автор. Ref связывает две половины ребра по именам, и генератор проверяет соответствие при генерации: описать половинки, которые не сходятся, не выйдет. Внешний ключ генератор разместит в таблице posts. Из имён рёбер рождаются методы: WithPosts() и WithAuthor() для жадной загрузки, HasPosts() и HasAuthor() для предикатов, QueryPosts() для обхода графа. Имена рёбер — часть публичного API вашего слоя данных: они попадают прямо в компилятор.

### Запрос builder-стилем

```go
client, err := ent.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
        return err
}
defer client.Close()

ctx := context.Background()

users, err := client.User.
        Query().
        Where(
                user.NameEQ("Анна"),
                user.AgeGTE(18),
        ).
        WithPosts().
        All(ctx)
```

ent.Open(диалект, dsn) открывает клиент: первый аргумент — диалект (postgres, mysql, sqlite3, tidb), второй — строка подключения. client.User — сгенерированный доступ к сущности; .Query() возвращает билдер, у которого всё автодополняется. Where() принимает предикаты из сгенерированного пакета ent/user: NameEQ("Анна") — точное равенство, AgeGTE(18) — «больше или равно». Несколько предикатов в Where объединяются через И, а переиспользовать их можно как обычные значения — предикат это переменная, его можно вернуть из функции и передать дальше (систематически — в десятом уроке). WithPosts() жадно загружает посты дополнительным запросом, All(ctx) исполняет и возвращает срез сущностей. В этой цепочке нет ни одной строки-условия: опечатка в любом методе — ошибка компиляции.

### Типобезопасность: компилятор против рантайма

Самая дорогая особенность ent видна не в красивом коде, а в сценарии переименования. Допустим, поле age в схеме переименовали в birth_year. В GORM поле структуры и строка `Where("age = ?")` расходятся молча: код собирается, библиотека в рантайме отправит запрос со старым именем, и ошибку вернёт драйвер — в лучшем случае на тестовом стенде, в худшем в проде. В ent цепочка другая: правите схему, запускаете go generate — и все вызовы user.AgeGTE() перестают компилироваться, потому что такого метода больше нет. Компилятор перечислит каждый вызов по файлам, IDE предложит замену. Это тот же заслон, что у sqlc (запросы проверяются по схеме на этапе генерации), только не для SQL-файлов, а для всего слоя данных целиком: связей, загрузок, вставок.

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

### GORM, sqlc и ent по критериям

| Критерий | GORM | sqlc | ent |
| --- | --- | --- | --- |
| Что пишет разработчик | структуры с тегами и цепочки методов | SQL в .sql-файлах | схемы (Go-код) в ent/schema |
| Кто собирает SQL | библиотека в рантайме, через рефлексию | никто: запрос ваш, обвязка сгенерирована | генератор по схеме, до запуска программы |
| Где ловится опечатка в колонке | в рантайме | на sqlc generate | на компиляции Go |
| Связи | ассоциации-теги, Preload со строками | JOIN руками | edges в схеме, With* типизировано |
| Динамические фильтры | легко: Where() с любыми условиями | трудно: COALESCE, отдельные выборки | легко: предикаты собираются в рантайме |
| Миграции | AutoMigrate | снаружи: golang-migrate, goose | automigrate и версионируемые миграции |
| Порог входа | минимальный | нужен уверенный SQL | выше всех: кодогенерация и свои концепции |

### Когда ent уместен, а когда избыточен

Вывод из таблицы короткий: ent берёт своим верхом там, где домен — действительно граф. Мой критерий — счёт связей и скорость их изменения. Микроблог из этого курса — пользователи, посты, комментарии, лайки, подписки — это пять сущностей, где половина связей «многие-ко-многим» и схема правится каждую неделю в начале проекта. Каждый такой рефакторинг в ent дешевле: поменял схему, перегенерировал, довёл код до компиляции — готово. Роли и права доступа, мульти-арендность, каталоги с категориями и атрибутами — та же история.

Честно про обратную сторону. Маленький CRUD из трёх таблиц без связей ent не нужен: GORM даст то же за вечер, без ent/ в каждом PR и без изучения нового словаря. Тяжёлые отчёты и аналитика ent не спасает — как и GORM, он генерирует запросы, которые в агрегатах всё равно допиливают сырым SQL (или держат рядом sqlc для этого слоя). И команду, которая не готова жить с кодогенерацией, ent будет раздражать — не подведёт, а именно раздражать: лишний шаг go generate, непривычные диффы, страх править «чужой» код. Моё мнение меняется только при одном условии: если связей мало и схема стабильна, разница между инструментами не стоит потраченного дня — берите то, что команда уже знает.

> **Песочница сайта не запускает ent.** Браузерный интерпретатор сайта исполняет чистый Go без сети и файловой системы, а ent-коду нужны кодогенерация (go generate) и база — поэтому примеры ent в уроках не запускаются в песочнице. Для практики нужен локальный Go и база: проще всего SQLite (файл вместо сервера) или PostgreSQL в Docker. Зато интерактивы урока — тесты, «что выведет», карточки — работают прямо в браузере и дают XP.

> **Что нужно знать перед курсом.** Базовый Go: структуры, методы, ошибки, указатели — всё это есть в [базовом курсе](/courses/go-basics/), а нулевые значения типов разбираются в уроке про [переменные и константы](/courses/go-basics/lessons/04-peremennye-konstanty/). Понадобятся и основы SQL: таблицы, ключи, JOIN. Если вы уже проходили [курс GORM](/courses/gorm/) или [sqlc](/courses/sqlc/lessons/01-znakomstvo/), сравнения из этого курса лягут на готовую почву.

### Проверка: что такое ent

**Вопрос:** Какой путь модуля у ent?

- github.com/ent/ent
- ✅ entgo.io/ent
- github.com/go-ent/ent
- entgo.io/orm

Модуль называется entgo.io/ent, документация — на entgo.io, а репозиторий — github.com/facebook/ent. Путь модуля не совпадает с адресом репозитория, как и у gorm.io/gorm.

### Проверка: где ловится ошибка

**Вопрос:** В схеме ent поле age переименовали в birth_year и перегенерировали код. Что произойдёт с вызовом user.AgeGTE(18) в запросе?

- Скомпилируется и упадёт в рантайме при первом запросе
- ✅ Не скомпилируется: метода user.AgeGTE больше не существует
- Генератор молча исправит все вызовы сам
- Запрос уйдёт со старым именем, ошибку вернёт драйвер

Предикаты — это сгенерированные методы: после переименования поля метод AgeGTE исчезает из пакета ent/user, и компилятор перечисляет все места с вызовом. Рантайм тут ни при чём — запрос с опечаткой просто не соберётся. У GORM та же ошибка дожила бы до выполнения.

### Прочитайте запрос как ent

```go
posts, err := client.Post.
        Query().
        Where(
                post.HasOwner(),
                post.TitleContains("Go"),
        ).
        All(ctx)
```

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

- ✅ Все посты, у которых есть автор и в заголовке встречается «Go»
- Все посты без автора с заголовком, содержащим «Go»
- Ошибка компиляции: предикат HasOwner не существует
- Все посты всех авторов, заголовки отсортированы по «Go»

HasOwner() — предикат по ребру: пост должен иметь владельца. TitleContains("Go") — подстрока в заголовке. Два предиката в Where объединяются через И, поэтому ответ — посты, у которых есть автор И в заголовке есть «Go». Ошибки компиляции нет: оба предиката сгенерированы для сущности Post, потому что в схеме есть ребро author.

### Восстановите схему

Поля и ребро: впишите конструктор строкового поля, валидатор непустой строки и конструктор исходящего ребра.

```go
func (User) Fields() []ent.Field {
        return []ent.Field{
                field.___("name").
                        ___(),
                field.String("email").
                        Unique(),
        }
}

func (User) Edges() []ent.Edge {
        return []ent.Edge{
                edge.___("posts", Post.Type),
        }
}
```

Подсказка: Тип поля задаёт конструктор field.String, запрет пустых строк — NotEmpty, исходящее ребро «один-ко-многим» — edge.To.

Правильные ответы в пропусках: `String`, `NotEmpty`, `To`

### Соберите рабочий цикл ent

Расставьте шаги от пустого модуля до первого запроса с типизированными предикатами.

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

1. `go mod init blog`
2. `go get entgo.io/ent`
3. `описать схемы User и Post в пакете ent/schema`
4. `go generate ./ent — появляются сущности, билдеры и предикаты`
5. `ent.Open("postgres", dsn) — подключиться к базе`
6. `client.User.Query().Where(user.NameEQ("Анна")).All(ctx)`

### Карточки: словарь ent

- **ent** — Библиотека для работы с базой из Go, выросшая в Facebook (Ariel Mashraki, опенсорс с 2019 года): схема данных как код плюс генерация слоя запросов. Модуль — entgo.io/ent, репозиторий — github.com/facebook/ent.
- **Схема** — Go-тип в пакете ent/schema, встраивающий ent.Schema. Fields() описывает колонки (field.String, field.Int...), Edges() — связи между сущностями.
- **Edges** — Рёбра графа в схеме: один-к-одному, один-ко-многим, многие-ко-многим. Объявляются edge.To/edge.From с Ref на обратную сторону; из имён рёбер генерируются With*, Has*, Query* методы.
- **Предикаты** — Типобезопасные условия из сгенерированного пакета (ent/user): user.NameEQ("..."), user.AgeGTE(18), post.HasOwnerWith(...). Комбинируются через And, Or, Not и передаются как обычные значения.
- **Кодогенерация** — go generate ./ent разворачивает схемы в пакет: сущности, билдеры (user_create.go, user_query.go), предикаты, mutation.go, client.go. Сгенерированный код коммитится в репозиторий.
- **Движки ent** — MySQL, PostgreSQL, SQLite и TiDB — все через драйверы database/sql. Диалект задаётся при открытии клиента: ent.Open("postgres", dsn).

### Что дальше по курсу

Во втором уроке поставим ent и соберём первые схемы: ent init, структура пакета ent/schema, файл generate.go. В третьем — поля: типы field.*, дефолты, валидаторы, уникальность. Четвёртый — optional и nillable, то есть работа с NULL. Пятый и шестой — edges: O2M и M2O, затем M2M и кастомные join-таблицы. Седьмой — устройство генерации подробнее. Дальше по плану: запросы и предикаты, жадная загрузка With*, пагинация, update/delete, миграции, транзакции, хуки и интерсепторы, privacy-политики, GraphQL через entgql, тестирование и производительность. Финал — итоговый проект: микроблог целиком, с лайками и подписками, на которых мы и отработаем граф целиком. Полный план из 25 уроков — на [странице курса](/courses/ent/).

Если перед стартом захочется освежить альтернативы, вводные уроки [GORM](/courses/gorm/lessons/01-znakomstvo/) и [sqlc](/courses/sqlc/lessons/01-znakomstvo/) показывают те же задачи слоя данных с обеих сторон — все сравнения в этом курсе будут опираться на них.

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

- ent — библиотека от Facebook с кодогенерацией: схему описываешь Go-кодом, слой запросов генерируется командой go generate ./ent.
- Данные — граф: сущности — узлы, связи — рёбра; O2M, M2O, M2M и O2O объявляются в Edges() схемы.
- Сгенерированный код — сущности, билдеры, предикаты вида user.NameEQ — коммитится в репозиторий.
- Ошибки слоя данных ловит компилятор: переименование поля роняет все вызовы user.AgeGTE, а не продакшен.
- GORM — рефлексия и теги в рантайме; sqlc — генерация из ваших SQL-запросов; ent — генерация из схемы-кода.
- ent окупается на графовых доменах со связями, как микроблог; маленький CRUD и тяжёлые отчёты — не его стихия.
- Песочница не исполняет ent: для практики нужна локальная связка Go и базы.

## Домашнее задание

Поднимите стартовый модуль локально: go mod init blog, затем go get entgo.io/ent и go run -mod=mod entgo.io/ent/cmd/ent init User Post. Перенесите схему User из урока, опишите Post с полями title и body, объявите ребро posts/author и запустите go generate ./ent. Откройте ent/user/where.go и найдите предикаты NameEQ и AgeGTE. Затем переименуйте поле age в схеме, перегенерируйте и посмотрите на ошибки компиляции — это тот самый заслон, ради которого ent и делали.

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

### Что такое ent простыми словами?

Библиотека для Go, в которой вы описываете данные как код — схемы с полями и связями, — а она генерирует по ним типобезопасный слой запросов. Связи между сущностями — рёбра графа, запросы к ним — типизированные методы вместо строк.

### Чем ent отличается от GORM?

GORM работает в рантайме через рефлексию и struct-теги, ошибки в строках условий всплывают при выполнении. ent генерирует код по схеме: опечатка в поле или связи не компилируется. GORM быстрее в старт, ent дорожает вместе с ростом графа связей.

### ent или sqlc?

sqlc генерирует обвязку из ваших SQL-запросов: контроль над SQL максимален, но связей, жадной загрузки и динамических фильтров там нет. ent закрывает всё это типизированными edges и предикатами, но SQL вы не контролируете. В одном проекте их совмещают: ent для домена, sqlc для отчётов.
