11. Провести code review и рефакторинг OrderService
Условие задачи:
Дан Spring-сервис обработки заказов.
Необходимо провести code review и ответить на вопросы:
как валидировать входящий по REST
OrderDto;где выполнять проверки на
null;какие обязанности оставить в
OrderService;когда откатится транзакция
processOrder();как разделить ответственность класса по SRP;
какие данные сохранять в БД;
какие проблемы возможны при сохранении заказа;
что произойдёт при исключении из
saveOrder();как корректно уменьшать остатки товаров;
будут ли автоматически сформированы методы Spring Data Repository;
где писать кастомные запросы;
какие ещё архитектурные и технические проблемы есть в коде.
Код:
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private NotificationService notificationService;
@Transactional
public void processOrder(Order order) {
validateOrder(order);
calculateTotalPrice(
order.getItems(),
order.getCustomerType()
);
saveOrder(order);
notifyCustomer(order);
updateInventory(order);
}
public double calculateTotalPrice(
List<OrderItem> items,
String customerType
) {
double total = items.stream()
.mapToDouble(
item -> item.getPrice()
* item.getQuantity()
)
.sum();
if ("VIP".equals(customerType)) {
total *= 0.9;
} else if ("LOYALTY".equals(customerType)) {
total *= 0.85;
}
if (orderRepository.countByCustomerType(
customerType
) > 10) {
total *= 0.95;
}
return total;
}
private void saveOrder(Order order) {
orderRepository.save(order);
}
private void updateInventory(Order order) {
order.getItems().forEach(item ->
inventoryRepository.decreaseStock(
item.getProductId(),
item.getQuantity()
)
);
}
}
@Repository
public interface OrderRepository
extends JpaRepository<Order, Long> {
int countByCustomerType(String customerType);
Order findByOrderId(Long id);
}
Спойлеры к решению
Подсказки
@NotNull, @NotEmpty, @Positive, @Valid.💡 Для денег нельзя использовать
double — лучше BigDecimal.💡 Результат
calculateTotalPrice() сейчас вообще нигде не сохраняется.💡
@Transactional откатывает транзакцию по умолчанию при неперехваченном RuntimeException или Error.💡 Отправка уведомления внутри DB-транзакции создаёт проблему: внешний эффект нельзя откатить вместе с БД.
💡 Остаток товара нужно уменьшать атомарно:
UPDATE ... WHERE stock >= quantity.💡 Spring Data сможет создать derived query только тогда, когда указанные в имени метода свойства действительно существуют в
Order.Решение
В классе есть несколько функциональных и архитектурных проблем.
1. Рассчитанная стоимость заказа теряется
Сейчас выполняется:
calculateTotalPrice(
order.getItems(),
order.getCustomerType()
);
но результат метода игнорируется.
То есть даже если цена рассчитана правильно, в Order она не записывается.
Должно быть примерно так:
BigDecimal total =
pricingService.calculateTotalPrice(order);
order.setTotalPrice(total);
И уже после этого заказ сохраняется.
2. Для денег нельзя использовать double
Код:
double total
и коэффициенты:
0.9
0.85
0.95
могут приводить к ошибкам двоичной арифметики с плавающей точкой.
Для денежных значений лучше использовать:
BigDecimal
Например:
BigDecimal total = items.stream()
.map(item ->
item.getPrice().multiply(
BigDecimal.valueOf(
item.getQuantity()
)
)
)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
Если price сейчас тоже double, его стоит заменить на BigDecimal.
3. Как валидировать OrderDto
В REST-слое удобно использовать Bean Validation.
Например:
public class OrderDto {
@NotNull
private Long customerId;
@NotNull
private CustomerType customerType;
@NotEmpty
@Valid
private List<OrderItemDto> items;
}
Элемент заказа:
public class OrderItemDto {
@NotNull
private Long productId;
@NotNull
@DecimalMin(value = "0.01")
private BigDecimal price;
@Positive
private int quantity;
}
Контроллер:
@PostMapping("/orders")
public ResponseEntity<Void> create(
@Valid @RequestBody OrderDto orderDto
) {
orderService.processOrder(orderDto);
return ResponseEntity.ok().build();
}
Для Spring Boot 3 используются аннотации из:
jakarta.validation
Так следует проверять структурную корректность входного запроса:
обязательные поля;
пустые коллекции;
положительные количества;
формат значений.
Бизнес-валидация вроде:
товар существует
товар доступен для продажи
клиент имеет право оформить заказ
остатка достаточно
должна выполняться уже в бизнес-слое.
4. Где проверять параметры на null
Основная проверка входящего REST DTO выполняется на границе приложения:
@Valid @RequestBody OrderDto dto
Но публичный сервисный метод лучше не делать зависимым исключительно от того, что его всегда вызовет REST-контроллер.
Для критичных параметров допустимо дополнительно защищать контракт:
Objects.requireNonNull(orderDto, "orderDto");
Не следует при этом хаотично дублировать null-проверки во всех приватных методах.
Общий принцип:
REST boundary
→ синтаксическая валидация DTO
Application/Domain layer
→ бизнес-инварианты
5. Что оставить в OrderService
OrderService разумно оставить application service, то есть координатором сценария:
проверить бизнес-правила
↓
посчитать стоимость
↓
уменьшить остатки
↓
сохранить заказ
↓
зафиксировать событие о создании заказа
Сам OrderService не должен знать детали:
расчёта всех скидок;
SQL обновления склада;
отправки email/push;
сложной бизнес-валидации.
6. Нарушение SRP
Сейчас один класс одновременно:
валидирует заказ
+
считает стоимость
+
считает скидки
+
ходит в OrderRepository
+
управляет складом
+
отправляет уведомления
Это несколько причин для изменения одного класса.
Можно разделить ответственность:
OrderService
→ orchestration
OrderValidator
→ бизнес-валидация
PricingService
→ цена
DiscountPolicy
→ конкретная скидка
InventoryService
→ остатки
OrderRepository
→ persistence
Notification / Outbox
→ уведомления
7. Скидки также реализованы проблемно
Сейчас:
if ("VIP".equals(customerType)) {
...
} else if ("LOYALTY".equals(customerType)) {
...
}
При добавлении нового типа клиента приходится менять существующий метод.
Здесь хорошо подходит Strategy:
public interface DiscountPolicy {
CustomerType supports();
BigDecimal apply(
BigDecimal price,
Customer customer
);
}
Например:
VipDiscountPolicy
LoyaltyDiscountPolicy
RegularDiscountPolicy
8. Проверка постоянного клиента выглядит логически неверной
В коде:
orderRepository.countByCustomerType(customerType)
и далее:
if (...) > 10 {
total *= 0.95;
}
Комментарий говорит:
Доп. скидка для постоянных клиентов
Но запрос считает заказы всех клиентов данного типа.
Например, если в системе есть 1000 VIP-заказов разных людей, любой новый VIP сразу получит скидку «за постоянство».
Если правило действительно относится к конкретному клиенту, вероятнее нужен запрос вроде:
long countByCustomerId(Long customerId);
а ещё лучше учитывать только успешно завершённые заказы:
long countByCustomerIdAndStatus(
Long customerId,
OrderStatus status
);
9. Когда откатится processOrder()
По умолчанию:
@Transactional
откатывает транзакцию, если наружу из метода выйдет:
RuntimeException
или
Error
Например:
throw new IllegalStateException();
приведёт к rollback.
Checked exception по умолчанию rollback не вызывает.
Если нужно:
@Transactional(rollbackFor = Exception.class)
Также транзакция должна быть вызвана через Spring proxy. Если processOrder() вызывается через:
this.processOrder(...)
из другого метода того же класса, стандартный proxy-based @Transactional не сработает.
Если исключение поймано:
try {
...
} catch (RuntimeException e) {
// ничего
}
и не проброшено дальше, транзакция обычно продолжит выполнение, если она отдельно не была помечена rollbackOnly.
10. Что произойдёт, если saveOrder() выбросит exception
Если:
orderRepository.save(order);
выбросит неперехваченный RuntimeException, например Spring DataAccessException, выполнение processOrder() прекратится.
То есть:
saveOrder()
↓ exception
notifyCustomer() ← уже не вызывается
updateInventory() ← уже не вызывается
Транзакция будет откатываться.
Но с JPA есть важный нюанс.
Вызов:
repository.save(order);
не всегда означает, что SQL уже отправлен в БД.
Hibernate может выполнить реальный INSERT только во время:
flush
или
commit
Поэтому нарушение constraint может обнаружиться после выполнения следующего кода.
Это особенно важно из-за notifyCustomer().
11. notifyCustomer() внутри транзакции опасен
Сейчас последовательность:
saveOrder
↓
notifyCustomer
↓
updateInventory
↓
COMMIT
Представим:
saveOrder() → пока всё нормально
notifyCustomer() → клиенту отправлено "Заказ создан"
updateInventory() → exception
transaction → ROLLBACK
В БД заказа нет, но клиент уже получил уведомление.
Внешний вызов невозможно откатить вместе с JDBC-транзакцией.
Для надёжной системы лучше использовать Transactional Outbox:
DB transaction:
сохранить Order
изменить Inventory
сохранить OutboxEvent
COMMIT
отдельный worker:
читает Outbox
отправляет notification
Тогда заказ и событие фиксируются атомарно в одной БД.
12. Что уменьшается в updateInventory()
Метод:
inventoryRepository.decreaseStock(
item.getProductId(),
item.getQuantity()
);
по названию должен уменьшать остаток.
То есть:
stock = stock - quantity
Если было:
stock = 10
quantity = 3
после операции ожидается:
stock = 7
13. Как должен работать decreaseStock()
Просто сначала прочитать остаток, а потом сохранить новое значение — недостаточно при конкурентных заказах.
Например:
stock = 1
Order A читает 1
Order B читает 1
A списывает 1
B списывает 1
Оба могут считать товар доступным.
Лучше выполнить атомарный запрос:
@Modifying
@Query("""
update Inventory i
set i.stock = i.stock - :quantity
where i.productId = :productId
and i.stock >= :quantity
""")
int decreaseStock(
@Param("productId") Long productId,
@Param("quantity") int quantity
);
И проверить результат:
int updated = repository.decreaseStock(
productId,
quantity
);
if (updated != 1) {
throw new OutOfStockException(productId);
}
Таким образом, БД атомарно проверяет наличие товара и уменьшает остаток.
14. Будет ли работать countByCustomerType()
Метод:
int countByCustomerType(String customerType);
может быть автоматически реализован Spring Data только если в Order существует свойство customerType.
Например:
private String customerType;
или совместимое поле с соответствующим property accessor.
Тогда Spring Data построит derived query самостоятельно.
Если такого свойства у Order нет, приложение получит ошибку при создании repository bean.
Компилятор Java такую проблему не обнаружит.
Также для количества записей естественнее возвращать:
long
поскольку Spring Data count... обычно семантически соответствует long.
15. Будет ли работать findByOrderId()
Это зависит от полей сущности.
Order findByOrderId(Long id);
ищет property path, соответствующий orderId.
Если сущность содержит:
private Long orderId;
такой derived query возможен.
Но если первичный ключ выглядит стандартно:
@Id
private Long id;
то этот метод лишний.
JpaRepository уже предоставляет:
Optional<Order> findById(Long id);
Следует использовать:
orderRepository.findById(id);
Если свойства orderId или подходящего property path в Order нет, Spring Data не сможет создать запрос и repository может не подняться при старте приложения.
16. Где писать SQL в Repository
Для стандартных запросов SQL вообще не нужен.
Можно использовать derived query:
long countByCustomerId(Long customerId);
Spring Data сформирует запрос самостоятельно.
Если нужен собственный JPQL:
@Query("""
select count(o)
from Order o
where o.customer.id = :customerId
""")
long countOrders(
@Param("customerId") Long customerId
);
Если нужен именно native SQL:
@Query(
value = """
select count(*)
from orders
where customer_id = :customerId
""",
nativeQuery = true
)
long countOrders(
@Param("customerId") Long customerId
);
Для UPDATE/DELETE дополнительно нужен:
@Modifying
17. Что записывать в таблицу
Конкретная структура Order в условии не показана, поэтому полный набор колонок определить нельзя.
Но как минимум сохраняемый заказ должен содержать результат бизнес-расчётов, а не только исходный request.
Например:
Order
----------------
id
customer_id
customer_type
total_price
status
created_at
Товары обычно хранят отдельно:
OrderItem
----------------
order_id
product_id
quantity
unit_price
Критично то, что вычисленная:
totalPrice
должна быть записана в Order.
Сейчас этого не происходит.
18. Какие риски есть при сохранении в БД
В production нужно учитывать:
нарушение
NOT NULL;нарушение
UNIQUE;нарушение FK;
optimistic locking conflict;
deadlock;
lock timeout;
потерю соединения;
превышение connection pool;
duplicate request;
повторную отправку одного заказа;
недостаточный остаток товара;
конкурентное изменение остатков;
exception на flush/commit, а не непосредственно на
save().
Для создания заказа также полезно иметь idempotency key, если вызывающий клиент может повторить HTTP-запрос после timeout.
19. Вариант структуры после рефакторинга
OrderService оставляем координатором use case:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OrderValidator orderValidator;
private final PricingService pricingService;
private final InventoryService inventoryService;
private final OutboxService outboxService;
public OrderService(
OrderRepository orderRepository,
OrderValidator orderValidator,
PricingService pricingService,
InventoryService inventoryService,
OutboxService outboxService
) {
this.orderRepository = orderRepository;
this.orderValidator = orderValidator;
this.pricingService = pricingService;
this.inventoryService = inventoryService;
this.outboxService = outboxService;
}
@Transactional
public Long processOrder(Order order) {
Objects.requireNonNull(order, "order");
orderValidator.validate(order);
BigDecimal totalPrice =
pricingService.calculateTotalPrice(order);
order.setTotalPrice(totalPrice);
inventoryService.decreaseStock(
order.getItems()
);
Order saved =
orderRepository.save(order);
outboxService.saveOrderCreatedEvent(
saved.getId()
);
return saved.getId();
}
}
Если InventoryRepository, OrderRepository и outbox используют одну БД и один transaction manager, то:
уменьшение stock
+
сохранение order
+
сохранение outbox event
фиксируются одной транзакцией.
При ошибке любой из этих операций изменения откатываются вместе.
20. Какие принципы нарушены
Основной — SRP:
OrderService знает слишком много и делает слишком много.
Также нарушается OCP:
if ("VIP".equals(...)) {
...
} else if ("LOYALTY".equals(...)) {
...
}
Каждый новый тип скидки требует изменения метода.
Поэтому скидки естественно вынести в Strategy.
Есть и нарушение Separation of Concerns: orchestration, pricing, persistence, inventory и notification смешаны в одном классе.
Дополнительно стоит исправить:
field injection → constructor injection;
String customerType→enum CustomerType;double→BigDecimal;синхронный notification внутри транзакции → outbox;
неиспользуемый результат
calculateTotalPrice();запрос скидки по
customerType, если бизнес-правило относится к конкретному клиенту;конкурентное списание остатков.
Главная целевая структура:
Controller
│
│ @Valid OrderDto
▼
OrderService
│
├── OrderValidator
├── PricingService
│ └── DiscountStrategy
│
├── InventoryService
│
├── OrderRepository
│
└── Outbox
│
▼
Notification
Так OrderService остаётся координатором бизнес-сценария, а конкретные обязанности разделены между специализированными компонентами.