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 и аудит разумно разделить по ответственности.