Код-ревью Senior Транзакции и @Transactional обновлено

Код-ревью: оплата заказа

Вопрос

Найдите проблемы с транзакциями.

@Service
public class PaymentService {
    private final OrderRepository orders;
    private final PaymentGateway gateway;
    private final AuditRepository audit;
    private final MailSender mail;

    public PaymentService(OrderRepository orders, PaymentGateway gateway,
                          AuditRepository audit, MailSender mail) {
        this.orders = orders;
        this.gateway = gateway;
        this.audit = audit;
        this.mail = mail;
    }

    @Transactional
    public void pay(long orderId) throws PaymentException {
        Order order = orders.findById(orderId).orElseThrow();
        order.setStatus(Status.PAYING);
        GatewayResult r = gateway.charge(order.getTotal());
        if (!r.ok()) {
            writeAudit(orderId, "declined");
            throw new PaymentException("declined");
        }
        order.setStatus(Status.PAID);
        mail.send(order.getEmail(), "Оплачено");
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeAudit(long orderId, String text) {
        audit.save(new AuditRecord(orderId, text));
    }

    @Transactional
    private void refund(Order order) {
        gateway.refund(order.getTotal());
        order.setStatus(Status.REFUNDED);
    }
}

Коротко

Платёж это операция с внешним миром, поэтому одна большая транзакция здесь не подходит. Нужно: короткая транзакция «начали оплату», вызов шлюза вне транзакции с ключом идемпотентности, вторая транзакция с результатом, аудит в отдельном бине с REQUIRESNEW, письмо после коммита.

Что должен найти ревьюер

  1. Ошибкастрока 20
    Внешний платёжный вызов внутри транзакции: соединение из пула держится всё время ответа шлюза; при откате после успешного списания деньги потеряны без записи.
  2. Ошибкастрока 22
    writeAudit вызывается через this: REQUIRES_NEW не сработает, запись аудита в той же транзакции.
  3. Ошибкастрока 23
    PaymentException checked (объявлен в throws): по умолчанию транзакция закоммитится, статус PAYING и аудит сохранятся. Нужен rollbackFor или осознанное решение.
  4. Ошибкастрока 26
    Письмо уходит до коммита: если коммит упадёт, клиент получит «Оплачено» без оплаты. Нужен AFTER_COMMIT слушатель или outbox.
  5. Ошибкастрока 35
    @Transactional на private-методе игнорируется прокси.

Разбор

Платёж это операция с внешним миром, поэтому одна большая транзакция здесь не подходит. Нужно: короткая транзакция «начали оплату», вызов шлюза вне транзакции с ключом идемпотентности, вторая транзакция с результатом, аудит в отдельном бине с REQUIRES_NEW, письмо после коммита. И явно решить, что делать с checked-исключением.

Ответьте сами, а не прочитайте

В тренажёре вопрос попадёт в повторения и вернётся, когда начнёт забываться. Плюс билеты с зачётом: 20 вопросов, не больше 2 ошибок.