golang

Транзакции в БД на Go с использованием многослойной архитектуры

  • понедельник, 7 октября 2024 г. в 00:00:09
https://habr.com/ru/articles/848596/

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

Однажды, я столкнулся с инцидентом на проде и обратился за помощью к самому опытному инженеру. Он пришел на помощь и с легкостью изменил значение в БД с помощью... ручного обновления. 🤯 Проблема заключалась в том, что набор SQL-обновлений не был выполнен внутри транзакции.

Работа в новой компании — это всегда увлекательно. Я осознал, что даже если какой-то аспект кажется простым, например, SQL-транзакции, его легко упустить из виду.

SQL кажется чем-то, что мы все хорошо знаем, и мало чем может удивить. (Ему уже 50 лет!) Возможно, пришло время пересмотреть подходы, так как мы уже прошли фазу хайпа по поводу NoSQL, и снова возвращаемся к “используйте просто Postgres”, а иногда и к “SQLite тут за глаза”.

Я хочу сосредоточиться на том, как правильно применять транзакции в коде, а не на их технической сложности. Когда ваш проект становится больше, вы начинаете разделять логику и код базы данных с помощью слоев. Однако это не всегда так просто, как кажется. Вы можете запутаться и столкнуться с неочевидными ошибками.

Основной принцип многослойной архитектуры заключается в разделении критически важных частей кода (логики) от деталей реализации (например, SQL-запросов). Одним из способов достижения такого разделения является паттерн «Репозиторий». Однако, наиболее сложным аспектом такой архитектуры является обработка транзакций.

Когда мы используем транзакции, обычно происходит следующее: сначала мы получаем данные из базы, затем выполняем необходимую логику, обновляем данные и коммитим транзакцию.

Как же организовать код таким образом, чтобы избежать путаницы между слоями? Я часто вижу, как люди задаются этим вопросом, и мне знакома эта боль. Мы сами искали решение, и со временем наши подходы менялись. Вот список различных методов, которые можно рассмотреть, и всё, что я узнал по этому поводу.

Основы SQL-транзакций

Давайте быстро повторим, зачем вообще нужны транзакции.

Представим веб-приложение для  e-commerce, где пользователи получают виртуальные баллы за покупку товаров. Эти баллы можно использовать для получения скидок в будущем.

Пользователь решает, сколько баллов потратить на скидку. Мы должны проверить, достаточно ли у пользователя баллов, затем уменьшить их количество и применить соответствующую скидку к следующему заказу.

Предположим, пользователь хочет потратить 100 баллов. Это может выглядеть примерно так:

SELECT points FROM users WHERE id = 19;
-- в коде: проверяем, что points >= 100
UPDATE users SET points = points - 100 WHERE id = 19;
UPDATE user_discounts SET next_order_discount = next_order_discount + 100 WHERE user_id = 19;

Теперь подумаем, что может пойти не так, даже если это маловероятно.

Приложение может неожиданно завершить работу после выполнения первого оператора UPDATE. Или может произойти сбой сети, который не даст выполнить второй UPDATE. В таком случае мы бы уменьшили количество баллов пользователя, но не дали ему скидку.

Баллы списали, но скидку не начислили
Баллы списали, но скидку не начислили

Или другой сценарий: если пользователь отправит два (или больше) запроса очень быстро, они могут быть обработаны параллельно. Если оба запроса выполнят команду SELECT до любых обновлений, проверка баланса баллов не сработает правильно, и пользователь может получить -100 баллов и скидку в 200.

Проблема параллельных запросов: два одновременных SELECT приводят к некорректному обновлению баланса и скидок.
Проблема параллельных запросов: два одновременных SELECT приводят к некорректному обновлению баланса и скидок.

Анти-паттерн: Игнорирование транзакций

Используйте транзакции для набора запросов, которые зависят друг от друга.

Даже если это маловероятно, что-то может пойти не так во время запроса. В результате вы получите несоответствия в системе, которые придется вручную разбираться и исправлять.

Если что-то может теоретически произойти, оно, скорее всего, произойдет в продакшене. Не принимайте решения, которые “почти всегда” работают. Те самые 1% могут обернуться ночным кошмаром с расследованиями, когда непонятно, что произошло в вашем приложении и как это исправить.

Мы можем легко улучшить ситуацию, введя транзакцию:

BEGIN;
SELECT points FROM users WHERE id = 19 FOR UPDATE;
-- в коде: проверяем, что points >= 100
UPDATE users SET points = points - 100 WHERE id = 19;
UPDATE user_discounts SET next_order_discount = next_order_discount + 100 WHERE user_id = 19;
COMMIT;

Теперь либо все изменения сохраняются, либо ничего.

Кроме операторов BEGIN и COMMIT, которые управляют транзакцией, обратите внимание на добавленный оператор FOR UPDATE в SELECT. Он блокирует строку, чтобы другие SELECT запросы ждали завершения транзакции. Это позволяет правильно обрабатывать параллельные запросы.

Предупреждение

FOR UPDATE хорошо работает для простых случаев, но может вызвать проблемы с производительностью. Он блокирует всю строку, и если несколько параллельных обновлений конкурируют за нее, им всем придется ждать. В большем масштабе это может стать узким местом.

Альтернативой может быть использование другого уровня изоляции, например, REPEATABLE READ. Это выходит за рамки этой статьи, но вы можете ознакомиться с дополнительными материалами по этой теме.

Что бы вы ни выбрали, обязательно проводите нагрузочные тесты, чтобы увидеть, как ваш код работает под нагрузкой. Это поможет избежать неприятных сюрпризов в продакшене, когда резко возрастет трафик.

Слои

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

В оставшейся части статьи я предполагаю, что ваш SQL-код находится в отдельной структуре, файле или пакете, отдельно от логики приложения, и вы инжектируете одно в другое. В примерах кода я для простоты разделяю “слои” в разных файлах, но в одном пакете.

http.go — HTTP-обработчик (мы не будем заострять внимание на этом).

app.go — логика приложения.

repository.go — репозиторий (хранилище данных).

три слоя счастья
три слоя счастья

В логике приложения я определяю команду UsePointsAsDiscount и обработчик для неё (подробнее см. статью Роберта о CQRS). Вы можете предпочесть что-то немного другое, например, структуру сервиса с методами или use cases. Всё это нормально. Главное, что эта часть кода ничего не знает о базе данных. Репозиторий инжектируется в командный обработчик через интерфейс, определенный рядом с самим обработчиком.

Вот как может выглядеть код без транзакций:

// UsePointsAsDiscount описывает команду, в которой пользователь тратит свои баллы на скидку.

type UsePointsAsDiscount struct {
	UserID int // UserID — это ID пользователя
	Points int // Points — количество баллов для списания.
}

// UsePointsAsDiscountHandler отвечает за обработку команды UsePointsAsDiscount.
// Он взаимодействует с двумя репозиториями: userRepository для пользователей и discountRepository для скидок.
type UsePointsAsDiscountHandler struct {
	userRepository     UserRepository
	discountRepository DiscountRepository
}

// UserRepository описывает интерфейс для работы с пользователями.
// GetPoints возвращает количество баллов у пользователя по его ID.
// TakePoints уменьшает количество баллов у пользователя.
type UserRepository interface {
	GetPoints(ctx context.Context, userID int) (int, error)
	TakePoints(ctx context.Context, userID int, points int) error
}

// DiscountRepository описывает интерфейс для работы со скидками.
// AddDiscount увеличивает скидку пользователя для следующего заказа.
type DiscountRepository interface {
	AddDiscount(ctx context.Context, userID int, discount int) error
}

// NewUsePointsAsDiscountHandler создает новый UsePointsAsDiscountHandler,
// принимая два репозитория: userRepository для работы с пользователями и
// discountRepository для работы со скидками.
func NewUsePointsAsDiscountHandler(
	userRepository UserRepository,
	discountRepository DiscountRepository,
) UsePointsAsDiscountHandler {
	return UsePointsAsDiscountHandler{
		userRepository:     userRepository,
		discountRepository: discountRepository,
	}
}

// Handle обрабатывает команду UsePointsAsDiscount.
// Проверяет, что количество баллов положительное, что у пользователя достаточно баллов,
// списывает баллы и добавляет соответствующую скидку.
func (h UsePointsAsDiscountHandler) Handle(ctx context.Context, cmd UsePointsAsDiscount) error {
	// Валидация: количество баллов должно быть больше 0
	if cmd.Points <= 0 {
		return errors.New("points must be greater than 0")
	}

	// Получаем текущее количество баллов пользователя
	currentPoints, err := h.userRepository.GetPoints(ctx, cmd.UserID)
	if err != nil {
		return fmt.Errorf("could not get points: %w", err)
	}

	// Проверяем, что у пользователя достаточно баллов
	if currentPoints < cmd.Points {
		return errors.New("not enough points")
	}

	// Списываем баллы у пользователя
	err = h.userRepository.TakePoints(ctx, cmd.UserID, cmd.Points)
	if err != nil {
		return fmt.Errorf("could not take points: %w", err)
	}

	// Добавляем скидку пользователю
	err = h.discountRepository.AddDiscount(ctx, cmd.UserID, cmd.Points)
	if err != nil {
		return fmt.Errorf("could not add discount: %w", err)
	}

	// Если всё прошло успешно, возвращаем nil, что означает отсутствие ошибок
	return nil
}

Есть два репозитория: один для пользователей, другой для скидок. Логика обработчика довольно проста: мы проверяем команду (баллы должны быть положительными), проверяем, достаточно ли баллов у пользователя, вычитаем их из его аккаунта и добавляем скидку.

Репозитории выглядят так:

// PostgresDiscountRepository — реализация DiscountRepository для работы с PostgreSQL.
// Он хранит ссылку на соединение с базой данных.
type PostgresDiscountRepository struct {
	db *sql.DB
}

// NewPostgresDiscountRepository создаёт новый экземпляр PostgresDiscountRepository,
// принимая соединение с базой данных в качестве аргумента.
func NewPostgresDiscountRepository(db *sql.DB) *PostgresDiscountRepository {
	return &PostgresDiscountRepository{
		db: db,
	}
}

// AddDiscount увеличивает скидку пользователя в базе данных.
// Она выполняет SQL-запрос для обновления поля скидки для следующего заказа.
func (r *PostgresDiscountRepository) AddDiscount(ctx context.Context, userID int, discount int) error {
	// Выполняем SQL-запрос для увеличения скидки на следующую покупку
	_, err := r.db.ExecContext(ctx, "UPDATE user_discounts SET next_order_discount = next_order_discount + $1 WHERE user_id = $2", discount, userID)
	// Возвращаем ошибку, если что-то пошло не так
	return err
}

Мы бы хотели, чтобы все это выполнялось в рамках одной транзакции, но у нас есть куча логики, смешанной с двумя репозиториями. Давайте посмотрим, как к этому подойти.

Последовательность выполнения операций в слоистой архитектуре без транзакций: проверка баллов, уменьшение баллов и добавление скидки выполняются без гарантии атомарности.
Последовательность выполнения операций в слоистой архитектуре без транзакций: проверка баллов, уменьшение баллов и добавление скидки выполняются без гарантии атомарности.

Все примеры доступны в репозитории go-web-app-anti patterns. Существует Docker Compose, который позволяет запускать их все локально. В качестве базы данных используется Postgres.

Транзакции в слое логики (избегайте, если можете)

Первое решение для работы с транзакциями через репозитории — это передача объекта транзакции по всему коду.

Для выполнения кода в транзакции мы можем использовать вспомогательную функцию, такую как runInTx

func runInTx(db *sql.DB, fn func(tx *sql.Tx) error) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}

	err = fn(tx)
	if err == nil {
		return tx.Commit()
	}

	rollbackErr := tx.Rollback()
	if rollbackErr != nil {
		return errors.Join(err, rollbackErr)
	}

	return err
}

Эта функция принимает другую функцию (вы увидите много анонимных функций в этой статье), которая получает объект транзакции. Что бы ни происходило внутри функции fn, она не знает и не заботится о том, как транзакция запускается, фиксируется или откатывается.

Вот как это работает внутри хендлера:

func (h UsePointsAsDiscountHandler) Handle(ctx context.Context, cmd UsePointsAsDiscount) error {
	return runInTx(h.db, func(tx *sql.Tx) error {
		if cmd.Points <= 0 {
			return errors.New("points must be greater than 0")
		}

		currentPoints, err := h.userRepository.GetPoints(ctx, tx, cmd.UserID)
		if err != nil {
			return fmt.Errorf("could not get points: %w", err)
		}

		if currentPoints < cmd.Points {
			return errors.New("not enough points")
		}

		err = h.userRepository.TakePoints(ctx, tx, cmd.UserID, cmd.Points)
		if err != nil {
			return fmt.Errorf("could not take points: %w", err)
		}

		err = h.discountRepository.AddDiscount(ctx, tx, cmd.UserID, cmd.Points)
		if err != nil {
			return fmt.Errorf("could not add discount: %w", err)
		}

		return nil
	})
}

Хорошая новость в том, что этот подход работает. Но нельзя игнорировать тот факт, что он смешивает логику приложения с деталями реализации (SQL-транзакцией). На первый взгляд, это может не казаться серьезной проблемой: мы, вероятно, не будем менять базу данных, так что зачем беспокоиться?

Во-первых, управление транзакцией с последовательностью коммит/откат усложняет поток кода. Вместо работы с чистой логикой приложения вам нужно учитывать дополнительное поведение, которое не имеет прямого отношения к тому, что делает ваше приложение. По мере роста логики вы должны тщательно обдумывать, что должно выполняться внутри транзакции, а что — вне её. Откат повлияет на всё, что вы поместите внутрь функции.

Не говоря уже о странном аргументе tx, который нужно передавать в методы репозитория. Тестирование становится неудобным, потому что нужно передавать транзакцию даже тогда, когда метод её не требует (метод GetPoints может работать без транзакции в другом контексте). Вы не можете мокировать SQL-соединение для тестирования командного обработчика, поэтому это становится интеграционным тестом, а не простым юнит-тестом, проверяющим логику.

Можно попытаться обойти это с помощью абстрактного объекта транзакции или, возможно, передавать его через Context, но это только маскирует корневую проблему смешения деталей реализации с логикой.

Анти-паттерн: Транзакции смешаны с логикой

Избегайте смешивания транзакций с логикой приложения. Это усложняет понимание работы кода, тестирование логики и отладку проблем.

Последовательность выполнения операций с транзакциями в слое логики: начало транзакции, проверка баллов, обновление данных в репозиториях и фиксация или откат транзакции в зависимости от результата.
Последовательность выполнения операций с транзакциями в слое логики: начало транзакции, проверка баллов, обновление данных в репозиториях и фиксация или откат транзакции в зависимости от результата.

Транзакции внутри репозитория (лучше, но далеко не идеально)

Если транзакция принадлежит слою базы данных, почему бы не оставить её там? Сложность заключается в том, что мы работаем с двумя репозиториями, и им нужно как-то разделить объект транзакции. Иногда это действительно необходимо (и я покажу, как это сделать чуть позже). Но сначала стоит задуматься, нужны ли нам два репозитория.

Часто мы рассматриваем таблицы базы данных как сущности. В результате у нас появляется один репозиторий на каждую таблицу, и логика приложения управляет их взаимодействием. Часто такое разделение репозиториев не приносит практической пользы.

Table Driven Design
Table Driven Design

Анти-паттерн: Один репозиторий на каждую таблицу базы данных

Не создавайте репозиторий для каждой таблицы базы данных. Вместо этого думайте о данных, которые нужно хранить транзакционно вместе.

Данные, которые должны быть транзакционно согласованными, должны также быть когерентными и храниться как одно целое. Domain-Driven Design предлагает идею агрегата — набора данных, которые всегда должны быть согласованными. Следуя этой идее, вы создаете репозиторий не для каждой SQL-таблицы, а для каждого агрегата. (Это краткое изложение всей идеи. Скоро у нас выйдет полноценная статья об агрегатах.)

Мы рассматриваем пользователей и скидки как отдельные концепции (сущности, структуры и т. д.). И это логично, так как концептуально это разные вещи. Пользователь используется для идентификации и аутентификации. Размещение заказов со скидками — это лишь одна из множества вещей, которые может делать пользователь на сайте. Но мы также хотим поддерживать согласованность баллов и скидок пользователя. Мы можем рассматривать их как часть одного агрегата — набора объектов, которые хранятся вместе транзакционно.

На практике можно считать скидки частью агрегата пользователя, даже если они хранятся в другой таблице SQL. В таком случае нам нужен только один репозиторий. И это позволяет нам перенести обработку транзакции туда.

Теперь логика приложения становится тривиальной.

func (h UsePointsAsDiscountHandler) Handle(ctx context.Context, cmd UsePointsAsDiscount) error {
	if cmd.Points <= 0 {
		return errors.New("points must be greater than 0")
	}

	err := h.userRepository.UsePointsForDiscount(ctx, cmd.UserID, cmd.Points)
	if err != nil {
		return fmt.Errorf("could not use points as discount: %w", err)
	}

	return nil
}

А репозиторий предоставляет метод, специфичный для этой операции.

func (r *PostgresUserRepository) UsePointsForDiscount(ctx context.Context, userID int, points int) error {
	return runInTx(r.db, func(tx *sql.Tx) error {
		row := tx.QueryRowContext(ctx, "SELECT points FROM users WHERE id = $1 FOR UPDATE", userID)

		var currentPoints int
		err := row.Scan(&currentPoints)
		if err != nil {
			return err
		}

		if currentPoints < points {
			return errors.New("not enough points")
		}

		_, err = tx.ExecContext(ctx, "UPDATE users SET points = points - $1 WHERE id = $2", points, userID)
		if err != nil {
			return err
		}

		_, err = tx.ExecContext(ctx, "UPDATE user_discounts SET next_order_discount = next_order_discount + $1 WHERE user_id = $2", points, userID)
		if err != nil {
			return err
		}

		return nil
	})
}

Тактика: Агрегаты

Храните данные, которые должны быть строго согласованными, в одном агрегате. Каждому агрегату по репозиторию!

Транзакции в репозитории
Транзакции в репозитории

Этот подход всё ещё имеет некоторые недостатки. Один из них — необходимость перемещения логики проверки баллов (“достаточно ли баллов”) в репозиторий. Название метода (UsePointsForDiscount) делает очевидным, что репозиторий знает что-то о логике, и иногда это может быть допустимо. Но в идеале эта логика должна находиться в обработчике.

Другой проблемой является то, что с ростом количества таких методов интерфейс репозитория становится трудно поддерживать и тестировать. Это противоречит тому разделению слоев, которого мы хотели достичь.

Хотя этот подход полезен, он плохо масштабируется. Кажется, что нам нужно более универсальное решение с методом обновления.

Паттерн UpdateFn (наше основное решение)

Универсальный метод обновления должен загружать пользователя из базы данных, позволять вносить любые изменения и сохранять результат. Проблема снова заключается в том, как сделать это с использованием транзакций.

Во-первых, нам нужна модель, которая объединяет пользователя и скидки (наш агрегат). Я использую инкапсуляцию для скрытия полей, поскольку мы хотим, чтобы модель всегда была валидной в памяти. Единственный способ изменить состояние — это вызвать экспортированные методы.

// User представляет пользователя с его уникальным ID, email, баллами (points) и скидками (discounts).
type User struct {
	id        int        // уникальный идентификатор пользователя
	email     string     // email пользователя
	points    int        // количество баллов пользователя, которые он может потратить
	discounts *Discounts // информация о скидках пользователя
}

// ID возвращает уникальный идентификатор пользователя.
func (u *User) ID() int {
	return u.id
}

// Email возвращает email пользователя.
func (u *User) Email() string {
	return u.email
}

// Points возвращает текущее количество баллов пользователя.
func (u *User) Points() int {
	return u.points
}

// Discounts возвращает структуру скидок, связанную с пользователем.
func (u *User) Discounts() *Discounts {
	return u.discounts
}

// Discounts представляет скидки пользователя, например, скидку на следующий заказ.
type Discounts struct {
	nextOrderDiscount int // скидка на следующий заказ
}

// NextOrderDiscount возвращает значение скидки на следующий заказ пользователя.
func (c *Discounts) NextOrderDiscount() int {
	return c.nextOrderDiscount
}

Добавление скидки осуществляется вызовом метода UsePointsAsDiscount.

func (u *User) UsePointsAsDiscount(points int) error {
	if points <= 0 {
		return errors.New("points must be greater than 0")
	}

	if u.points < points {
		return errors.New("not enough points")
	}

	u.points -= points
	u.discounts.nextOrderDiscount += points

	return nil
}

Легко понять, тривиально тестировать. Нет необходимости думать о транзакциях базы данных при чтении этого кода. На самом деле, нет необходимости думать даже о таблицах базы данных. Именно с таким кодом я хочу работать.

Теперь введем интерфейс метода обновления.

type UserRepository interface {
	UpdateByID(ctx context.Context, userID int, updateFn func(user *User) (bool, error)) error
}

Аргумент updateFn — это то, как мы отделяем логику от деталей базы данных. Функция получает модель User, которую можно изменить. Любые изменения сохраняются в хранилище, если не возвращается ошибка.

updateFn возвращает значение updated типа bool, указывающее, был ли пользователь обновлен. Если возвращается true, репозиторий сохраняет пользователя. Пока у нас нет случая для возврата false, но это может быть полезно при обновлении нескольких значений (например, при использовании PATCH-запросов). Тогда обновление может быть ненужным и пропускаться.

Теперь обработчик выглядит так:

func (h UsePointsAsDiscountHandler) Handle(ctx context.Context, cmd UsePointsAsDiscount) error {
	return h.userRepository.UpdateByID(ctx, cmd.UserID, func(user *User) (bool, error) {
		err := user.UsePointsAsDiscount(cmd.Points)
		if err != nil {
			return false, err
		}

		return true, nil
	})
}

Теперь давайте посмотрим на реализацию репозитория.

func (r *PostgresUserRepository) UpdateByID(ctx context.Context, userID int, updateFn func(user *User) (bool, error)) error {
	return runInTx(r.db, func(tx *sql.Tx) error {
		row := tx.QueryRowContext(ctx, "SELECT email, points FROM users WHERE id = $1 FOR UPDATE", userID)

		var email string
		var currentPoints int
		err := row.Scan(&email, &currentPoints)
		if err != nil {
			return err
		}

		row = tx.QueryRowContext(ctx, "SELECT next_order_discount FROM user_discounts WHERE user_id = $1 FOR UPDATE", userID)

		var discount int
		err = row.Scan(&discount)
		if err != nil {
			return err
		}

		discounts := UnmarshalDiscounts(discount)
		user := UnmarshalUser(userID, email, currentPoints, discounts)

		updated, err := updateFn(user)
		if err != nil {
			return err
		}

		if !updated {
			return nil
		}

		_, err = tx.ExecContext(ctx, "UPDATE users SET email = $1, points = $2 WHERE id = $3", user.Email(), user.Points(), user.ID())
		if err != nil {
			return err
		}

		_, err = tx.ExecContext(ctx, "UPDATE user_discounts SET next_order_discount = $1 WHERE user_id = $2", user.Discounts().NextOrderDiscount(), user.ID())
		if err != nil {
			return err
		}

		return nil
	})
}

В репозитории нет никакой логики. Он извлекает все данные из таблиц пользователей и скидок, переводит их в модели приложения (с помощью функций Unmarshal), а затем вызывает функцию updateFn. Затем он сохраняет все изменения обратно в базу данных. Всё это происходит в рамках одной транзакции.

Совет

Сейчас репозиторий довольно многословен. ORM может помочь избавиться от шаблонного кода. Но будьте осторожны при выборе библиотеки, так как некоторые ORM могут принести больше вреда, чем пользы. Проверьте библиотеки, которые никогда нас не подводили.

Таким образом, мы достигли цели: логика остаётся в обработчике, а транзакция остаётся в репозитории.

Тактика: Паттерн UpdateFn

Используйте метод Update, который загружает и сохраняет агрегат. Логику оставляйте в замыкании updateFn.

Молоэффективно?

Распространенное беспокойство по поводу метода Update заключается в том, что он обновляет все поля, даже если они не изменились (например, в приведённом выше примере email).

Если вы не сохраняете огромные наборы данных или не работаете с большим масштабом, это не должно иметь большого значения. Вы можете отслеживать, какие поля изменились, и обновлять только их, но это, скорее всего, только усложнит вашу архитектуру без фактической выгоды для производительности. Будьте прагматичными и избегайте преждевременной оптимизации. Если сомневаетесь, проведите нагрузочные тесты.

Паттерн Transaction Provider — решение для особых случаев

Этот паттерн полезен, когда нужно управлять транзакцией, которая затрагивает несколько репозиториев, хотя это не всегда является лучшей практикой. Прежде всего, стоит задаться вопросом: “Почему это не единый репозиторий?”, но иногда действительно бывают случаи, когда транзакция должна охватывать несколько репозиториев.

Примером может быть ведение аудит-лога действий пользователей. Сохранение действий в одной транзакции с другими операциями (например, обновлением данных о пользователе) имеет смысл, но включать аудит-лог в агрегат пользователя было бы чрезмерно сложно и нецелесообразно.

Как работает Transaction Provider:

1. Transaction Provider содержит метод Transact, который принимает функцию как аргумент. В этой функции можно работать с адаптерами (структура с репозиториями), и всё это будет происходить в пределах одной транзакции.

2. Адаптеры включают репозитории, которые зависят от транзакции, например, UserRepository и AuditLogRepository. Эти репозитории работают в рамках одной транзакции, но сама транзакция явно не передаётся в коде приложения.

type UsePointsAsDiscountHandler struct {
	txProvider txProvider
}

type txProvider interface {
	Transact(txFunc func(adapters Adapters) error) error
}

type Adapters struct {
	UserRepository     UserRepository
	AuditLogRepository AuditLogRepository
}

func (h UsePointsAsDiscountHandler) Handle(ctx context.Context, cmd UsePointsAsDiscount) error {
	return h.txProvider.Transact(func(adapters Adapters) error {
		// Обновляем баллы пользователя
		err := adapters.UserRepository.UpdateByID(ctx, cmd.UserID, func(user *User) (bool, error) {
			err := user.UsePointsAsDiscount(cmd.Points)
			if err != nil {
				return false, err
			}
			return true, nil
		})
		if err != nil {
			return fmt.Errorf("could not use points as discount: %w", err)
		}

		// Записываем действия пользователя в аудит-лог
		log := fmt.Sprintf("used %d points as discount for user %d", cmd.Points, cmd.UserID)
		err = adapters.AuditLogRepository.StoreAuditLog(ctx, log)
		if err != nil {
			return fmt.Errorf("could not store audit log: %w", err)
		}

		return nil
	})
}

В этом примере UserRepository и AuditLogRepository работают в рамках одной транзакции, но явный объект транзакции не передаётся в коде обработчика.

Основные моменты:

Адаптеры предоставляют необходимые зависимости (репозитории) для работы внутри транзакции.

• Мы не передаём объект транзакции напрямую в методы репозиториев. Вместо этого репозитории принимают транзакцию через конструктор.

Транзакция управляется “в фоне” через TransactionProvider, что делает код чище и читабельнее.

Пример реализации репозитория:

type db interface {
	QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
	ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
}

type PostgresUserRepository struct {
	db db
}

func NewPostgresUserRepository(db db) *PostgresUserRepository {
	return &PostgresUserRepository{
		db: db,
	}
}

Реализация Transaction Provider:

type TransactionProvider struct {
	db *sql.DB
}

func NewTransactionProvider(db *sql.DB) *TransactionProvider {
	return &TransactionProvider{
		db: db,
	}
}

func (p *TransactionProvider) Transact(txFunc func(adapters Adapters) error) error {
	return runInTx(p.db, func(tx *sql.Tx) error {
		adapters := Adapters{
			UserRepository:     NewPostgresUserRepository(tx),
			AuditLogRepository: NewPostgresAuditLogRepository(tx),
		}

		return txFunc(adapters)
	})
}

Важные моменты:

Не злоупотребляй паттерном: хотя паттерн Transaction Provider может быть полезен в некоторых случаях, злоупотребление им может привести к тому, что логика, связанная с несколькими репозиториями, начнёт слишком тесно переплетаться.

Опасности при использовании FOR UPDATE: если ты используешь FOR UPDATE в своих запросах, будьте осторожны с этим паттерном, так как нужно тщательно следить за тем, чтобы все SELECT включали этот флаг для корректной работы транзакции.

Модификации и работа в команде: работа с несколькими репозиториями внутри одной транзакции может быстро выйти из-под контроля, особенно при частых изменениях в команде. Это требует внимательности при проектировании архитектуры.

Когда использовать Transaction Provider:

• Когда тебе действительно нужно объединить несколько репозиториев в рамках одной транзакции, но логика не требует их объединения в единый агрегат.

• Использовать паттерн стоит только для особых случаев, например, для технических задач, таких как ведение аудит-лога, когда их интеграция в основной агрегат будет чрезмерной.

Этот паттерн может быть полезным, но нужно использовать его с осторожностью, чтобы не усложнить логику приложения.

Это завершает рассмотрение работы с SQL-транзакциями в рамках одного сервиса. Однако существует смежная тема, которая часто возникает и требует особого внимания: транзакции между сервисами.