SQL и Qt

Unit of Work и управление транзакциями в слое данных

Введение

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

Концепция

Unit of Work инкапсулирует транзакцию: открывает её при создании, отслеживает все операции, выполненные в её рамках, и предоставляет единый метод commit()/rollback() для завершения. Репозитории, работающие внутри Unit of Work, должны использовать то же самое подключение к базе данных (QSqlDatabase), что и сам Unit of Work — это гарантирует, что все операции действительно происходят в рамках одной транзакции на уровне СУБД, а не в нескольких параллельных, не связанных друг с другом.

Пример кода: Unit of Work

// unitofwork.h
#pragma once
#include <QSqlDatabase>
#include <QSqlError>
#include <QDebug>
#include <functional>

class UnitOfWork
{
public:
    explicit UnitOfWork(QSqlDatabase db) : m_db(db)
    {
        m_started = m_db.transaction();
        if (!m_started) {
            qWarning() << "Не удалось начать транзакцию:" << m_db.lastError().text();
        }
    }

    ~UnitOfWork()
    {
        // Если commit() не был вызван явно — откатываем все изменения при разрушении объекта
        if (m_started && !m_finished) {
            m_db.rollback();
        }
    }

    bool commit()
    {
        if (!m_started || m_finished) return false;
        m_finished = true;
        if (!m_db.commit()) {
            qWarning() << "Ошибка коммита транзакции:" << m_db.lastError().text();
            m_db.rollback();
            return false;
        }
        return true;
    }

    void rollback()
    {
        if (!m_started || m_finished) return;
        m_finished = true;
        m_db.rollback();
    }

    QSqlDatabase database() const { return m_db; }

private:
    QSqlDatabase m_db;
    bool m_started = false;
    bool m_finished = false;
};
// Использование: оформление заказа как единая атомарная операция
bool placeOrder(QSqlDatabase db, int productId, int quantity, int customerId)
{
    UnitOfWork unitOfWork(db);

    SqlProductRepository productRepository(unitOfWork.database());
    SqlOrderRepository orderRepository(unitOfWork.database());
    SqlInventoryRepository inventoryRepository(unitOfWork.database());

    auto product = productRepository.findById(productId);
    if (!product || product->stockQuantity < quantity) {
        qWarning() << "Недостаточно товара на складе";
        unitOfWork.rollback();
        return false;
    }

    Order newOrder;
    newOrder.customerId = customerId;
    newOrder.productId = productId;
    newOrder.quantity = quantity;

    if (!orderRepository.save(newOrder)) {
        unitOfWork.rollback();
        return false;
    }

    if (!inventoryRepository.decreaseStock(productId, quantity)) {
        unitOfWork.rollback(); // заказ уже создан, но мы откатим И ЭТО изменение тоже —
                                // поскольку обе операции в одной транзакции
        return false;
    }

    return unitOfWork.commit(); // только теперь обе операции фиксируются одновременно
}

Пояснения к коду

Конструктор UnitOfWork сразу начинает транзакцию через m_db.transaction(), а деструктор содержит защитную логику — если ни commit(), ни rollback() не были вызваны явно до разрушения объекта (например, из-за раннего return без явного вызова, или исключения), транзакция автоматически откатывается, что предотвращает случайное «зависание» незакрытой транзакции, аналогично паттерну QMutexLocker для мьютексов.

В placeOrder() все три репозитория (productRepository, orderRepository, inventoryRepository) создаются с одним и тем же объектом QSqlDatabase, полученным через unitOfWork.database() — это критично: если бы каждый репозиторий получил собственное, отдельное подключение к базе данных, они оказались бы в разных транзакциях, и атомарность всей операции была бы нарушена. Если уменьшение остатка на складе провалится после того, как заказ уже был сохранён, явный вызов unitOfWork.rollback() откатывает и созданный заказ — без Unit of Work это было бы невозможно сделать одним вызовом, пришлось бы вручную удалять уже созданный заказ отдельной операцией.

Подводные камни

  • Передача разных объектов QSqlDatabase (с разными именами подключений) в разные репозитории внутри одного Unit of Work полностью разрушает идею атомарности — в Qt каждое именованное подключение (QSqlDatabase::addDatabase(driver, name)) имеет собственное состояние транзакции, и операции через разные подключения не объединяются в одну транзакцию на уровне СУБД, даже если физически это одна и та же база данных.
  • Длительные операции (сетевые запросы, ожидание пользовательского ввода) внутри границ Unit of Work, удерживающие транзакцию открытой — как уже отмечалось в основном цикле статей про транзакции, это удерживает блокировки на затронутые строки на длительное время, существенно снижая параллелизм для других клиентов базы данных; вся логика внутри UnitOfWork должна выполняться максимально быстро.
  • Вложенные Unit of Work внутри друг друга (например, если placeOrder() вызывает другую функцию, которая сама создаёт собственный UnitOfWork с тем же подключением) — повторный вызов m_db.transaction() для уже находящегося в транзакции подключения в большинстве драйверов не создаёт настоящую вложенную транзакцию с независимым частичным откатом, и нужно либо избегать такой вложенности в архитектуре, либо явно использовать SAVEPOINT для имитации вложенности, как упоминалось в основном цикле статей про транзакции.
  • Забытая проверка результата transaction() в конструкторе. Если транзакция не была успешно начата (например, подключение уже находится в каком-то проблемном состоянии), m_started остаётся false, но код, использующий UnitOfWork, может не проверить это явно и продолжить выполнение операций, ошибочно считая, что они защищены транзакцией, хотя на самом деле выполняются без неё.