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

# Итоговый проект: слой данных блога — посты, комментарии, теги, транзакции

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

Финал: двадцать пятый урок закрывает курс, и это будет самый честный экзамен — gorm проект, в котором собирается всё, что вы строили двадцать четыре урока подряд. Новых механик почти нет, зато есть сборка: посты, комментарии, теги, мягкое удаление, транзакции — слой данных блога целиком, от моделей до тестов. Запрос «слой данных go» вбивают в поиск тогда, когда учебные примеры кончились, а реальное приложение ещё не началось: между ними лежит ровно этот мост — спроектировать схему, объявить интерфейс репозитория, завести связи, обернуть многошаговые операции в транзакции и накрыть всё тестами. Скелет дал прошлый [урок](/courses/gorm/lessons/24-testirovanie/): репозиторий TodoItemRepo с четырьмя методами и тестовым хозяйством вокруг — здесь мы вырастим его в PostRepo для блога, сохранив все приёмы: база на тест, фабрика с модификаторами, сабтесты по таблице. Связи соберём в карту из уроков про ассоциации — belongs to, has many и [many-to-many](/courses/gorm/lessons/13-many-to-many/), — а транзакционную обёртку возьмём готовой из [урока про транзакции](/courses/gorm/lessons/15-tranzakcii/). По дороге заглянем в SQL-следы: что GORM пишет за вас и как выглядело бы то же самое руками — связка с уроком про Raw. И оговорка о формате, как всегда одна: GORM требует живую СУБД, поэтому runner-ов в уроке нет — код не исполнить в песочнице, вас проверяют quiz, predict, fill, order и карточки, а весь проект рассчитан на локальный Go. Карта курса — на его [странице](/courses/gorm/).

### gorm проект: что мы собираем к финалу

Итоговый gorm проект начинается не с кода, а с инвентаризации: что вообще должен уметь слой данных go-приложения-блога. Список получается короткий и очень знакомый: показать ленту опубликованных постов постранично; открыть один пост вместе с автором, комментариями и тегами; создать пост и сразу приклеить к нему теги — так, чтобы половина операции не могла уцелеть отдельно от другой; спрятать пост, не стирая его с диска; вернуть спрятанное; и не обрушиваться при каждом запуске. Это и есть нижний этаж приложения: сервисы и хендлеры дёргают его через интерфейс, а он разговаривает с базой. Двадцать четыре урока дали все детали по отдельности — модели, миграции, удаление, связи, транзакции, пагинация, скоупы, тесты. Сегодня мы не переобъясняем ни одну из них: мы расставляем их по рабочим местам в одном проекте. Схема блога уложится в пять таблиц и четыре модели, Repository — в один интерфейс и одну gorm-реализацию, тесты — в тот же каркас, что вы собирали в прошлом уроке для списка задач. Каждый фрагмент кода снабжён пометкой, из какого урока он приехал: так проект работает дважды — как готовый слой данных и как шпаргалка по всему курсу. Если какая-то деталь кажется чужой — вернитесь по пометке, освежите её и возвращайтесь: финал не прощает пропусков, но и не требует ничего держать в голове насильно.

### Схема блога: сущности, связи, таблицы

Начнём со схемы блога — на бумаге, до всякого кода. Четыре сущности и одна join-таблица. Author — автор: id и имя, больше ничего, потому что профиль пользователя нам не нужен, а принадлежность поста — нужна. Post — центральная сущность: заголовок, уникальный slug-адрес, тело, флаг published для черновиков, время создания и внешний ключ author_id — он покажет связь принадлежности из одиннадцатого урока. Comment — комментарий: тело, имя комментатора и post_id, обратная сторона той же пары, но уже один-ко-многим из двенадцатого урока: у поста много комментариев, у каждого комментария один пост. Tag — тег с уникальным именем: один тег живёт у многих постов, и у каждого поста много тегов — это many-to-many из тринадцатого урока, и для него GORM заведёт пятую таблицу post_tags с колонками post_id и tag_id. И одна не-очевидная деталь, которая делает блог блогом: DeletedAt у поста. Удалённая публикация не должна исчезать физически — её возвращают из корзины, её осталось бы видно в админке — поэтому посты удаляются мягко, приёмом девятого урока, а колонка deleted_at получает индекс, потому что фильтр по ней стоит в каждом втором запросе. Обратите внимание, чего в схеме нет: отдельной таблицы «черновики» — черновик это published равный false; таблицы «просмотры» — не наш уровень; категорий — теги их заменяют. Схема блога — это всегда минус сущностей, а не плюс: каждая лишняя таблица — это лишний JOIN, лишняя миграция и лишний тест.

### Карта проекта: какая тема курса куда входит

| Тема курса | В проекте | Что делает |
| --- | --- | --- |
| Модели и соглашения (третий урок) | Author, Post, Comment, Tag | структуры с тегами; таблицы posts, comments, tags, authors |
| Автомиграции (четвёртый урок) | AutoMigrate на старте | пять таблиц со связями за один вызов |
| Удаление и мягкое удаление (девятый) | gorm.DeletedAt в Post | Delete превращается в UPDATE, Unscoped возвращает спрятанное |
| belongs to (одиннадцатый) | Post.AuthorID + Post.Author | пост принадлежит автору; ключ на стороне поста |
| has one и has many (двенадцатый) | Post.Comments []Comment | у поста много комментариев; ключ PostID у комментария |
| many-to-many (тринадцатый) | Post.Tags many2many:post_tags | m2m теги: join-таблица post_tags со связками post_id-tag_id |
| Preload (четырнадцатый) | BySlug с Preload | пост выезжает с автором, комментариями и тегами разом |
| Транзакции (пятнадцатый) | Create с db.Transaction | пост и его теги фиксируются или откатываются вместе |
| Пагинация (семнадцатый) и скоупы (девятнадцатый) | Feed со Scopes(Published, Page) | лента постранично; черновики — отдельным скоупом Draft |
| Тестирование (двадцать четвёртый) | TestPostRepo + фабрика makePost | тот же каркас, что и у списка задач, на новой схеме |

### Канон проекта: модели Author, Post, Comment, Tag

```go
// Автор: минимальный владелец постов.
type Author struct {
    ID   uint
    Name string
}

// Пост: центральная сущность блога.
type Post struct {
    ID        uint
    Title     string
    Slug      string `gorm:"unique"` // уникальный адрес - приём из канона репозитория
    Body      string
    Published bool           // false = черновик
    CreatedAt time.Time      // заполнится сам при создании
    AuthorID  uint           // belongs to: ключ на стороне поста
    Author    Author         // связь из одиннадцатого урока
    Comments  []Comment      // has many из двенадцатого
    Tags      []Tag          `gorm:"many2many:post_tags"` // m2m из тринадцатого
    DeletedAt gorm.DeletedAt `gorm:"index"`               // мягкое удаление из девятого
}

// Комментарий: принадлежит посту, живёт в своей таблице.
type Comment struct {
    ID        uint
    PostID    uint   // без внешнего ключа has many не соберётся
    Author    string // имя комментатора: отдельная таблица не нужна
    Body      string
    CreatedAt time.Time
}

// Тег: один тег живёт у многих постов, и наоборот.
type Tag struct {
    ID   uint
    Name string `gorm:"unique"` // одно имя тега на всю базу
}
```

Четыре структуры — и вся схема блога. Единственные теги здесь — уникальность на Slug и Name, ключ many2many для m2m тегов и индексированный DeletedAt: остальное GORM выведет из типов и имён полей. Автомиграция четвёртого урока собирает по ним все пять таблиц одним вызовом AutoMigrate.

### Разбор связей: belongs to, has many, m2m теги

Пройдём по связям, не переобъясняя механику одиннадцатого-тринадцатого уроков — только то, как она легла в схему блога. Связь принадлежности живёт там, где внешний ключ: у поста есть AuthorID, значит пост принадлежит автору, а поле Author даёт GORM понять, к какой таблице идти. Важно, что ключ объявлен явно: без AuthorID связь принадлежности была бы догадкой, а с ним — контрактом. В паре пост-комментарий ключ лежит у комментария: PostID. Со стороны поста это выглядит как has many — поле Comments описывает «у меня много комментариев», а GORM сам догадывается о таблице и колонке по именам Post и Comment. Ничего магического: если бы поле комментария называлось иначе, пришлось бы подсказать тегом — и это было в двенадцатом уроке. Наконец, m2m теги: поле Tags с указанием join-таблицы post_tags. Почему именно многие-ко-многим, а не has many с одной связкой? Потому что тег — разделяемый ресурс: «go» вешают сто постов, и хранить сто копий тега — путь к рассинхрону; join-таблица держит только пары идентификаторов, а имя тега существует в одном экземпляре. Три вида связей закрывают практически любую предметную область: принадлежность — для «чей это», один-ко-многим — для «у кого сколько», многие-ко-многим — для «что пересекается». Блог использовал все три, и ни одну не пришлось подпиливать напильником — это главный признак того, что связи спроектированы верно.

### Проверка: сколько таблиц соберёт миграция

**Вопрос:** Автомиграция вызывается так: AutoMigrate(&Author{}, &Post{}, &Comment{}, &Tag{}). Сколько таблиц появится в базе и почему?

- ✅ Пять: четыре сущности плюс join-таблица post_tags, которую GORM создаёт из many2many
- Четыре: GORM не создаёт join-таблицу без отдельного вызова
- Три: Author уложится в колонки Post без отдельной таблицы
- Шесть: для m2m заведутся две join-таблицы, в обе стороны

Join-таблицу many2many GORM создаёт сам при миграции — отдельного вызова не нужно. Имена колонок предсказуемы: post_id и tag_id, по именам связываемых таблиц и их ключей. Итого пять таблиц — ровно сколько мы насчитали на бумаге.

### SQL-следы: что вы написали бы руками

Свяжем проект с десятым уроком про Raw и Exec: полезно видеть, что GORM делает вместо вас, потому что однажды вам придётся писать это руками — в отчёте, в миграции данных, в запросе, который ORM выразит неуклюже. Лента — это цепочка из двух соединений: posts к authors по ключу принадлежности, плюс фильтры — опубликовано, не удалено — и сортировка с постраничными границами. Создание поста с тегами — пачка INSERT-ов: сам пост, теги, которых не было, и строки в post_tags, причём между первым и последним INSERT-ом нельзя позволить базе остаться наполовину заполненной — вот почему следующая операция уедет в транзакцию. Мягкое удаление руками — вообще не DELETE, а UPDATE с условием, что строка ещё не спрятана. Ниже — след, который оставляет наш Repository, если бы его писал человек без ORM. Сравните с кодом ниже по уроку: разница не в объёме, а в том, кто отвечает за корректность колонок, экранирование и диалект. ORM берёт рутину на себя, но цена — вы обязаны понимать, какой SQL он соберёт из ваших цепочек, иначе однажды поймаете скан таблицы там, где ждали точечный поиск по индексу. Десятый урок показал, как заглянуть под капот через Raw; здесь достаточно взгляда на текст запроса глазами.

### След проекта в SQL: лента, создание, удаление

```go
-- Лента: Scopes(Published, Page) + Order превратились вот в это.
SELECT p.id, p.title, p.slug, a.name AS author
FROM posts p
JOIN authors a ON a.id = p.author_id
WHERE p.published = 1 AND p.deleted_at IS NULL
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;
-- published = 1 пишет SQLite; Postgres ждал бы true - привет двадцать третьему уроку.

-- Создание поста с тегами: пачка INSERT внутри одной транзакции.
INSERT INTO posts (title, slug, body, published, author_id)
VALUES ('Про Go', 'pro-go', 'Текст', 1, 1);
INSERT INTO tags (name) VALUES ('go');
INSERT INTO tags (name) VALUES ('orm');
INSERT INTO post_tags (post_id, tag_id) VALUES (1, 1);
INSERT INTO post_tags (post_id, tag_id) VALUES (1, 2);
COMMIT;

-- Мягкое удаление - не DELETE, а UPDATE по ещё живым строкам.
UPDATE posts SET deleted_at = '2025-03-01 12:00:00'
WHERE slug = 'pro-go' AND deleted_at IS NULL;
```

Три следа: чтение ленты с соединением и пагинацией, создание с join-строками, мягкое удаление как UPDATE. Всё это GORM собирает из кода ниже — Compare и держите в голове, что именно уезжает в базу.

### Слой Repository: интерфейс как шов

Схема есть — теперь её рабочая обёртка. Слой Repository мы строим точно по канону прошлого урока: структура с закрытым полем *gorm.DB, конструктор, методы-операции — но с одним важным усилением, которого не было у репозитория задач из прошлого урока. Мы объявим интерфейс PostRepo отдельно от реализации: пять методов, каждый — операция блога, а не обёртка над вызовом GORM. Create принимает пост и список имён тегов — потому что «создать пост с тегами» это одна бизнес-операция, и сервис не должен знать, что внутри неё транзакция. BySlug возвращает пост со всеми связями — потому что страница поста всегда рисует автора, комментарии и теги. Feed возвращает и страницу, и общий счётчик — потому что пагинации без знания общего числа постов не бывает. SoftDelete возвращает счётчик затронутых строк — приём из репозитория задач: промах по несуществующему слагу это ноль без ошибки, и пусть решает сервис, что с ним делать. Зачем интерфейс, если реализация одна? Шов из прошлого урока: тесты сервиса получат мок этого интерфейса, тесты самого репозитория будут гонять настоящую базу, а публикация — не знать ни того ни другого. Конструктор возвращает именно интерфейс: потребители зависят от контракта, а не от конкретной структуры — это и есть слой данных go-приложения, отделённый от всего остального.

### Канон: интерфейс PostRepo и конструктор

```go
// PostRepo - контракт слоя данных блога для сервиса.
type PostRepo interface {
    Create(post *Post, tagNames []string) error
    BySlug(slug string) (*Post, error)
    Feed(page, perPage int) ([]Post, int64, error)
    SoftDelete(slug string) (int64, error)
    Restore(slug string) error
}

// gormPostRepo - единственная gorm-реализация контракта.
type gormPostRepo struct {
    db *gorm.DB
}

// NewBlogRepo принимает базу или транзакцию - это один и тот же тип *gorm.DB.
func NewBlogRepo(db *gorm.DB) PostRepo {
    return &gormPostRepo{db: db}
}
```

Пять методов — пять операций блога. Конструктор возвращает интерфейс, а не структуру: сервис зависит от контракта. Подмена базы на транзакцию из пятнадцатого урока здесь бесплатна — Begin возвращает тот же *gorm.DB.

### Создание поста с тегами: одна транзакция

Самая важная операция проекта — Create, и она же — главный аргумент за транзакции из пятнадцатого урока. Операция многошаговая: вставить пост, для каждого имени тега найти существующий или создать новый, вставить связку в post_tags. Между шагами всё, что угодно может пойти не так: слаг оказался занят, имя тега нарушило уникальность, соединение оборвалось посреди пачки. Без транзакции это оставляет мусор — пост без тегов, теги без поста, полупустые связки; с транзакцией — или всё, или ничего. Обёртка db.Transaction принимает замыкание и управляет судьбой операций за вас: вернули nil — COMMIT, вернули ошибку — Rollback, и неважно, на каком шаге вы спохватились. Внутри замыкания работаем не с r.db, а с tx — тем же типом, но связанным с транзакцией; фабрика тегов через Where и FirstOrCreate находит существующий тег или создаёт новый, а Association с Append вставляет строку в join-таблицу — механика тринадцатого урока в чистом виде. Одна честная оговорка: FirstOrCreate под конкуренцией — не атомарная операция; два запроса, создающих один новый тег одновременно, разойдутся так, что один получит ошибку уникальности — и это правильно: его транзакция откатится целиком, а клиент попробует ещё раз. Ловим это на верхнем уровне, а не ломаем транзакцию хитростями. Всё остальное — забота замыкания: оно либо доводит операцию до конца, либо стирает её следы.

### Канон: Create с транзакцией и тегами

```go
// Create сохраняет пост и привязывает теги - атомарно или никак.
func (r *gormPostRepo) Create(post *Post, tagNames []string) error {
    return r.db.Transaction(func(tx *gorm.DB) error {
        // Шаг 1: сам пост. INSERT заполнит ID.
        if err := tx.Create(post).Error; err != nil {
            return err // откат: поста нет
        }
        // Шаг 2: теги - найти или создать, затем привязать.
        for _, name := range tagNames {
            tag := Tag{Name: name}
            if err := tx.Where(Tag{Name: name}).FirstOrCreate(&tag).Error; err != nil {
                return err // гонка на уникальном имени - тоже откат
            }
            if err := tx.Model(post).Association("Tags").Append(&tag); err != nil {
                return err // join-строка не легла - откат всего
            }
        }
        return nil // nil - транзакция фиксируется
    })
}
```

Замыкание из пятнадцатого урока: nil — COMMIT, ошибка — ROLLBACK. FirstOrCreate повторяет семантику «найди или создай», Append вставляет связку в post_tags. Любой сбой посреди пачки стирает всё созданное ранее — пост не останется без тегов.

### Прогноз: что останется после отката

```go
// Обёртка Create с валидацией внутри транзакции.
func (r *gormPostRepo) CreateChecked(post *Post) error {
    return r.db.Transaction(func(tx *gorm.DB) error {
        if err := tx.Create(post).Error; err != nil {
            return err
        }
        if post.Body == "" {
            return errors.New("пустое тело поста")
        }
        return nil
    })
}

// Пост без тела: Create прошёл, валидация - нет.
_ = repo.CreateChecked(&Post{Title: "Пустышка", Slug: "empty", AuthorID: 1})

var n int64
db.Model(&Post{}).Count(&n)
fmt.Println(n)
```

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

- ✅ 0: замыкание вернуло ошибку, и INSERT поста откатился вместе с транзакцией
- 1: Create уже вставил строку, а своя ошибка после него ничего не отменяет
- 1: GORM замечает свою ошибку и делает COMMIT, чтобы запись не пропала
- паника: из замыкания нельзя возвращать собственную ошибку

Контракт db.Transaction из пятнадцатого урока: любая ошибка из замыкания — откат всех его операций, включая уже выполненный INSERT. Транзакция существует ровно до возврата из замыкания: nil фиксирует, ошибка стирает.

### Мягкое удаление в проекте: спрятать, не найти, вернуть

Удаление в блоге — самое коварное место, и мягкое удаление девятого урока здесь не украшение, а необходимость: публикацию может прятать редактор, а автор через час попросит её вернуть. Наш SoftDelete — это Delete по слагу, но для модели с DeletedAt GORM развернёт его в UPDATE: строка остаётся в таблице, колонка deleted_at получает отметку времени, а все обычные запросы — и наши, и чужие — перестают её видеть, потому что фильтр deleted_at IS NULL GORM добавляет к каждому чтению автоматически, без единого условия с вашей стороны. Отсюда два следствия, которые стоит проговорить. Первое: BySlug удалённого поста вернёт знакомую ошибку отсутствия записи из шестого урока — для читателя спрятанный пост неотличим от несуществующего, и это ровно то поведение, которое нужно ленте и страницам. Второе: строка никуда не делась, а значит её можно вернуть — Restore вызывает Unscoped, пропускающий автоматический фильтр, и пишет в deleted_at нулевое значение. Пара SoftDelete-Restore стоит четырёх строк и закрывает целый класс обращений «верните, пожалуйста, тот пост». А вот чего мягкое удаление не делает — не трогает чужие строки: комментарии спрятанного поста остаются в своей таблице, связки в post_tags тоже. Это не баг, а семантика операции: UPDATE одной таблицы. Что с ними делать — вопрос политики блога, и мы разберём его в ловушках ниже.

### Канон: BySlug, SoftDelete, Restore

```go
// BySlug выезжает со всеми связями - жадная загрузка четырнадцатого урока.
func (r *gormPostRepo) BySlug(slug string) (*Post, error) {
    var post Post
    err := r.db.Preload("Author").Preload("Comments").Preload("Tags").
        Where("slug = ?", slug).
        First(&post).Error
    if err != nil {
        return nil, err // в том числе отсутствие записи: поста нет или он спрятан
    }
    return &post, nil
}

// SoftDelete прячет пост: UPDATE deleted_at вместо DELETE.
func (r *gormPostRepo) SoftDelete(slug string) (int64, error) {
    res := r.db.Where("slug = ?", slug).Delete(&Post{})
    return res.RowsAffected, res.Error
}

// Restore возвращает спрятанное: Unscoped пускает к удалённым строкам.
func (r *gormPostRepo) Restore(slug string) error {
    return r.db.Unscoped().
        Model(&Post{}).
        Where("slug = ?", slug).
        Update("deleted_at", nil).Error
}
```

Три метода — весь цикл жизни публикации. First вернёт ErrRecordNotFound и для несуществующего, и для спрятанного слага; RowsAffected различит промах и успех в SoftDelete; Restore пишет nil в deleted_at под Unscoped.

### Прогноз: два счётчика после мягкого удаления

```go
// База с одним опубликованным постом "hello".
repo := NewBlogRepo(db)
if _, err := repo.SoftDelete("hello"); err != nil {
    log.Fatal(err)
}

var visible int64
db.Model(&Post{}).Count(&visible)

var all int64
db.Unscoped().Model(&Post{}).Count(&all)

fmt.Println(visible, all)
```

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

- ✅ 0 1: обычный счётчик спрятанную строку не видит, Unscoped видит всё
- 1 1: Delete удалил строку, а Unscoped возвращает и удалённые
- 0 0: Delete физически стёр строку, Unscoped помочь не может
- 1 0: фильтр deleted_at работает в обратную сторону

Delete на модели с gorm.DeletedAt — это UPDATE, строка жива, но закрыта автоматическим фильтром: обычный Count даёт ноль. Unscoped отключает фильтр — счётчик видит спрятанную строку. Админка «корзины» строится ровно на этом.

### Проверка: уникальный слаг против мягкого удаления

**Вопрос:** Пост со Slug "golang" был мягко удалён. Пытаемся создать новый пост с тем же Slug. Что произойдёт?

- ✅ Ошибка уникальности: строка физически в таблице, а уникальный индекс про deleted_at не знает
- Запись создастся: мягкое удаление освобождает уникальные значения
- GORM заметит конфликт и молча заменит старую запись новой
- Новый пост унаследует deleted_at и тоже станет спрятанным

Мягкое удаление — это UPDATE, а не DELETE: строка с занятым слагом лежит в таблице, и обычный уникальный индекс честно отклоняет второй такой же. Лечение — частичный уникальный индекс с условием deleted_at IS NULL или составное уникальное поле слаг+отметка; направление подскажет урок про индексы.

### Лента: скоупы published/draft и пагинация

Лента — самое читаемое место любого блога, и соберём мы её из двух тем: скоупов девятнадцатого урока и пагинации семнадцатого. Скоуп — это именованный кусочек условия в виде функции, принимающей и возвращающей *gorm.DB: Published — только опубликованные, Draft — только черновики, Page — границы страницы с защитными ограничениями. Композиция — их сила: Scopes принимает их списком, и условия складываются в один запрос; завтра понадобится «черновики конкретного автора» — напишется третьим скоупом, а не четвёртой копией WHERE. Page как функция высшего порядка возвращает замыкание: Feed вызывает Scopes(Published, Page(page, perPage)), а замыкание уже ограничивает границы — страница меньше единицы становится первой, размер страницы больше сотни урезается до разумного. Запросы пользователя — не место для доверия. Сам Feed делает два запроса от одной базы: сначала Count по опубликованным — чтобы вернуть total, без которого пагинация не нарисует страницы; затем Find с теми же скоупами, границами и сортировкой по времени создания. Две независимые цепочки от r.db — условия не перетекают из одной в другую, это важная гигиена из урока про чтение. Мягкое удаление здесь участвует молча: DeletedAt в модели добавляет свой фильтр к обоим запросам, и спрятанные посты в ленту не попадают без единого упоминания.

### Канон: скоупы Published/Draft/Page и метод Feed

```go
// Published - скоуг опубликованных: публичная лента.
func Published(db *gorm.DB) *gorm.DB {
    return db.Where("published = ?", true)
}

// Draft - скоуп черновиков: личная панель автора.
func Draft(db *gorm.DB) *gorm.DB {
    return db.Where("published = ?", false)
}

// Page - скоуп пагинации: замыкание с защитными границами.
func Page(page, perPage int) func(*gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        if page < 1 {
            page = 1
        }
        if perPage < 1 || perPage > 100 {
            perPage = 20
        }
        return db.Limit(perPage).Offset((page - 1) * perPage)
    }
}

// Feed - публичная лента: счётчик и страница одним методом.
func (r *gormPostRepo) Feed(page, perPage int) ([]Post, int64, error) {
    var total int64
    // Счётчик - отдельная цепочка: те же условия, без Limit/Offset.
    if err := r.db.Model(&Post{}).Scopes(Published).Count(&total).Error; err != nil {
        return nil, 0, err
    }
    var posts []Post
    err := r.db.Model(&Post{}).
        Scopes(Published, Page(page, perPage)).
        Order("created_at DESC").
        Find(&posts).Error
    return posts, total, err
}
```

Две независимые цепочки от одной базы: Count для total, Find для страницы. Скоупы складываются списком, мягкое удаление фильтрует оба запроса само. Замыкание Page страхует границы от гуляющих параметров запроса.

### Соберите Feed: скоупы, границы, выборка

Восстановите метод ленты: три пропуска — скоуг для счётчика, скоуг с границами и финишер выборки.

```go
func (r *gormPostRepo) Feed(page, perPage int) ([]Post, int64, error) {
    var total int64
    if err := r.db.Model(&Post{}).Scopes(___).Count(&total).Error; err != nil {
        return nil, 0, err
    }
    var posts []Post
    err := r.db.Model(&Post{}).
        Scopes(Published, ___(page, perPage)).
        Order("created_at DESC").
        ___(&posts).Error
    return posts, total, err
}
```

Подсказка: Счётчику хватит Published; странице нужны Published и замыкание Page; завершает выборку Find.

Правильные ответы в пропусках: `Published`, `Page`, `Find`

### Тесты проекта: канон прошлого урока переезжает

Тестовый слой проекта — это ровно каркас двадцать четвёртого урока, переезжающий на новую схему без переделок: база на тест с одним соединением, фабрика с осмысленными дефолтами и модификаторами, сабтесты по таблице ожиданий. Меняются только персонажи: вместо TodoItemRepo — PostRepo, вместо makeTask — makePost, вместо одной таблицы — пять. Наша фабрика создаёт пост через сам репозиторий — это не лень, а честность: тест проверяет то, что увидит продакшн, включая транзакцию Create. Черновик или публикация — модификатором, как дедлайн у фабрики задач. Единственная новая дисциплина — слаги: они уникальны на весь прогон, поэтому каждый сабтест использует свои, а если накроете сабтесты транзакционным пузырём newTx — сможете не думать об этом вовсе, канон прошлого урока остался при вас. Что проверяем: Create с тегами — что join-строки легли и теги выгрузились вместе с постом; SoftDelete-Restore — полный цикл спрятать-не найти-вернуть; Feed — что черновики и спрятанные посты не просачиваются, счётчик сходится со страницей. Это минимальный набор, при котором рефакторинг репозитория перестаёт быть прыжком с парашютом: сломаете автоматический фильтр мягкого удаления или потеряете транзакцию — тесты это покажут раньше пользователей.

### Канон: фабрика makePost и тесты репозитория

```go
// makePost - фабрика по канону прошлого урока: дефолты + модификаторы.
func makePost(t *testing.T, repo PostRepo, slug string, mods ...func(*Post)) *Post {
    t.Helper()
    p := &Post{Title: "Пост " + slug, Slug: slug, AuthorID: 1, Body: "Текст"}
    for _, mod := range mods {
        mod(p)
    }
    if err := repo.Create(p, nil); err != nil {
        t.Fatalf("фабрика не смогла создать %q: %v", slug, err)
    }
    return p
}

func TestPostRepo(t *testing.T) {
    db := openBlogDB(t) // каркас прошлого урока: база на тест + схема пяти моделей
    repo := NewBlogRepo(db)

    t.Run("Create с тегами - всё или ничего", func(t *testing.T) {
        post := &Post{Title: "Про Go", Slug: "pro-go", Body: "Текст",
            AuthorID: 1, Published: true}
        if err := repo.Create(post, []string{"go", "orm"}); err != nil {
            t.Fatal(err)
        }
        var got Post
        if err := db.Preload("Tags").Where("slug = ?", "pro-go").
            First(&got).Error; err != nil {
            t.Fatal(err)
        }
        if len(got.Tags) != 2 {
            t.Fatalf("ждали два тега, получено %d", len(got.Tags))
        }
    })

    t.Run("SoftDelete прячет, Restore возвращает", func(t *testing.T) {
        makePost(t, repo, "sketch")
        n, err := repo.SoftDelete("sketch")
        if err != nil || n != 1 {
            t.Fatalf("n=%d, err=%v", n, err)
        }
        if _, err := repo.BySlug("sketch"); err == nil {
            t.Fatal("спрятанный пост не должен находиться")
        }
        if err := repo.Restore("sketch"); err != nil {
            t.Fatal(err)
        }
        if _, err := repo.BySlug("sketch"); err != nil {
            t.Fatalf("после Restore пост должен находиться: %v", err)
        }
    })

    t.Run("Feed - только опубликованные, постранично", func(t *testing.T) {
        for _, slug := range []string{"post-a", "post-b", "post-c", "post-d"} {
            makePost(t, repo, slug, func(p *Post) { p.Published = true })
        }
        makePost(t, repo, "draft-a") // черновик в ленту не попадёт

        posts, total, err := repo.Feed(1, 2)
        if err != nil || total != 4 || len(posts) != 2 {
            t.Fatalf("total=%d, len=%d, err=%v", total, len(posts), err)
        }
    })
}
```

Три сабтеста — по строке таблицы ниже. Фабрика создаёт посты через репозиторий, поэтому транзакция Create под тестом всегда. Черновик в третьем сабтесте проверяет сразу и скоуг Published, и молчаливый фильтр мягкого удаления.

### Соберите путь создания поста с тегами

Расставьте шаги операции Create в правильном порядке — от входа сервиса до фиксации транзакции.

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

1. `сервис валидирует данные: заголовок, слаг и автор на месте`
2. `репозиторий вызывает db.Transaction с замыканием`
3. `tx.Create(post) - INSERT в posts, ID заполнен`
4. `для каждого имени тега: Where + FirstOrCreate находит или создаёт тег`
5. `Association("Tags").Append вставляет связку в post_tags`
6. `ошибка на любом шаге - return err, и всё сделанное откатывается`
7. `nil из замыкания - COMMIT, пост с тегами готов`

### Чек-лист репозитория блога

| Метод | Контракт | Что проверяет тест |
| --- | --- | --- |
| Create(post, tagNames) | пост и теги атомарно | теги выгрузились с постом; сбой посреди — записей нет вообще |
| BySlug(slug) | пост со связями или ошибка отсутствия | живой находится; спрятанный неотличим от несуществующего |
| Feed(page, perPage) | страница опубликованных + total | черновики и спрятанные не просачиваются; счётчик сходится |
| SoftDelete(slug) | RowsAffected 1 или 0 | повторное удаление даёт 0 — строка уже спрятана |
| Restore(slug) | deleted_at снова nil | после Restore пост находится обычным BySlug |
| SoftDelete по чужому слагу | 0 и без ошибки | промах — не паника; решает сервис, как сообщать |

> **Три ловушки финального проекта.** Первая: уникальный слаг против мягкого удаления. Спрятанный пост занимает свой слаг в уникальном индексе, поэтому пересоздать публикацию с тем же адресом не выйдет — нужен частичный уникальный индекс по живым строкам, тема урока про индексы. Вторая: мягкое удаление не каскадится. UPDATE не будит внешние ключи, поэтому комментарии спрятанного поста остаются в таблице, а связки в post_tags — в своей; решите политику заранее: фильтровать по родителю, чистить в той же транзакции или оставить до Restore. Третья: гонка на тегах. FirstOrCreate под конкуренцией даёт ошибку уникальности одному из двоих — это не баг, а сигнал повторить операцию; глушить её молчаливым повтором внутри замыкания не стоит, ловите на верхнем уровне.

### Финал: двадцать четыре урока под вашими пальцами

Пройдём по курсу ещё раз — теперь каждая строка этого проекта имеет адрес. Первый урок объяснил, зачем ORM и когда без него чище; второй — подключение и пул соединений, на котором держится база на тест; третий — модели и соглашения, из которых выросли наши четыре структуры; четвёртый — миграции, что собрали пять таблиц; пятый — Create с его привычками, шестой — чтение с ошибкой отсутствия записи, седьмой — Where, из которого выросли скоупы, восьмой — обновления со счётчиком строк, что вернул нам SoftDelete. Девятый дал мягкое удаление — DeletedAt в модели блога; десятый — Raw, чтобы читать SQL-следы глазами; одиннадцатый-тринадцатый — три вида связей, скелет всей схемы; четырнадцатый — Preload, что выгрузил автора, комментарии и теги одним BySlug. Пятнадцатый — транзакции, на которых стоит Create; шестнадцатый — хуки жизненного цикла, куда проект созреет вешать слагогенерацию; семнадцатый — пагинация, что стала лентой; восемнадцатый — индексы, что подскажут частичное решение для слагов; девятнадцатый — скоупы, что собрали Published и Draft; двадцатый — builder-стиль для динамических фильтров, если блогу понадобится поиск; двадцать первый — NULL и указатели, двадцать второй — пользовательские типы, когда появятся метаданные поста; двадцать третий — диалекты, чтобы проект переехал с SQLite на Postgres без переписывания; двадцать четвёртый — тесты, что сегодня прикрывали спину. Это и есть слой данных go-приложения в сборке — и ваш gorm проект как портфолио.

### Чем владеет выпускник курса

1. проектировать схему из моделей: таблицы, связи, ограничения — и собирать их миграциями
2. читать и писать данные через Create, First, Find, Updates, Delete — зная, какой SQL уезжает вместо каждого вызова
3. собирать фильтры Where-ом, скоупами и builder-стилем, не оставляя дыр в условиях
4. держать связки belongs to, has many и m2m теги и выгружать их Preload-ом без лишних запросов
5. оборачивать многошаговые операции в транзакции и понимать цену уровню изоляции
6. прятать и возвращать строки мягким удалением, помня про уникальные индексы
7. выдавать ленты постранично со счётчиком и предсказуемой сортировкой
8. работать с NULL через указатели, а с JSON-данными — через пользовательские типы
9. переносить проект между диалектами, зная, где их отличия стреляют
10. тестировать слой данных на in-memory-базе с фабриками и транзакционными откатами

### Карточки урока

- **Что создаёт many2many:post_tags в схеме блога?** — Join-таблицу post_tags с колонками post_id и tag_id — сам, при миграции четырёх моделей. Имя тега хранится один раз в tags, связки живут в post_tags.
- **Почему мягкое удаление не освобождает Slug?** — Delete на модели с DeletedAt — это UPDATE: строка остаётся в таблице, и обычный уникальный индекс отклоняет второй такой же слаг. Лечение — частичный индекс по живым строкам.
- **Что даёт обёртка Create в db.Transaction?** — Атомарность: пост, теги и связки в post_tags фиксируются вместе (nil из замыкания) или стираются все (ошибка) — пост не останется без тегов.
- **Зачем интерфейс PostRepo, если реализация одна?** — Шов из прошлого урока: сервисы тестируются на моке интерфейса, сам репозиторий — на реальной in-memory-базе. Конструктор возвращает интерфейс, потребители зависят от контракта.
- **Как собран метод Feed?** — Две цепочки от одной базы: Count по Scopes(Published) для total, затем Find с Published, замыканием Page и Order по created_at. Мягкое удаление фильтрует оба запроса само.
- **Что вернёт GORM после мягкого удаления поста?** — RowsAffected 1 при успехе, 0 на промахе; строка остаётся с заполненным deleted_at, BySlug по ней возвращает ошибку отсутствия, Restore через Unscoped возвращает её в жизнь.

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

- Итоговый gorm проект собрал слой данных блога из двадцати четырёх уроков: схема блога — пять таблиц (authors, posts, comments, tags, post_tags), Repository — интерфейс PostRepo плюс одна gorm-реализация, тесты — канон прошлого урока на новой схеме.
- Связи легли по карте: belongs to — AuthorID на посте, has many — Comments с ключом PostID, m2m теги — many2many:post_tags с join-таблицей. Join-таблицу GORM создаёт сам при миграции.
- Create — одна транзакция из пятнадцатого урока: INSERT поста, FirstOrCreate для каждого тега, Association Append для связок; nil — COMMIT, ошибка — ROLLBACK, пост не остаётся без тегов.
- Мягкое удаление — UPDATE deleted_at вместо DELETE: обычные запросы не видят спрятанное, Unscoped видит, Restore пишет nil обратно. Похороненный слаг остаётся занятым уникальным индексом.
- Лента — композиция скоупов девятнадцатого урока и пагинации семнадцатого: Published/Draft как именованные условия, Page как замыкание с защитными границами, Feed возвращает страницу и total двумя независимыми цепочками.
- SQL-следы проекта: лента — SELECT с JOIN и LIMIT/OFFSET, создание — пачка INSERT с COMMIT, мягкое удаление — UPDATE с условием. ORM пишет рутину, но понимать её текст — обязанность разработчика.
- Тесты переезжают из прошлого урока без переделок: база на тест, фабрика makePost с модификаторами, сабтесты Create/SoftDelete-Restore/Feed. Фабрика создаёт посты через сам репозиторий — транзакция под тестом всегда.
- Ловушки финала: уникальный индекс против мягкого удаления, отсутствие каскада у UPDATE (комментарии и связки остаются), гонка FirstOrCreate на тегах под конкуренцией.

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

Соберите проект целиком и расширьте его. Шаг первый: соберите models.go с четырьмя моделями, откройте базу AutoMigrate-ом и убедитесь, что в схеме пять таблиц, а у post_tags два внешних ключа. Шаг второй: реализуйте интерфейс PostRepo целиком — Create через db.Transaction, BySlug с Preload, Feed с двумя цепочками, SoftDelete и Restore. Шаг третий: сломайте Create нарочно — передайте имя тега, нарушающее уникальность, — и проверьте, что после отката в базе нет ни поста, ни связок; затем прогоните два горутинных Create с одним новым тегом и посмотрите, как гонка FirstOrCreate завершится ошибкой у одного. Шаг четвёртый: закройте чек-лист тестами — сабтест на каждую строку, фабрика makePost через репозиторий, база на тест по канону прошлого урока. Шаг пятый, на вкус: добавьте скоупу Draft панель черновиков автора — метод Drafts(authorID) в интерфейс и сабтест к нему; затем частично мигрируйте SoftDelete на Unscoped-админку «корзины» со списком спрятанных постов. Финал: поднимите настоящий Postgres, переключите gorm.Open одной строкой и запишите, что из проекта сдвинулось, а что осталось нетронутым — это и есть проверка двадцать третьего урока на практике.

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

### Можно ли разнести модели и репозиторий по разным пакетам?

Да, и для боевого проекта это норма: модели живут в доменном пакете без зависимостей на базу, репозиторий — в пакете вида blogrepo, который импортирует gorm и модели. Интерфейс PostRepo при этом объявляют рядом с потребителем — сервисом, а не рядом с реализацией: так сервис не тянет за собой gorm-пакет, а подмена на мок в тестах остаётся тривиальной.

### Почему Feed делает два запроса вместо одного?

Пагинации нужен total — общее число опубликованных постов, иначе клиент не нарисует страницы и не узнает, что дальше пусто. Count и Find — два разных запроса с одинаковыми условиями; делить одну цепочку опасно, поэтому их собирают независимо от r.db. Если список большой, счётчик кэшируют или считают по окну страниц — но это уже оптимизация поверх работающего контракта.

### Что делать с комментариями мягко удалённого поста?

Мягкое удаление — это UPDATE одной таблицы, каскада он не будит: комментарии и связки post_tags остаются. Рабочие варианты: ничего не делать, пока их никто не видит (страница поста недоступна), чистить их в той же транзакции, если политика требует, или закрывать дочерние записи пометкой в фоновой задаче. Единственный неверный ответ — не решить это вовсе и оставить поведение на волю случая.
