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

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);
}

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

Подсказки
💡 Для REST DTO используй Bean Validation: @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 customerTypeenum CustomerType;

  • doubleBigDecimal;

  • синхронный notification внутри транзакции → outbox;

  • неиспользуемый результат calculateTotalPrice();

  • запрос скидки по customerType, если бизнес-правило относится к конкретному клиенту;

  • конкурентное списание остатков.

Главная целевая структура:

Controller
    │ @Valid OrderDto
OrderService
    ├── OrderValidator
    ├── PricingService
    │      └── DiscountStrategy
    ├── InventoryService
    ├── OrderRepository
    └── Outbox
        Notification

Так OrderService остаётся координатором бизнес-сценария, а конкретные обязанности разделены между специализированными компонентами.