Провести code review сервиса сохранения заказа

12. Провести code review сервиса сохранения заказа

Условие задачи:
Дан сервис сохранения заказа.

Необходимо провести code review и определить:

  • корректно ли работает @Transactional;

  • правильно ли организована валидация;

  • что произойдёт при исключениях;

  • корректно ли выполняется аудит;

  • какие проблемы есть с Dependency Injection;

  • какие риски возникнут при работе с БД;

  • как разделить ответственность класса;

  • как должна выглядеть исправленная реализация.

Код:

public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Inject
    private AuditRepository auditRepository;

    public void save(Order order)
            throws OrderValidationException {
        try {
            saveOrderInternal(order);
        } catch (Throwable e) {
            e.printStackTrace();
            throw new OrderSaveException(e, order);
        }
    }

    @Transactional
    private void saveOrderInternal(Order order)
            throws OrderValidationException {

        auditRepository.save(order);

        if (order.getClient().getName() == "") {
            throw new OrderValidationException(
                    "Поле клиент не заполнено."
            );
        }

        orderRepository.save(order);
    }
}

Спойлеры к решению

Подсказки
💡 @Transactional работает через Spring AOP proxy, поэтому аннотация на private-методе, вызываемом из того же класса, не создаёт транзакцию.
💡 Строки нельзя сравнивать через ==.
💡 До обращения к order.getClient().getName() нужно учитывать null.
💡 catch (Throwable) перехватывает даже серьёзные ошибки JVM, которые обычно перехватывать не следует.
💡 Checked exception по умолчанию не приводит к rollback Spring-транзакции.
💡 Нужно определить семантику аудита: фиксировать только успешные операции или также неуспешные попытки.

Решение

В коде есть несколько существенных проблем.

1. @Transactional фактически не работает

Метод:

@Transactional
private void saveOrderInternal(Order order) {
    ...
}

вызывается так:

saveOrderInternal(order);

то есть непосредственно из того же объекта.

Spring в обычном proxy-based режиме применяет @Transactional, когда вызов проходит через Spring-прокси:

другой bean
Spring proxy
@Transactional method

Здесь происходит:

OrderService.save()
private saveOrderInternal()

поэтому транзакционный interceptor не вызывается.

Кроме того, private-метод сам по себе не является подходящей точкой для proxy-based транзакционного метода.

Аннотацию следует поставить на публичный метод:

@Transactional
public void save(...) {
    ...
}

2. Валидация выполняется после записи аудита

Сейчас:

auditRepository.save(order);

if (...) {
    throw new OrderValidationException(...);
}

Получается логика:

сначала что-то сохраняем
потом выясняем, что заказ вообще невалидный

Если аудит должен фиксировать только успешно принятые заказы, сначала нужно валидировать данные.

Если же бизнес-требование состоит в том, чтобы аудитировать все попытки, включая невалидные и упавшие, это уже другая семантика: такой аудит часто выполняют отдельно, например в собственной транзакции REQUIRES_NEW.


3. Строки сравниваются неправильно

Так делать нельзя:

order.getClient().getName() == ""

== сравнивает ссылки на объекты.

Минимальный вариант:

order.getClient().getName().isBlank()

Но сначала необходимо проверить всю цепочку на null:

if (order == null
        || order.getClient() == null
        || order.getClient().getName() == null
        || order.getClient().getName().isBlank()) {
    ...
}

Лучше вынести это в отдельный валидатор.


4. catch (Throwable) слишком широкий

Сейчас:

catch (Throwable e)

перехватывает не только обычные исключения, но и Error, например:

OutOfMemoryError
StackOverflowError
LinkageError

Такие ошибки обычно не должны превращаться в бизнесовый OrderSaveException.

Нужно перехватывать ожидаемые исключения, максимум:

catch (Exception e)

но и это часто избыточно.


5. OrderValidationException — checked exception

Класс:

public class OrderValidationException
        extends Exception {
}

является checked exception.

По умолчанию Spring делает rollback для:

RuntimeException
Error

но не для checked exception.

То есть если внутри реально работающего @Transactional-метода после изменения БД будет выброшен:

throw new OrderValidationException(...);

Spring по умолчанию не обязан откатывать транзакцию.

Если checked exception необходимо сохранить:

@Transactional(
        rollbackFor = OrderValidationException.class
)

Но ещё проще выполнять валидацию до любых изменений.

Для многих бизнесовых ошибок также используют исключение-наследник RuntimeException.


6. Внешний try/catch может повлиять на семантику исключений

Сейчас:

try {
    saveOrderInternal(order);
} catch (Throwable e) {
    throw new OrderSaveException(e, order);
}

При этом сигнатура:

public void save(Order order)
        throws OrderValidationException

но OrderValidationException тоже будет пойман и превращён в OrderSaveException.

То есть вызывающий код фактически не получит заявленный:

OrderValidationException

Если OrderSaveException является checked exception, данный код вообще не скомпилируется, пока он не будет добавлен в throws.

Если это RuntimeException, код скомпилируется, но контракт метода всё равно вводит в заблуждение.


7. Нельзя использовать printStackTrace()

В серверном приложении:

e.printStackTrace();

следует заменить нормальным логированием:

log.error(
        "Failed to save order {}",
        order != null ? order.getId() : null,
        e
);

Но важно не логировать один и тот же exception на каждом слое без необходимости, иначе логи будут дублироваться.


8. Dependency Injection оформлен непоследовательно

Используются одновременно:

@Autowired

и:

@Inject

Обе модели могут работать, но смешивать их в одном классе нет смысла.

Предпочтительнее constructor injection:

private final OrderRepository orderRepository;
private final AuditRepository auditRepository;

public OrderService(
        OrderRepository orderRepository,
        AuditRepository auditRepository
) {
    this.orderRepository = orderRepository;
    this.auditRepository = auditRepository;
}

Преимущества:

  • зависимости обязательны;

  • поля можно сделать final;

  • класс легче тестировать;

  • объект невозможно создать в частично инициализированном состоянии.


9. OrderService должен быть Spring-бином

В примере отсутствует:

@Service

Если его действительно нет в исходном приложении, Spring не создаст бин, Dependency Injection и @Transactional не заработают.

Должно быть:

@Service
public class OrderService {
    ...
}

10. Что произойдёт, если orderRepository.save() выбросит exception

Например, Spring Data может выбросить наследника:

DataAccessException

который является RuntimeException.

Если save() находится внутри реально работающей транзакции и исключение выходит наружу, транзакция будет помечена на rollback.

Но у JPA есть важная особенность:

orderRepository.save(order);

не гарантирует немедленного выполнения SQL.

Ошибка БД может возникнуть позже:

save()
...
flush
COMMIT
constraint violation

Например, нарушение UNIQUE или FK может обнаружиться только при flush/commit.


11. Порядок orderRepository.save() и auditRepository.save() зависит от бизнес-смысла

Если аудит означает:

«успешно сохранён заказ»

логично выполнять:

validate
save order
save audit
commit

в одной транзакции.

Если оба repository работают с одной транзакционной БД, ошибка на любом шаге откатит оба изменения.

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

Например:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeFailedAttempt(...) {
    ...
}

может быть вынесен в отдельный Spring-бин, чтобы REQUIRES_NEW действительно применился через proxy.


12. auditRepository.save(order) нужно проверить по типам

Определение AuditRepository в условии отсутствует.

Если он, например:

JpaRepository<Audit, Long>

то:

auditRepository.save(order);

некорректен по типам — нужно создавать Audit.

Если же AuditRepository действительно работает с Order, этот вызов может быть допустим.

Без определения repository однозначно утверждать нельзя.


Один из вариантов рефакторинга:

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final AuditRepository auditRepository;
    private final OrderValidator orderValidator;

    public OrderService(
            OrderRepository orderRepository,
            AuditRepository auditRepository,
            OrderValidator orderValidator
    ) {
        this.orderRepository = orderRepository;
        this.auditRepository = auditRepository;
        this.orderValidator = orderValidator;
    }

    @Transactional
    public void save(Order order) {
        orderValidator.validate(order);

        Order savedOrder =
                orderRepository.save(order);

        auditRepository.save(
                Audit.orderSaved(savedOrder)
        );
    }
}

Валидацию можно вынести отдельно:

@Component
public class OrderValidator {

    public void validate(Order order) {
        if (order == null) {
            throw new OrderValidationException(
                    "Заказ не указан"
            );
        }

        if (order.getClient() == null
                || order.getClient().getName() == null
                || order.getClient().getName().isBlank()) {

            throw new OrderValidationException(
                    "Поле клиент не заполнено"
            );
        }
    }
}

Если выбрать бизнесовое исключение как unchecked:

public class OrderValidationException
        extends RuntimeException {

    public OrderValidationException(String message) {
        super(message);
    }
}

то при возникновении такого исключения внутри транзакции Spring по умолчанию выполнит rollback.

В данном варианте оно возникает до первого изменения БД, поэтому это дополнительно упрощает семантику.


Основной сценарий становится таким:

save(order)
    ├── validate
    │      └── ошибка → никаких записей в БД
    ├── save Order
    ├── save Audit
    └── COMMIT

Если сохранение аудита падает:

save Order
save Audit → RuntimeException
ROLLBACK

то при одной общей транзакции заказ также не сохраняется.


Основные замечания по исходному коду:

  • @Transactional на приватном self-invoked методе не работает;

  • валидация происходит слишком поздно;

  • строки сравниваются через ==;

  • отсутствуют проверки order, client, name на null;

  • Throwable перехватывать не следует;

  • printStackTrace() не подходит для сервера;

  • checked OrderValidationException имеет другую rollback-семантику;

  • заявленный OrderValidationException фактически поглощается общим catch;

  • DI-аннотации смешаны;

  • лучше использовать constructor injection;

  • нужно проверить наличие @Service;

  • исключение JPA может возникнуть только на flush/commit;

  • семантика аудита должна быть определена явно;

  • сохранение Order через AuditRepository корректно только при подходящем generic-типе repository;

  • валидацию, persistence и аудит разумно разделить по ответственности.