---
title: "Итоговый проект: микроблог"
description: "Итоговый проект на ent: блог целиком — схема пользователей, постов и комментариев, сервис с транзакциями, тесты на enttest и миграции — финал курса."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/25-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 30
---

# Итоговый проект: микроблог

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

Финал: всё, что вы собрали за двадцать четыре урока, уезжает в один проект — блог на ent целиком. Схема графа: пользователи-авторы, посты, комментарии, лайки и подписки; сервисный слой поверх клиента с транзакциями из шестнадцатого урока; миграции, которые поднимают базу; тесты на настоящей SQLite из двадцать третьего. По канону курса: анна, борис, вера, счётчик posts_count, мягкое удаление и группа go-ninja. Соберём композиционный корень main, посчитаем в таблице, из какого урока что пришло, и закроем курс выпускным экзаменом в домашке. Если вы искали «ent проект» или «микроблог go» — это тот урок.

Маршрут урока — от ТЗ до запуска. Сначала — карта графа: пять сущностей и их рёбра, требования списком. Затем — схемы User, Post, Comment и Like с индексами из двадцатого урока. Третья часть — сервисный слой: BlogService над *ent.Client, транзакции WithTx из шестнадцатого, лайки без гонок через ConstraintError из тринадцатого. Четвёртая — лента подписок: With-цепочки из одиннадцатого, ключсет из двенадцатого, интерсептор мягкого удаления из восемнадцатого. Пятая — композиционный корень main с миграцией Schema.Create из пятнадцатого и пулом из двадцать второго. Финал — тесты на enttest из двадцать третьего урока, таблица «из какого урока что пришло» и выпускной экзамен. Если вы искали «ent проект» или «блог на ent» — это тот урок.

### ТЗ блога

- Пользователи: имя, уникальный email, возраст; авторы пишут посты и комментарии.
- Посты: заголовок, тело, время создания, автор; мягкое удаление вместо жёсткого.
- Комментарии: тело, автор, пост; умирают вместе с постом каскадом базы.
- Лайки: пользователь лайкает пост не более одного раза — уникальная пара на уровне базы.
- Подписки: направленные following/followers из шестого урока, дружба — симметричная.
- Лента: посты тех, на кого подписан, новые сверху, по двадцать на страницу с курсором.
- Счётчик posts_count у автора всегда согласован с числом его постов — без Cron и гонок.
- Удалённые посты исчезают из всех выборок, но остаются в базе: интерсептор и частичный индекс.

### Карта графа проекта

| Сущность | Поля | Рёбра | Таблица |
| --- | --- | --- | --- |
| User | name, age, email*, posts_count, deleted_at | posts (O2M), comments (O2M), friends (M2M), following/followers, likes (O2M) | users |
| Post | title, body, created_at, deleted_at | author (M2O), comments (O2M), likes (O2M) | posts |
| Comment | body, created_at | post (M2O), author (M2O) | comments |
| Like | — | post (M2O), user (M2O); уникальная пара (post_id, user_id) | likes |
| Рёбра-таблицы | user_friends: user_id, friend_id | друзья — симметричные строки, подписки — направленные | user_friends |

### Схема User: канон курса

```go
type User struct{ ent.Schema }

func (User) Fields() []ent.Field {
        return []ent.Field{
                field.String("name").NotEmpty(),
                field.Int("age").Positive(),
                field.String("email").Unique(),
                field.Int("posts_count").Default(0),
                field.Time("deleted_at").Optional().Nillable(),
        }
}

func (User) Edges() []ent.Edge {
        return []ent.Edge{
                edge.To("posts", Post.Type),
                edge.To("comments", Comment.Type),
                edge.To("likes", Like.Type),
                edge.To("friends", User.Type),
                edge.To("following", User.Type).From("followers"),
        }
}
```

Канон пятого и шестого уроков: O2M на посты, комментарии и лайки, симметричная дружба одним ребром, направленные подписки одним билдером To и From. posts_count — денормализация из двенадцатого, её двигают хуки из семнадцатого; deleted_at — мягкое удаление из четырнадцатого, фильтр на чтение ставит интерсептор из восемнадцатого. Unique на email — уникальный индекс из двадцатого.

### Схема Post: индекс ленты из двадцатого урока

```go
type Post struct{ ent.Schema }

func (Post) Fields() []ent.Field {
        return []ent.Field{
                field.String("title").NotEmpty(),
                field.Text("body"),
                field.Time("created_at").Default(time.Now),
                field.Time("deleted_at").Optional().Nillable(),
        }
}

func (Post) Edges() []ent.Edge {
        return []ent.Edge{
                edge.From("author", User.Type).Ref("posts").
                        Field("author_id").Required().Unique(),
                edge.To("comments", Comment.Type),
                edge.To("likes", Like.Type),
        }
}

func (Post) Indexes() []ent.Index {
        return []ent.Index{
                index.Edges("author").Fields("created_at").
                        Annotations(entsql.Desc(), entsql.IndexWhere("deleted_at IS NULL")).
                        StorageKey("posts_author_created_idx"),
        }
}
```

M2O-ребро с Field и Required из пятого урока: колонка author_id — NOT NULL, лента автора ищет именно её. Индекс — канон двадцатого урока: составной по author_id и created_at, убывающий — порядок дерева совпадает с «новые сверху», частичный — удалённые посты в дерево не попадают. StorageKey фиксирует имя: ревью миграций из пятнадцатого урока скажет спасибо.

### Схемы Comment и Like: каскад и уникальная пара

```go
type Comment struct{ ent.Schema }

func (Comment) Fields() []ent.Field {
        return []ent.Field{
                field.Text("body").NotEmpty(),
                field.Time("created_at").Default(time.Now),
        }
}

func (Comment) Edges() []ent.Edge {
        return []ent.Edge{
                edge.From("post", Post.Type).Ref("comments").
                        Field("post_id").Required().Unique(),
                edge.From("author", User.Type).Ref("comments").
                        Field("author_id").Required().Unique(),
        }
}

type Like struct{ ent.Schema }

func (Like) Edges() []ent.Edge {
        return []ent.Edge{
                edge.From("post", Post.Type).Ref("likes").
                        Field("post_id").Required().Unique(),
                edge.From("user", User.Type).Ref("likes").
                        Field("user_id").Required().Unique(),
        }
}

func (Like) Indexes() []ent.Index {
        return []ent.Index{
                index.Edges("post", "user").Unique().
                        StorageKey("likes_post_user_uq"),
        }
}
```

Like — не join-таблица через Through, а полноценная сущность: уникальный индекс пары (post_id, user_id) — паттерн «одно на владельца» из двадцатого урока. Обе колонки NOT NULL благодаря Required — NULL-дыры уникальности нет. Каскадное поведение удалений настраивается на рёбрах из пятого урока: комментарии и лайки умирают вместе с постом силами базы.

### Сервисный слой: контракты поверх клиента

Граф собран — время сервиса. Границу из двадцать второго урока держим и здесь: BlogService получает *ent.Client в конструктор и наружу отдаёт методы домена, а не билдеры: CreateAuthor, Publish, Comment, ToggleLike, Follow, Feed, DeletePost. Ошибки ent переводятся в доменные в одном месте: ent.IsNotFound — к 404, ent.IsConstraintError — к конфликту «email занят» или «уже лайкнул»; хелперы сравнения ошибок из [девятого](/courses/ent/lessons/09-zaprosy/) и [тринадцатого](/courses/ent/lessons/13-update/) уроков не расползаются по хендлерам. Хуки из [семнадцатого урока](/courses/ent/lessons/17-huki/) поддерживают posts_count автоматически, интерсептор из [восемнадцатого](/courses/ent/lessons/18-interceptors/) прячет удалённые, privacy из [девятнадцатого](/courses/ent/lessons/19-privacy/) решает, кого пускать; сервис их не дублирует — они уже подключены на клиенте из схемы. Сервис тестопригоден без моков: под ним настоящий клиент из enttest, и тесты прогоняют настоящий SQL.

### BlogService: создание автора и публикация

```go
type BlogService struct{ client *ent.Client }

func NewBlogService(c *ent.Client) *BlogService {
        return &BlogService{client: c}
}

func (s *BlogService) CreateAuthor(ctx context.Context, name, email string, age int) (*ent.User, error) {
        u, err := s.client.User.Create().
                SetName(name).SetEmail(email).SetAge(age).
                Save(ctx)
        if ent.IsConstraintError(err) {
                return nil, ErrEmailTaken // доменная ошибка: наверху -> 409
        }
        return u, err
}

func (s *BlogService) Publish(ctx context.Context, authorID int, title, body string) (*ent.Post, error) {
        var post *ent.Post
        err := WithTx(ctx, s.client, func(tx *ent.Tx) error {
                var err error
                post, err = tx.Post.Create().
                        SetTitle(title).SetBody(body).
                        SetAuthorID(authorID).
                        Save(ctx)
                return err // хук 17-го сдвинет posts_count в этой же транзакции
        })
        return post, err
}
```

WithTx из шестнадцатого урока делит код на «внутри транзакции» и «снаружи»: пост и инкремент счётчика коммитятся одним COMMIT — согласованность posts_count бесплатна, потому что её двигает хук из семнадцатого урока внутри той же мутации. ConstraintError переводится в доменную ошибку наверху: слой HTTP не знает про SQL. SetAuthorID возможен благодаря Field на ребре из пятого урока.

### Проверка: posts_count без гонок

**Вопрос:** Два автора публикуют посты одновременно, у анны — сотый пост. Почему posts_count не врёт?

- ✅ Инкремент делает хук AddPostsCount(1) в той же транзакции, что и INSERT поста
- Сервис читает счётчик, прибавляет единицу и пишет обратно SetPostsCount
- Счётчик пересчитывается задачей по расписанию раз в минуту
- ent автоматически пересчитывает счётчик при каждом чтении

Канон тринадцатого и семнадцатого уроков: чтение-запись (SetPostsCount(n+1)) теряет инкременты под гонками — оба воркера записывают n+1 от одного старого значения. AddPostsCount(1) уезжает в SET posts_count = posts_count + 1, база применяет выражение атомарно; хук гарантирует, что оно исполняется в той же транзакции, что и сам INSERT.

### Комментарии и лайки: транзакции и ConstraintError

Комментарий — формально одна мутация: Comment.Create().SetPostID(...).SetAuthorID(...) создаёт обе связи одним INSERT. Транзакция нужна, когда рядом с комментарием двигается счётчик или аудит-хук из [семнадцатого урока](/courses/ent/lessons/17-huki/) пишет строку — тогда всё внутри WithTx, как учил [шестнадцатый урок](/courses/ent/lessons/16-tranzakcii/). Лайк сложнее: пара (post, user) уникальна, и «сначала прочитай, потом создай» — гонка: два быстрых клика создадут два лайка. Правильная схема — ограничения базы из [двадцатого урока](/courses/ent/lessons/20-indeksy/) плюс ветвление по ошибке из [тринадцатого](/courses/ent/lessons/13-update/): пробуем INSERT, на ent.IsConstraintError считаем, что лайк уже стоял, и снимаем его тем же запросом Delete().Where(). Гонки исчезают, потому что арбитр — не ваш код между чтением и записью, а UNIQUE-индекс, который проверяет база атомарно.

### ToggleLike: лайк без гонок

```go
func (s *BlogService) ToggleLike(ctx context.Context, userID, postID int) (bool, error) {
        err := s.client.Like.Create().
                SetUserID(userID).SetPostID(postID).
                Save(ctx)
        switch {
        case err == nil:
                return true, nil // поставили лайк
        case ent.IsConstraintError(err):
                _, err := s.client.Like.Delete().
                        Where(like.UserIDEQ(userID), like.PostIDEQ(postID)).
                        Exec(ctx)
                return false, err // сняли лайк
        default:
                return false, err // настоящая ошибка базы — наверх
        }
}
```

Первый Create вставляет строку — лайк стоит. Второй упирается в уникальную пару likes_post_user_uq, IsConstraintError срабатывает, ветка Delete удаляет строку — Toggle вернул состояние обратно. Заметьте: OnConflictColumns из тринадцатого урока тоже подошёл бы, но для переключателя «поставил-снял» честнее явная ветка удаления, чем UpdateNewValues.

### Второй лайк

```go
st1, err1 := svc.ToggleLike(ctx, anna.ID, post.ID)
st2, err2 := svc.ToggleLike(ctx, anna.ID, post.ID)
n, _ := svc.LikeCount(ctx, post.ID)

fmt.Println(st1, err1 == nil, st2, err2 == nil, n)
```

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

- ✅ true true false true 0: первый поставил, второй снял, в базе ноль лайков
- true true true true 2: каждый ToggleLike добавлял строку
- true true false false 0: ConstraintError выпал наружу как ошибка
- ошибка: второй лайк упал и счётчик испортился

Первый Create — новая строка: st1 true, ошибки нет. Второй упирается в уникальный индекс, ConstraintError не покидает сервис — он превращается в ветку Delete, и st2 false означает «лайк снят». LikeCount читает таблицу likes: ноль. Так ограничение базы из двадцатого урока становится доменной логикой без единой гонки.

### Лента подписок: граф целиком

Лента — витрина всего курса: посты тех, на кого подписан пользователь, свежие сверху, живые, с авторами и комментариями. Запрос начинается от Post: Where(post.HasAuthorWith(user.HasFollowersWith(user.IDEQ(me)))) — предикаты по связям из [десятого урока](/courses/ent/lessons/10-predikaty/) читаются снаружи внутрь. Загрузка — With-цепочка из [одиннадцатого](/courses/ent/lessons/11-with/): WithAuthor — имя и счётчик автора, WithComments с Limit(3) и вложенным WithAuthor — последние комментарии с их авторами. Пагинация — ключсет из [двенадцатого урока](/courses/ent/lessons/12-paginaciya/): CreatedAtLT плюс IDGT, чтобы равные времена не дублировали страницы. Мягкое удаление в запросе не упоминается ни разу: интерсептор из [восемнадцатого](/courses/ent/lessons/18-interceptors/) вшивает DeletedAtIsNil во всякий обход графа — и в корень, и в With-загрузки. Цена известна заранее: один корневой SELECT плюс по одному на уровень With — счётчик запросов из [двадцать четвёртого](/courses/ent/lessons/24-proizvoditelnost/) это докажет.

### Feed: несколько запросов на страницу

```go
// type Cursor struct { Time time.Time; ID int }

func (s *BlogService) Feed(ctx context.Context, me int, cur *Cursor) ([]*ent.Post, error) {
        q := s.client.Post.Query().
                Where(post.HasAuthorWith(user.HasFollowersWith(user.IDEQ(me)))).
                WithAuthor(func(aq *ent.UserQuery) {
                        aq.Select(user.FieldName, user.FieldPostsCount)
                }).
                WithComments(func(cq *ent.CommentQuery) {
                        cq.Order(ent.Desc(comment.FieldCreatedAt)).Limit(3).
                                WithAuthor(func(aq *ent.UserQuery) {
                                        aq.Select(user.FieldName)
                                })
                }).
                Order(ent.Desc(post.FieldCreatedAt), ent.Asc(post.FieldID)).
                Limit(20)
        if cur != nil {
                q = q.Where(post.CreatedAtLT(cur.Time), post.IDGT(cur.ID))
        }
        return q.All(ctx)
}
```

Один корневой SELECT плюс по одному на уровень With: авторы, комментарии, их авторы — четыре запроса на страницу, сколько бы подписок ни было. Select на соседях безопасен: ent сам допишет ключи, без которых рёбра не соберутся. Интерсептор мягкого удаления уже внутри: DeletedAtIsNil вшит и в корень, и в WithAuthor, и в WithComments.

### Путь запроса GET /feed

Соберите путь запроса ленты от хендлера до курсора.

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

1. `HTTP-хендлер достаёт id пользователя и курсор из параметров`
2. `BlogService.Feed строит запрос от Post с предикатами по связям`
3. `интерсептор мягкого удаления добавляет DeletedAtIsNil`
4. `корневой SELECT уходит по составному индексу ленты`
5. `With-загрузки догружают авторов и комментарии запросами с IN`
6. `ключсет-курсор для следующей страницы возвращается клиенту`

### Миграции и композиционный корень

Проект начинается не с кода, а с базы, которую поднимет Schema.Create из [пятнадцатого урока](/courses/ent/lessons/15-migracii/): automigrate сверяет схемы с таблицами и дописывает недостающее — первый прогон на чистой базе создаёт всё, второй подряд ничего не меняет; идемпотентность эту проверяли тесты [двадцать третьего урока](/courses/ent/lessons/23-testirovanie/), и здесь она станет частью приёмки. Versioned-миграции Atlas — следующий шаг, когда изменения схем нужно ревьюить файлами. Композиционный корень собирает всё ровно один раз: пул соединений из [двадцать второго](/courses/ent/lessons/22-mnogobaz/) урока, клиент, миграция, сервис, хендлеры. Хуки и интерсепторы объявлены в схеме — в main их не видно, и это хорошо: корень не обязан знать про posts_count и DeletedAtIsNil. Конфигурация с честным падением: пустой DSN — это fatal при старте, а не тайна на третьем экране продакшена.

### main.go: весь проект в двадцати строках

```go
func main() {
        ctx := context.Background()
        dsn := os.Getenv("BLOG_DSN")
        if dsn == "" {
                log.Fatal("BLOG_DSN пуст: старт невозможен")
        }

        db, err := sql.Open(dialect.Postgres, dsn)
        if err != nil {
                log.Fatal(err)
        }
        db.SetMaxOpenConns(25)
        db.SetConnMaxLifetime(30 * time.Minute)

        client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db)))
        if err := client.Schema.Create(ctx); err != nil {
                log.Fatal(err)
        }
        svc := NewBlogService(client)

        mux := http.NewServeMux()
        mux.HandleFunc("POST /authors", handleCreateAuthor(svc))
        mux.HandleFunc("POST /posts", handlePublish(svc))
        mux.HandleFunc("POST /posts/{id}/like", handleToggleLike(svc))
        mux.HandleFunc("GET /feed", handleFeed(svc))

        log.Println("блог на ent слушает :8080")
        log.Fatal(http.ListenAndServe(":8080", mux))
}
```

Композиционный корень: конфиг с честным падением, пул, клиент, миграция, сервис, роутер. Хендлеры — тонкие адаптеры: разобрать запрос, вызвать метод сервиса, перевести доменные ошибки в статусы — IsNotFound в 404, ErrEmailTaken в 409. Полные HTTP-слои с валидацией и JWT строятся в курсах [Gin](/courses/gin/) и [Echo](/courses/echo/) — слой данных уже готов.

### Тесты проекта: контур из двадцать третьего урока

Тесты не пишутся заново — они уже спроектированы. enttest.Open поднимает SQLite с automigrate и DSN с _fk=1, фикстура seedMicroblog создаёт канон — анну, бориса, веру и их связи, table-driven проверяет фильтры, ent.IsConstraintError — дубль email и второй лайк, атомарность хука posts_count проверяется сбоем посреди WithTx, невидимость удалённых — интерсептором. Новое только масштаб: тесты гоняют сервис, а не сырые билдеры — фикстура возвращает BlogService поверх клиента, и тест ленты доказывает на настоящем SQL, что анна видит посты бориса, на которого подписана, но не посты веры. Каскад удалений комментариев при этом исполняется базой — поведение ребра из [пятого урока](/courses/ent/lessons/05-edges-o2m/) — а не хуком. go test ./... -race обязателен: ToggleLike из двух горутин на одном посте обязан оставить ровно один лайк или ни одного, но не два.

### Тест ленты на сервисе

```go
func TestFeedFollowsOnly(t *testing.T) {
        client := enttest.Open(t, dialect.SQLite,
                fmt.Sprintf("file:test_%s?mode=memory&_fk=1", t.Name()))
        defer client.Close()

        svc := NewBlogService(client)
        users := seedMicroblog(t, client) // анна, борис, вера + подписки

        page, err := svc.Feed(ctx, users.anna.ID, nil)
        if err != nil {
                t.Fatal(err)
        }
        if len(page) == 0 {
                t.Fatal("лента анны пуста: подписка на бориса потерялась")
        }
        for _, p := range page {
                if p.Edges.Author.Name == "вера" {
                        t.Fatal("пост веры не должен попадать в ленту анны")
                }
        }
}
```

Фикстура из двадцать третьего урока экономит пол-теста: анна с постами, борис, вера, подписки анны на бориса. Ассерт на границе графа — самый ценный: он ловит и испорченный предикат HasFollowersWith, и снятый интерсептор одновременно. WithAuthor уже внутри Feed, поэтому Edges.Author заполнен без отдельного запроса.

### Проверка: что докажет тест с _fk=1

**Вопрос:** Тест удаляет пост бориса и проверяет, что его комментарии исчезли. Что обеспечивает это поведение?

- ✅ Каскадное поведение ребра post.comments из пятого урока и PRAGMA _fk=1 в DSN тестовой базы
- Хук на Delete, который удаляет комментарии вручную
- Сервис BlogService: он удаляет комментарии перед постом
- Ничего: SQLite всегда чистит дочерние строки сама

Каскад объявлен на ребре из пятого урока и исполняется базой через внешние ключи; без _fk=1 SQLite игнорирует FK — тест дал бы зелёный на половине правды, как предупреждал двадцать третий урок. В проекте та же проверка стоит на пяти таблицах графа: посты, комментарии, лайки, друзья, подписки.

### Соберите композиционный корень

Впишите открытие клиента, миграцию и диагностику.

```go
client, err := ___.Open(dialect.Postgres, dsn)
if err != nil {
        log.Fatal(err)
}
if err := client.___.Create(ctx); err != nil {
        log.Fatal(err)
}
client = client.___() // считать запросы глазами на старте проекта
svc := NewBlogService(client)
```

Подсказка: Первый пропуск — сгенерированный пакет-обёртка, который открывает драйвер; второй — контейнер миграции из пятнадцатого урока; третий — метод клиента, включающий лог SQL из двадцать четвёртого урока.

Правильные ответы в пропусках: `ent`, `Schema`, `Debug`

### Из какого урока что пришло

| Что в проекте | Уроки курса |
| --- | --- |
| Поля, валидаторы, уникальный email, Optional-NULL | 03-поля, 04-optional |
| Рёбра O2M и M2O, Required, Field, каскады удалений | 05-edges-o2m |
| Друзья и подписки following/followers | 06-edges-m2m |
| Билдеры Create, OnConflict, SetAuthorID | 08-создание |
| Предикаты по связям HasAuthorWith | 10-предикаты |
| With-цепочки ленты, проекции соседей | 11-with, 24-производительность |
| Ключсет-пагинация, курсор (created_at, id) | 12-пагинация |
| posts_count: Add-мутаторы и хуки | 13-update, 17-хуки |
| Мягкое удаление и DeletedAtIsNil | 14-delete, 18-interceptors |
| Schema.Create, идемпотентность, Atlas | 15-миграции |
| WithTx и атомарность счётчика | 16-транзакции |
| Индексы ленты, уникальная пара лайка, StorageKey | 20-indeksy |
| enttest, фикстуры seedMicroblog, -race | 23-тестирование |

### Грабли выпускника

- Schema.Create в хендлере: миграция — старт приложения, не горячий путь.
- Счётчик через чтение-запись: только Add-мутаторы и хуки, иначе гонки вернутся.
- Лайк без уникальной пары: два клика — два лайка; «прочитал-создал» — гонка.
- With-цепочка на всё сразу: грузите то, что рисуется; проекции соседей дешевле.
- OFFSET вместо ключсета: страницы замедляются с глубиной.
- Хук без ветки ent.BatchMutation: переход на CreateBulk молча ломает posts_count.
- Тесты на моках: SQL не мокается — enttest и настоящая база из двадцать третьего урока.
- Индексы на всякий случай: каждая штука — цена на записи; индекс появляется от запроса.

### Проверка: ent или не ent

**Вопрос:** Ваш следующий проект — каталог с десятью связанными сущностями и еженедельными правками схемы. Что советует курс?

- ✅ ent: рёбра первым классом и кодогенерация — компилятор сам покажет все места, которые сломала правка схемы
- Ничего: связей много, нужен только database/sql
- GORM: связи строками в кавычках надёжнее кодогенерации
- ent, но рёбра описывать вручную колонками

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

> **Как проверять себя в этом уроке.** Песочницы в курсе ent нет — и финальному проекту она не нужна: соберите блог в своём go-проекте. Порядок: четыре схемы из урока, go generate, Schema.Create на чистой базе, BlogService с методами из урока, enttest-тесты на фикстуре. Затем идите к выпускному экзамену из домашки ниже: он собирает проверки из ключевых уроков, и каждая из них либо зелёная, либо указывает, какой урок перечитать.

### Что дальше

Курс закрыт: вы прошли путь от «что такое ent» до полного блога — граф из пяти сущностей, сервис с транзакциями, тесты на настоящей базе, миграции и производительность. Дальше три дороги. GraphQL-слой поверх этого же графа через entgql из двадцать первого урока — схемы и резолверы сгенерируются из тех же Schema-файлов. Мульти-тенантность из двадцать второго, если блог станет платформой: клиенты и интерсепторы уже к ней готовы. И соседние курсы: [GORM](/courses/gorm/) и [sqlc](/courses/sqlc/) решают те же задачи слоя данных другими инструментами, HTTP-слой собирают [Gin](/courses/gin/), [Echo](/courses/echo/) и [Fiber](/courses/fiber/). Полный план из 25 уроков — на [странице курса](/courses/ent/), микроблог поднимался с [первого урока](/courses/ent/lessons/01-znakomstvo/). Домашка ниже — выпускной экзамен: семь заданий, которые соберут курс в одну приёмку.

### Карточки: проект «микроблог»

- **Карта графа блога** — User (name, age, email*, posts_count, deleted_at) — posts, comments, likes, friends, following/followers; Post (title, body, created_at, deleted_at) — author, comments, likes; Comment (body, created_at) — post, author; Like — пара (post, user) с уникальным индексом. Таблицы: users, posts, comments, likes, user_friends.
- **Сервис поверх клиента** — BlogService с *ent.Client в конструкторе; методы домена вместо билдеров; ent.IsNotFound -> 404, ent.IsConstraintError -> 409 (ErrEmailTaken). Хуки, интерсепторы и privacy подключены на клиенте из схемы — сервис их не дублирует и потому тестируется на настоящем SQL без моков.
- **Транзакции в проекте** — WithTx из шестнадцатого урока: Publish создаёт пост, хук AddPostsCount(1) срабатывает в той же транзакции — один COMMIT. ToggleLike: Create лайка, на ent.IsConstraintError — ветка Delete. Гонки невозможны: уникальность пары держит индекс «одно на владельца» из двадцатого урока.
- **Лента подписок** — Post.Query().Where(post.HasAuthorWith(user.HasFollowersWith(user.IDEQ(me)))) + WithAuthor + WithComments(Limit(3).WithAuthor()) + ключсет CreatedAtLT и IDGT. Интерсептор DeletedAtIsNil вшит во все обходы; четыре запроса с IN на страницу независимо от размера.
- **Композиционный корень** — main: конфиг с честным падением на пустом DSN, sql.Open и SetMaxOpenConns, ent.NewClient(ent.Driver(entsql.OpenDB(...))), Schema.Create один раз при старте, сервис, тонкие хендлеры. Хуки объявлены в схеме — в main их не видно: корень не знает про posts_count.
- **Приёмка проекта** — enttest.Open с _fk=1, фикстура seedMicroblog, table-driven на фильтры и ленту, IsConstraintError на дубль email и второй лайк, атомарность posts_count сбоем посреди WithTx, невидимость удалённых, go test ./... -race. Идемпотентность миграции: второй Schema.Create ничего не меняет.

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

- Проект собрал курс воедино: граф User/Post/Comment/Like с индексами ленты, сервисный слой, транзакции, тесты и миграции.
- Схемы из третьего-шестого уроков: поля с валидаторами, O2M и M2O-рёбра с Required и каскадами, друзья и following/followers, денормализованный posts_count.
- Индексы из двадцатого урока: составной (author_id, created_at) с Desc и IndexWhere для ленты, уникальная пара лайка — «одно на владельца».
- Сервис прячет ent за доменными методами и ошибками: IsNotFound — 404, IsConstraintError — 409; хуки и интерсепторы живут на клиенте из схемы.
- Транзакции шестнадцатого урока держат атомарность: пост и счётчик одним COMMIT; ToggleLike без гонок на ConstraintError.
- Лента — With-цепочки, предикаты по связям и ключсет: четыре запроса на страницу, удалённое невидимо благодаря интерсептору.
- Композиционный корень main: пул, клиент, Schema.Create при старте, тонкие хендлеры; GraphQL из двадцать первого — следующий слой над тем же графом.
- Тесты из двадцать третьего урока: enttest, фикстуры, -race; выпускной экзамен в домашке собирает приёмку проекта.

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

Выпускной экзамен: соберите блог на ent целиком и пройдите приёмку. Первое: схемы User, Post, Comment и Like из урока — с индексом ленты и уникальной парой лайка; прогоните go generate и Schema.Create дважды: второй прогон обязан ничего не менять (идемпотентность миграции). Второе: BlogService с методами CreateAuthor, Publish, Comment, ToggleLike, Follow и Feed; дубль email обязан вернуть доменную ошибку из ent.IsConstraintError, а не сырую ConstraintError. Третье: подключите хуки posts_count из семнадцатого урока и докажите тестом атомарность: ошибка посреди WithTx откатывает и пост, и счётчик; затем проверьте, что хук переживёт CreateBulk — ветка ent.BatchMutation и Mutations() обязательна. Четвёртое: лента — предикаты по связям, With-цепочки, ключсет; тестом докажите, что анна видит посты бориса, но не веры, и что мягко удалённый пост не виден ни в ленте, ни в загрузке через ребро. Пятое: ToggleLike дважды — счётчик лайков обязан вернуться в ноль; затем тот же ToggleLike из двух горутин под go test -race на одном посте: итог один лайк или ноль, но не два. Шестое: замерьте страницу ленты счётчиком запросов из двадцать четвёртого урока — на ней не должно быть N+1; замените OFFSET на ключсет, если ещё не сделали этого. Седьмое: сравните подход с соседями — [GORM](/courses/gorm/) и [sqlc](/courses/sqlc/) решают те же задачи иначе; напишите абзац, что выбрали бы для следующего проекта и почему. Приёмка-контроль: go vet ./..., go test ./... -race, второй Schema.Create без изменений, счётчик запросов ленты не больше четырёх — вся приёмка зелёная, значит курс сдан. Сдал — вы знаете ent.

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

### Достаточно ли этого проекта для портфолио?

Как слой данных — да: граф с пятью сущностями, транзакции, тесты на настоящей базе и миграции показывают владение ent на уровне, который встречается нечасто. Для полного приложения добавьте HTTP-слой из курсов Gin или Echo, Docker из их уроков деплоя — и проект станет резюме-кейсом «от схемы до продакшена».

### SQLite или Postgres для этого проекта?

Разрабатывать можно на SQLite из двадцать третьего урока — скорость контура тестов важнее. Деплоить — на Postgres: диалектные вещи вроде частичных индексов из двадцатого урока гоняйте на настоящем Postgres в CI. ent мультибазовый с седьмого урока: меняется DSN и драйвер, схема не меняется.

### Как добавить роли и права к блогу?

Privacy-политики из девятнадцатого урока поверх клиента: viewer-контекст, DenyIfNoViewer, AllowIfAdmin — сервис и хендлеры менять не придётся, правила уже исполняются на каждом запросе и мутации. Транспортный слой (JWT, middleware) подставляет viewer в контекст — это материал курсов Gin и Echo.
