---
title: "Транзакции в ent"
description: "Транзакции ent: client.Tx и BeginTx, идиома defer tx.Rollback, хелпер WithTx, tx.Client для функций, вложенные Tx через savepoints и sql.TxOptions для изоляции."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/16-tranzakcii/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Транзакции в ent

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

Тринадцатый урок обещал: составные операции нужно завернуть в транзакции, четырнадцатый показал, что бывает, когда «дети, потом родитель» разъезжаются по разным коммитам. Сегодня — ent транзакции: один вызов client.Tx(ctx) даёт транзакционный клиент, сгенерированный ent/tx.go, с теми же билдерами User, Group, Post, но на одном соединении и одним судьёй — Commit или Rollback. Разберём контракт обоих методов и идиому defer, соберём хелпер WithTx, который делит код на «внутри транзакции» и «снаружи», передадим транзакцию чужому коду через tx.Client(), отрегулируем изоляцию через BeginTx и sql.TxOptions и заглянем в вложенные транзакции через savepoints. По канону курса: анна, борис, вера, группа go-ninja и счётчик posts_count из двенадцатого урока.

Маршрут урока — от первого client.Tx до вложенных savepoints. Сначала — зачем это нужно: сцена с постом и счётчиком из тринадцатого урока, где две отдельные мутации обязаны жить или умереть вместе; здесь же — что транзакция даёт и чего не обещает. Затем — механика: client.Tx(ctx), сгенерированный файл ent/tx.go и контракт Commit/Rollback, включая sql.ErrTxDone. Третья часть — дисциплина: идиома defer и хелпер WithTx, стандарт де-факто из документации ent. Четвёртая — интеграция: tx.Client() для функций, чьи подписи принимают *ent.Client, With-цепочки из одиннадцатого урока внутри транзакции и BeginTx с sql.TxOptions. Пятая — вложенные транзакции: tx.Tx(ctx) и savepoints, где подоперация может откатиться, не роняя внешнюю. Финиш — ошибки: ConstraintError из четырнадцатого урока становится штатной веткой, а «полуудалённое состояние» исчезает из вашего словаря. Если вы искали по запросу «ent tx» — всё это здесь.

### Зачем транзакции, когда ent уже «сам всё делает»

Восьмой урок заметил: мутация с рёбрами уже открывает транзакцию сама — внутренний mayTx оборачивает INSERT узла и вставки в join-таблицу одним блоком. Но эта автоматика покрывает ровно одну мутацию. Две отдельные мутации — пост и инкремент счётчика, удаление дружб и удаление пользователя, создание группы и вступление в неё — в транзакции не живут: между их Exec природа может вставить что угодно, от падения процесса до сетевого сбоя. Итог известен из четырнадцатого урока: пост есть, счётчик не сдвинулся; дружбы стёрты, пользователь жив — полуудалённое состояние, которое не чинится ретраем, потому что часть операций уже необратима. Транзакция на уровне SQL — это BEGIN, набор стейтментов и либо COMMIT, либо ROLLBACK: атомарность, консистентность и изоляция на стороне базы. ent транзакции не переизобретают это — они дают типобезопасный доступ: сгенерированный tx-клиент, те же билдеры, компилятор следит за тем, чтобы вы не позвали клиентские билдеры там, где открыта транзакция, — ну, почти следит, и о дыре в этом заборе — в конце урока.

### client.Tx: транзакционный клиент

```go
// пост + счётчик — атомарно:
tx, err := client.Tx(ctx)
if err != nil {
        return err
}

if _, err = tx.Post.Create().
        SetTitle("про рёбра").
        SetBody("...").
        SetAuthorID(1).
        Save(ctx); err != nil {
        return err // до Commit база не увидит ничего
}
if err = tx.User.UpdateOneID(1).
        AddPostsCount(1).
        Exec(ctx); err != nil {
        return err
}

return tx.Commit()
```

client.Tx(ctx) возвращает (*ent.Tx, error) — из сгенерированного ent/tx.go. У Tx те же поля: tx.Post, tx.User — и те же билдеры, что у client, но драйвер у них один: соединение, открытое для этой транзакции. AddPostsCount — тот же мутатор из тринадцатого урока; разница в окружении: обе мутации теперь либо зафиксируются вместе на Commit, либо исчезнут вместе при откате.

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

**Вопрос:** Что возвращает client.Tx(ctx) и чем tx.Post отличается от client.Post?

- ✅ *ent.Tx с теми же билдерами, но привязанными к одному соединению транзакции
- Копию *ent.Client — только чтобы стилистически писать tx вместо client
- Открытое sql.Tx из database/sql — без билдеров, вручную
- Функцию commit, которую надо вызвать после каждой мутации

*ent.Tx — клиент-двойник: поля User, Post, Group содержат те же сгенерированные билдеры, но их драйвер — транзакция на одном соединении, а не пул. Именно поэтому билдеры внутри tx «знают» друг друга судьбой: один COMMIT фиксирует всё, один ROLLBACK стирает. sql.Tx спрятан глубже, руками вы его не трогаете.

### Контракт Commit и Rollback

Два метода *ent.Tx решают судьбу транзакции, и у обоих контракты стоит знать наизусть. tx.Commit() возвращает error: успех — nil, и все мутации зафиксированы; ошибка может прийти и здесь — констрейнты с отложенной проверкой, обрыв соединения, конфликт изоляции. tx.Rollback() тоже возвращает error — и это главное источник путаницы: rollback после успешного Commit вернёт ошибку уже завершённой транзакции, из database/sql она приходит как sql.ErrTxDone. Это не баг и не паника: вы просите отменить то, чего уже нет. Обратный порядок безопаснее по данным: Rollback после неудачного Commit может вытащить соединение из комы, если часть стейтментов исполнилась, а фиксировать нечего. Ещё одно свойство, о котором забывают: транзакция держит соединение из пула. Забытый Tx без Commit и без Rollback — соединение, которое не вернётся в пул до таймаута; три таких утечки — и приложение стоит, хотя процесс здоров. Отсюда железное правило: у каждой транзакции либо Commit, либо Rollback обязаны быть исполнены — на любом пути, включая панику.

### Идиома: defer tx.Rollback

```go
tx, err := client.Tx(ctx)
if err != nil {
        return err
}
defer func() { _ = tx.Rollback() }()

// ... бизнес-код, любые return внутри ...

return tx.Commit()
```

Классическая идиома: defer с Rollback страхует все пути. На успешном пути Commit уже зафиксировал транзакцию, и отложенный Rollback вернёт sql.ErrTxDone — её осознанно глотают подчёркиванием. На любом ошибочном или паническом пути rollback сработает сам и вернёт соединение в пул. Заметьте: Commit здесь последний return — defer исполняется после него, но не мешает: ErrTxDone безвредна.

### Проверка: defer после Commit

**Вопрос:** Код: tx открыта, defer func() { _ = tx.Rollback() }(), бизнес-код прошёл, return tx.Commit() вернул nil. Что исполнилось и что вернулось?

- ✅ Commit зафиксировал данные; отложенный Rollback вернул sql.ErrTxDone — безвредно
- Rollback в defer отменил транзакцию — данные не сохранились
- Паника: нельзя вызывать Rollback после Commit
- Commit вернул ошибку, потому что транзакция уже была закрыта Rollback-ом

Порядок строгий: Commit исполняется в return, затем срабатывает defer — но транзакция уже завершена, и Rollback получает sql.ErrTxDone. Данные зафиксированы. Паники нет — это штатная ситуация, ради которой идиома и существует: она делает «забытую» транзакцию невозможной.

### WithTx: хелпер, который делит код на «внутри» и «снаружи»

Идиома с defer требует дисциплины в каждом месте: ошибку бизнес-кода надо не потерять, rollback не забыть, панику не упустить. Документация ent предлагает шаблон, который закрывает это раз и навсегда — хелпер WithTx. Он принимает клиент и функцию fn func(tx *ent.Tx) error; всё, что fn делает, живёт внутри транзакции, всё, что после её возврата, — снаружи. Возврат nil — Commit, возврат ошибки — Rollback с сохранением оригинальной ошибки: заметьте, как она оборачивается, если откат сам споткнулся, — ошибка мутации для вас ценнее ошибки отката, и терять её нельзя. Тот же принцип с паникой: recover поднимает её после отката — соединение вернётся в пул, а стектрейс останется честным. С таким хелпером ent транзакции становятся декларативными: читатель функции сразу видит границы атомарного блока, а не ищет парные Commit/Rollback по коду.

### WithTx: стандартный хелпер

```go
func WithTx(ctx context.Context, client *ent.Client,
        fn func(tx *ent.Tx) error) error {
        tx, err := client.Tx(ctx)
        if err != nil {
                return err
        }
        defer func() {
                if v := recover(); v != nil {
                        _ = tx.Rollback()
                        panic(v) // откатились — и подняли честно
                }
        }()
        if err := fn(tx); err != nil {
                if rerr := tx.Rollback(); rerr != nil {
                        // ошибка мутации важнее ошибки отката:
                        err = fmt.Errorf("откат: %v: %w", rerr, err)
                }
                return err
        }
        if err := tx.Commit(); err != nil {
                return err
        }
        return nil
}
```

Три ветки — успех (Commit), ошибка (Rollback + оригинальная ошибка наружу), паника (Rollback + repanic). Хелпер живёт в одном файле проекта и обслуживает все транзакции приложения. Это адаптация примера из документации ent: если в вашей версии исходники отличаются мелочами — порядок идей тот же.

### Что в базе после падения внутри WithTx

```go
err := WithTx(ctx, client, func(tx *ent.Tx) error {
        if _, err := tx.Post.Create().
                SetTitle("черновик").
                SetAuthorID(1).
                Save(ctx); err != nil {
                return err
        }
        return errors.New("feature flag выключен: выходим")
})
// анна (id 1) имела posts_count = 2. Что в базе после возврата?
```

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

- ✅ Постов по-прежнему два, posts_count = 2: мутация откатилась
- Пост «черновик» появился, posts_count = 2: первая мутация успела
- Пост появился и posts_count = 3: откат не трогает счётчик
- Постов два, но posts_count = 3: счётчик инкрементируется вне отката

Ошибка внутри fn — это Rollback всей транзакции: INSERT поста отменён, счётчик не тронут. Именно это и есть контракт: либо обе мутации, либо ни одна. Ошибка вернулась наружу как return errors.New — WithTx не глотает её, только докладывает откат.

### tx.Client() и BeginTx: интеграция с чужим кодом

Первый вопрос интеграции: что делать с функциями, чьи подписи принимают *ent.Client — сервисный слой из восьмого урока, репозитории, хелперы. Переписывать всё на *ent.Tx — долго и заразно. Ответ — tx.Client(): сгенерированный метод возвращает *ent.Client, чьи все билдеры привязаны к этой транзакции; передайте его в функцию — и её client.User.Query() исполнится внутри tx, а не на пуле. Внутри транзакции работают и With-цепочки из одиннадцатого урока: WithPosts, WithOwner — всё на том же соединении, консистентный снимок данных. Второй вопрос — изоляция. client.Tx(ctx) — сахар для BeginTx с нулевыми опциями; когда нужны особые уровни, вызывайте client.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) — уровень согласован с диалектом базы: Postgres и MySQL поддерживают Repeatable Read, SQLite в режимах гонок ограничен. Третий — ReadOnly: для чисто читающих задач транзакция только с чтением освобождает базу от лишних блокировок. ent транзакции здесь не добавляют своей магии — они честно пробрасывают database/sql.

### Транзакция внутри чужих функций и изоляция

```go
// функция ждёт *ent.Client — не переписываем:
if err := publishToFeed(ctx, tx.Client(), post); err != nil {
        return err // всё внутри publishToFeed — в этой же tx
}

// особая изоляция для отчёта по балансам:
tx, err := client.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelRepeatableRead,
})
```

tx.Client() — мост между tx-миром и клиентскими подписями: один вызов, и чужой код работает в вашей транзакции, не зная об этом. BeginTx с sql.TxOptions — единственный способ задать уровень изоляции и ReadOnly: у client.Tx таких параметров нет. Выбор уровня — тема базы, не ent: ent лишь гарантирует, что билдеры ничего не перепутают.

### Вложенные транзакции: tx.Tx и savepoints

Реальные операции состоят из обязательной и необязательной части: основное должно зафиксироваться любой ценой, а необязательное — пусть откатится отдельно, не роняя всё. SQL решает это savepoints: точка внутри транзакции, к которой можно откатиться частично. ent поддерживает вложенные транзакции через savepoints: у *ent.Tx есть метод Tx(ctx) — он возвращает новую *ent.Tx, чья Commit исполняет RELEASE SAVEPOINT, а Rollback — ROLLBACK TO SAVEPOINT. Внешняя транзакция продолжает жить: ошибка вложенного блока, если вы её обработали, не смертельна для основной. Это тот же контракт, что и у client.Tx, только точка отсчёта другая: не пул, а текущая транзакция. Поддержка диалектами хорошая: Postgres и MySQL/InnoDB умеют savepoints нативно, SQLite — тоже. Дисциплина остаётся прежней: вложенная транзакция — тот же WithTx-паттерн, только корнем служит tx, а не client. Глубину не зарывайте: savepoint в savepoint — уже запутанный след в логе базы, и не каждая операция это заслуживает.

### WithNestedTx: savepoint по-человечески

```go
func WithNestedTx(ctx context.Context, rootTx *ent.Tx,
        fn func(tx *ent.Tx) error) error {
        tx, err := rootTx.Tx(ctx) // savepoint внутри внешней
        if err != nil {
                return err
        }
        if err := fn(tx); err != nil {
                if rerr := tx.Rollback(); rerr != nil {
                        err = fmt.Errorf("откат вложенной: %v: %w", rerr, err)
                }
                return err // внешняя решает, что с этим делать
        }
        return tx.Commit() // RELEASE SAVEPOINT
}

// использование: основное — обязательно, аватар — пусть откатится:
return WithTx(ctx, client, func(tx *ent.Tx) error {
        if _, err := tx.Group.Create().SetName("go-ent").Save(ctx); err != nil {
                return err
        }
        err := WithNestedTx(ctx, tx, func(ntx *ent.Tx) error {
                return makeAvatar(ctx, ntx.Client()) // может упасть
        })
        if err != nil {
                log.Printf("аватар не удался, группа жива: %v", err)
        }
        return nil
})
```

rootTx.Tx(ctx) открывает savepoint; Commit вложенной — RELEASE, Rollback — ROLLBACK TO. Ошибка makeAvatar проглочена логом: группа go-ent создана и зафиксируется, аватар — нет. Это точный ответ на «необязательная часть не должна ронять обязательную» без второго соединения и без переписывания бизнес-кода.

### Проверка: судьба внешней транзакции

**Вопрос:** Внешняя транзакция создала группу. Вложенный savepoint упал, ошибку перехватили логом. Что окажется в базе после внешнего Commit?

- ✅ Группа зафиксирована; изменения вложенного блока — нет
- Ничего: падение вложенной транзакции откатывает внешнюю
- Группа и частичные изменения вложенного блока — savepoint фиксирует частично
- Commit внешней вернёт ошибку: транзакция испорчена savepoint-ом

Смысл savepoint — частичный откат: ROLLBACK TO SAVEPOINT стирает только то, что было после точки. Обработанная ошибка вложенного блока не портит внешнюю: COMMIT проходит, группа жива. Необработанная — ваша ответственность: если ошибка всплывёт до Commit внешней, откатится уже всё.

### Ошибки внутри транзакции: ConstraintError становится штатной

Четырнадцатый урок кончался обещанием: пара «дети, потом родитель» обязана быть атомарной. Теперь это один блок: Delete по дружбам и DeleteOneID пользователя — внутри WithTx; ConstraintError, если что-то ссылается, останавливает блок до второго DELETE, и Rollback стирает след первого. Полуудалённого состояния больше не существует — в логе либо оба DELETE и COMMIT, либо первый DELETE и ROLLBACK. То же с ent.IsConstraintError и ent.IsNotFound: они ловятся как обычно, но теперь означают «откат + решение», а не «частичный успех». Отдельная честность: ошибки базы в транзакции бывают неконтролируемыми — после грубого нарушения (deadlock, серийный конфликт) база может сама откатить транзакцию, и дальнейшие стейтменты бесполезны; в этом случае ретрай — это новый WithTx с самого начала, а не продолжение старого. И ещё одна дыра забора, обещанная в начале: билдеры client.Post всё ещё доступны внутри WithTx — компилятор не запретит вам по ошибке вызвать клиентский билдер и уехать мимо транзакции на пул. Дисциплина простая: внутри fn(tx) используйте только tx.*, а ловлю мимо-транзакционных вызовов отдайте код-ревью и статическому анализу.

### «Дети, потом родитель» — атомарно

```go
err := WithTx(ctx, client, func(tx *ent.Tx) error {
        // дети: дружбы через through-таблицу из шестого урока
        if _, err := tx.Friendship.Delete().
                Where(friendship.HasUserWith(user.ID(1))).
                Exec(ctx); err != nil {
                return err
        }
        // родитель: если FK откажет — откатится всё
        return tx.User.DeleteOneID(1).Exec(ctx)
})
if ent.IsConstraintError(err) {
        // кто-то ещё держит строку: карта рёбер из пятого урока
}
```

Тот же порядок, что в четырнадцатом уроке, но теперь разрыв невозможен: ConstraintError на втором шаге откатывает первый. ent.IsConstraintError ловится снаружи — это сигнал карты зависимостей, а не 500. Внутри fn(tx) только tx-билдеры: Friendship и User из транзакционного клиента.

### Чеклист транзакций

- Граница транзакции — граница бизнес-операции, не HTTP-запроса: держать tx открытым на время походов в сеть и ответов пользователю — способ связать весь пул в узел.
- WithTx в проекте один, его шаблон не переизобретают на месте: успех — Commit, ошибка — Rollback с оригинальной ошибкой, паника — Rollback + repanic.
- defer tx.Rollback — страховка каждого ручного client.Tx без WithTx: sql.ErrTxDone после успешного Commit — норма, а не баг.
- Внутри fn(tx) — только tx.*: tx.Client() для чужих подписей, client.Post внутри транзакции — способ тихо уехать мимо неё.
- Счётчики и инкременты — Add-мутаторами из тринадцатого урока и в той же транзакции, что и сама мутация: posts_count обязан догонять посты атомарно.
- ConstraintError и NotFoundError — штатные ветки: откат, лог карты зависимостей, решение; ретрай — новый WithTx с нуля, а не продолжение мёртвой транзакции.
- Внешние сайд-эффекты — после Commit: письмо и очередь, отправленные внутри транзакции, уйдут зря при откате; паттерн outbox-таблицы, записанной в той же tx, разберём в семнадцатом.

### Карта случаев: что и как оборачивать

| Сценарий | Инструмент | Что атомарно |
| --- | --- | --- |
| Пост + инкремент posts_count | WithTx: tx.Post.Create + AddPostsCount | обе мутации вместе |
| Дети, потом родитель (14-й урок) | WithTx: два Delete в fn | весь каскад или ничего |
| Мутация с рёбрами (AddGroupIDs) | обычный Save — ent сам открывает mayTx | вставка узла + join-строки |
| Чужая функция с *ent.Client | tx.Client() как аргумент | всё, что она делает |
| Необязательная подоперация | WithNestedTx: tx.Tx(ctx) → savepoint | вложенный блок отдельно |
| Особый уровень изоляции | client.BeginTx + sql.TxOptions | вся транзакция по правилам уровня |
| Ручной client.Tx без хелпера | defer tx.Rollback + явный Commit | любой путь выхода |
| Массовый upsert из восьмого | CreateBulk + OnConflict внутри WithTx | весь каталог целиком |
| Паника в бизнес-коде | recover в WithTx: Rollback + repanic | соединение вернётся в пул |
| Аудит-лог той же судьбой | хук с m.Tx() — семнадцатый урок | мутация + запись аудита |

> **Как проверять себя в этом уроке.** Runner-ов в курсе ent нет, и песочница не появится и здесь: транзакциям нужны настоящий драйвер и база — правило всего курса. Возьмите базу из домашек восьмого урока, включите лог SQL и запустите WithTx из этого урока: в логе увидите BEGIN, стейтменты, COMMIT. Затем верните errors.New из середины fn и увидите ROLLBACK — и ноль изменений в таблицах. Вложенную транзакцию проверьте по логу на Postgres или MySQL: SAVEPOINT, падение, ROLLBACK TO SAVEPOINT, внешние INSERT и COMMIT. Финальный тест — параллельность: два WithTx одновременно создают пост и двигают posts_count; после сотни итераций счётчик обязан совпасть с числом постов.

### Соберите каркас транзакции

Впишите открытие транзакции, страховку отката и фиксацию.

```go
tx, err := client.___(ctx)
if err != nil {
        return err
}
defer func() { _ = tx.___() }()

// ... мутации на tx.Post и tx.User ...

return tx.___()
```

Подсказка: Первый пропуск — метод клиента, открывающий транзакцию; второй — метод, страхующий любой путь выхода (после успешного третьего вернёт sql.ErrTxDone); третий — метод фиксации.

Правильные ответы в пропусках: `Tx`, `Rollback`, `Commit`

### Жизнь одной транзакции

Соберите по порядку всё, что происходит с WithTx от вызова до возврата.

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

1. `client.Tx(ctx) — *ent.Tx на одном соединении, SQL-лог открывает BEGIN`
2. `fn(tx): билдеры tx.Post и tx.User копят стейтменты и исполняют их в транзакции`
3. `ошибка в fn → WithTx вызывает tx.Rollback() — ROLLBACK в логе, изменений нет`
4. `успех в fn → WithTx вызывает tx.Commit() — COMMIT, обе мутации зафиксированы`
5. `defer после Commit получает sql.ErrTxDone и осознанно глотает её`
6. `ошибка мутации возвращается наружу без потерь: ConstraintError ловится снаружи`

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

Вы умеете строить атомарные операции: client.Tx и BeginTx с sql.TxOptions, контракт Commit и Rollback с sql.ErrTxDone, идиому defer и хелпер WithTx, tx.Client() для чужого кода, вложенные транзакции через savepoints — и ошибки, которые больше не оставляют полуудалённых состояний. Транзакция — это контейнер; семнадцатый урок покажет, кто исполняется внутри него рядом с вашими мутациями: хуки перехватывают каждую Create/Update/Delete до записи, могут валидировать, переписывать поля и писать аудит-лог в ту же транзакцию — счётчик posts_count наконец начнёт двигаться сам. Домашнее задание ниже заставит транзакцию провалиться красиво и доказать по логу, что база этого не заметила. Полный план из 25 уроков — на [странице курса](/courses/ent/), счётчик — в [тринадцатом уроке](/courses/ent/lessons/13-update/), жадная загрузка внутри tx — в [одиннадцатом](/courses/ent/lessons/11-with/), источник пары «дети, потом родитель» — в [четырнадцатом](/courses/ent/lessons/14-delete/).

### Карточки: транзакции

- **client.Tx: контракт** — client.Tx(ctx) → (*ent.Tx, error) из ent/tx.go: те же билдеры User/Post/Group, но драйвер — одно соединение. tx.Commit() фиксирует всё, tx.Rollback() стирает; оба возвращают error. client.Tx — сахар для BeginTx(ctx, nil).
- **defer tx.Rollback** — Страховка любого пути: после успешного Commit отложенный Rollback вернёт sql.ErrTxDone — её глотают подчёркиванием; на ошибке или панике rollback вернёт соединение в пул и отменит изменения. Забытая транзакция = соединение, не вернувшееся в пул.
- **WithTx: зачем и как** — Хелпер из документации: fn func(tx *ent.Tx) error; nil → Commit, ошибка → Rollback с сохранением оригинальной ошибки, паника → Rollback + repanic. Делит код на «внутри транзакции» и «снаружи», делает забытую пару Commit/Rollback невозможной.
- **tx.Client() и BeginTx** — tx.Client() возвращает *ent.Client, привязанный к транзакции, — мост к функциям с клиентскими подписями; With-цепочки внутри tx работают на том же соединении. BeginTx(ctx, &sql.TxOptions{Isolation: ...}) — способ задать изоляцию и ReadOnly; client.Tx опций не принимает.
- **Вложенные транзакции** — tx.Tx(ctx) открывает savepoint внутри внешней транзакции: Commit вложенной = RELEASE SAVEPOINT, Rollback = ROLLBACK TO. Обработанная ошибка вложенного блока не роняет внешнюю — «необязательная часть откатилась отдельно». Поддерживают Postgres, MySQL/InnoDB, SQLite.
- **Ошибки внутри транзакции** — ent.IsConstraintError и ent.IsNotFound ловятся как обычно, но означают «откат и решение», а не частичный успех. После deadlock/серийного конфликта база может откатить сама — ретрай это новый WithTx с нуля. Внутри fn(tx) только tx.*: клиентский билдер уедет мимо транзакции на пул.

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

- ent транзакции начинаются с client.Tx(ctx) → (*ent.Tx, error): сгенерированный tx-клиент из ent/tx.go с теми же билдерами User/Post/Group, но на одном соединении; Commit и Rollback — единственные два исхода.
- Идиома defer tx.Rollback делает забытую транзакцию невозможной: после успешного Commit она вернёт sql.ErrTxDone — штатно и безвредно; на ошибке и панике — отменит изменения и вернёт соединение в пул.
- WithTx — стандартный хелпер: fn func(tx *ent.Tx) error, nil → Commit, ошибка → Rollback с сохранением оригинальной ошибки, паника → Rollback + repanic; границы атомарного блока видны в сигнатуре.
- tx.Client() возвращает *ent.Client, привязанный к транзакции, — чужой код с клиентскими подписями работает внутри tx; With-цепочки из одиннадцатого урока исполняются на том же соединении.
- Изоляция задаётся только через client.BeginTx(ctx, &sql.TxOptions{Isolation: ...}) — client.Tx параметров не принимает; ReadOnly и уровни изоляции — ответственность базы, ent честно пробрасывает database/sql.
- Вложенные транзакции — tx.Tx(ctx): savepoint, где Commit = RELEASE, Rollback = ROLLBACK TO; обработанная ошибка вложенного блока не роняет внешнюю транзакцию.
- Ошибка внутри tx отменяет всё: ConstraintError из четырнадцатого урока становится штатной веткой, полуудалённое состояние исчезает; после deadlock ретрай — новый WithTx с нуля.
- Дисциплина: внутри fn(tx) только tx.* (tx.Client() для чужих подписей), внешние сайд-эффекты после Commit, счётчики Add-мутаторами в той же транзакции.

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

Работайте на базе из восьмого урока — анна, борис, вера, их посты и группа go-ninja — с включённым логом SQL драйвера. Первое: соберите WithTx из урока и проведите через него пару «пост + AddPostsCount(1)»: найдите в логе BEGIN и COMMIT, затем верните ошибку из середины fn и найдите ROLLBACK; после каждого прогона проверяйте SELECT-ом, что posts_count и число постов согласованы. Второе: смоделируйте «полуудалённое состояние» без транзакции — отдельные Exec на удаление дружб и пользователя с падением посередине — и письменно опишите, что зашилось в базе; затем повторите то же внутри WithTx и убедитесь, что база вернулась к исходному. Третье: соберите WithNestedTx на tx.Tx(ctx): основная часть создаёт группу и добавляет участника, вложенная «рисует аватар» и падает — по логу найдите SAVEPOINT и ROLLBACK TO SAVEPOINT и докажите, что группа зафиксировалась. Четвёртое: откройте BeginTx с sql.TxOptions и попробуйте два уровня изоляции на гонке «две горутины двигают posts_count по сотне итераций» — сравните итоговые числа с ожидаемыми и опишите, что изменил уровень. Пятое: передайте tx.Client() в функцию с подписью *ent.Client и с помощью лога докажите, что её запросы исполнились внутри транзакции; затем по ошибке вызовите внутри fn клиентский билдер client.Post — зафиксируйте в комменте, какой след в логе выдаёт утечку мимо транзакции. Вопрос на подумать: почему сайд-эффект во внешнюю систему (письмо, HTTP-вызов) внутри WithTx — это баг, даже если мутации пройдут без ошибок, и как из него корректно выйти?

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

### client.Tx или client.BeginTx — что использовать?

По умолчанию client.Tx(ctx) — сахар над BeginTx с нулевыми опциями. BeginTx нужен, когда вы хотите задать sql.TxOptions: уровень изоляции (LevelRepeatableRead и другие) или ReadOnly. API билдеров у них одинаковый: оба возвращают *ent.Tx, и весь остальной урок применим без изменений.

### Можно ли держать транзакцию открытым весь HTTP-запрос?

Технически — да, tx.Client() в начале запроса и defer Commit в конце. Практически — не надо: транзакция держит соединение из пула, и пара десятков параллельных запросов вычерпают пул, а любое ожидание внешней системы растянет транзакцию на секунды. Граница транзакции — граница бизнес-операции: короткий блок мутаций, снаружи — чтения и сайд-эффекты.

### Почему Rollback после Commit не паникует и что делать с ошибкой?

Потому что запрос «отменить уже завершённую транзакцию» — штатная ситуация для идиомы defer: database/sql отвечает sql.ErrTxDone, и её осознанно глотают подчёркиванием. Паника была бы вредительством: страховка должна быть бесплатной. Если Rollback спотыкается на живой транзакции — это уже информация о сбое соединения, и WithTx оборачивает её вместе с оригинальной ошибкой мутации.
