Демо-отчёт. Проект и код написаны для примера — в них специально оставлены частые ошибки тестовых заданий. Это не работа реального клиента.
CodeCheckupОтчёт по код-ревью

Сервис заказов — тестовое задание

REST API заказов: создание с резервом товара, список, смена статуса. Java 21, Spring Boot 3, PostgreSQL, Docker Compose.

Заявка
№DEMO
Стек
Backend
Что проверяли
Тестовое задание
Уровень
Middle
Репозиторий
демо-проект
Ревьюер
Backend-разработчик CodeCheckup
Дата
20 октября 2026

Главное за минуту

4Исправить обязательно
3Желательно поправить
2На будущее

Проект аккуратно разложен по слоям и запускается одной командой. До отправки исправьте 4 пункта P1 — это около 3 часов.

  1. Передавайте статус в SQL параметром — P1-1. Склейка строки запроса — первое, что ищут в коде бэкенда.
  2. Создавайте заказ в транзакции — P1-3. Сейчас при ошибке остаётся резерв товара без заказа.
  3. Уберите пароль базы и секрет JWT из application.yml — P1-2. Они уже в истории git.
  4. Уберите N+1 при выдаче заказов с позициями — P1-4. На списке из 50 заказов сейчас 51 запрос к базе.
  5. Если останется время — пагинация P2-1 и понятные ошибки вместо 500 P2-2.

Ответ на ваш вопрос

«Достаточно ли тестов и как обычно тестируют такие сервисы?»

Тестов мало, но исправить это несложно P2-3. Сейчас есть только проверка, что контекст поднимается. Для тестового хватит двух уровней:

  • Юнит-тесты сервиса на правила: нельзя заказать больше остатка, нельзя перевести отменённый заказ в «оплачен». Репозитории здесь подменяются фейками или моками Mockito.
  • Интеграционные тесты API через MockMvc и настоящий PostgreSQL в Testcontainers: создать заказ → получить его → сменить статус. H2 вместо PostgreSQL не подойдёт — у вас нативные запросы, которые H2 выполняет иначе.
Что проверяют интеграционные тесты
POST /orders201 + LocationGET /orders/{id}200, позиции на месте
POST /orders (товара нет)409, остаток не изменился
PATCH /orders/{id}/status (отменёноплачен)422

Сильные стороны

  • Слои без смешения: контроллеры только принимают запрос и отдают DTO, логика в сервисах, SQL в репозиториях.
  • Docker Compose с PostgreSQL: проект запускается одной командой, README объясняет порты и переменные.
  • Валидация входных данных: @Valid и аннотации на DTO, ошибки формата отдаются как 400.
  • Современная Java к месту: record для DTO и запросов, конструкторная инъекция через final-поля, без @Autowired на полях.

Соответствие требованиям задания

Требование Статус Комментарий
Создание заказа с резервом товара частично частично без транзакции — P1-3
Список заказов с фильтром по статусу частично частично фильтр уязвим к SQL-инъекции — P1-1; нет пагинации — P2-1
Смена статуса выполнено переходы проверяются, но ошибка отдаётся как 500 — P2-2
PostgreSQL выполнено схема создаётся через ddl-auto — см. P3 про миграции
Запуск в Docker выполнено docker compose up поднимает приложение и базу
Тесты частично частично только проверка контекста — P2-3

Замечания

Безопасность

Исправить обязательноP1-1

Статус подставляется прямо в SQL

Где
order/OrderRepository.java:24
Время
~15 мин

Что не так. Статус из параметра запроса склеивается со строкой SQL. Запрос ?status=x' OR '1'='1 вернёт все заказы, а более хитрый — изменит данные.

Было · order/OrderRepository.java
public List<Order> findByStatus(String status) {
    return jdbc.query("SELECT * FROM orders WHERE status = '" + status + "'", orderMapper);
}

Как исправить. Передавайте значение параметром, а статус принимайте как enum — тогда неизвестное значение отсечётся ещё на входе с ответом 400:

Стало · order/OrderRepository.java
public List<Order> findByStatus(OrderStatus status) {
    return jdbc.query("SELECT * FROM orders WHERE status = ?", orderMapper, status.name());
}
Исправить обязательноP1-2

Пароль базы и секрет JWT лежат в application.yml

Где
src/main/resources/application.yml:6–14
Время
~20 мин

Пароль PostgreSQL и jwt.secret записаны значениями и попали в историю git. Удаление строк не поможет — смените секрет и читайте значения из переменных окружения:

Было · src/main/resources/application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/orders
    username: orders
    password: orders_pass_2026
app:
  jwt-secret: 7f3c9a1e5b2d4f6a8c0e1b3d5f7a9c2e
Стало · src/main/resources/application.yml
spring:
  datasource:
    url: ${DB_URL:jdbc:postgresql://localhost:5432/orders}
    username: ${DB_USER:orders}
    password: ${DB_PASSWORD}
app:
  jwt-secret: ${JWT_SECRET}

В docker-compose.yml передайте их через env_file: .env, а в репозиторий положите .env.example без настоящих значений.

Данные и транзакции

Исправить обязательноP1-3

Заказ создаётся без транзакции

Где
order/OrderService.java:40
Время
~20 мин

Что не так. Сначала резервируется товар, потом сохраняется заказ — двумя отдельными операциями. Если сохранение упадёт (нарушение ограничения, обрыв соединения), резерв останется, а заказа не будет. Проверяющий почти наверняка спросит, что произойдёт при ошибке на середине.

Было · order/OrderService.java
public Order create(NewOrder order) {
    stock.reserve(order.items());
    return orders.save(order.toEntity());
}

Как исправить. Оберните операцию в транзакцию. Резерв делайте одним условным UPDATE, чтобы два параллельных заказа не забрали последний товар:

Стало · order/OrderService.java
@Transactional
public Order create(NewOrder order) {
    for (OrderItem item : order.items()) {
        if (!stock.reserve(item.productId(), item.quantity())) {
            throw new OutOfStockException(item.productId());
        }
    }
    return orders.save(order.toEntity());
}
Стало · stock/StockRepository.java
@Modifying
@Query(value = """
        UPDATE stock SET available = available - :quantity
        WHERE product_id = :productId AND available >= :quantity
        """, nativeQuery = true)
int decrease(@Param("productId") long productId, @Param("quantity") int quantity);

reserve возвращает decrease(...) == 1. OutOfStockException наследует RuntimeException, поэтому откатит всю транзакцию, включая уже зарезервированные позиции.

Исправить обязательноP1-4

N+1 запросов при выдаче заказов с позициями

Где
order/OrderController.java:30, order/OrderEntity.java:18
Время
~40 мин

Позиции заказа загружаются лениво, а OrderDto.from() обращается к ним в цикле: один запрос за список и ещё по одному на каждый заказ. Включите spring.jpa.show-sql и посмотрите на лог — проверяющие делают именно так.

Было · order/OrderEntity.java
@OneToMany(mappedBy = "order") // LAZY по умолчанию
private List<OrderItemEntity> items = new ArrayList<>();
Было · order/OrderService.java
// OrderDto.from() потом читает order.getItems() у каждого заказа — по запросу на заказ
public List<OrderEntity> list() {
    return orders.findAll();
}
Стало · order/OrderJpaRepository.java
@EntityGraph(attributePaths = "items")
Page<OrderEntity> findAllByStatus(OrderStatus status, Pageable pageable);

API

Желательно поправитьP2-1

Все заказы — одним ответом

Где
order/OrderController.java:30
Время
~40 мин

findAll() без ограничений: на тысячах заказов ответ станет огромным и медленным. Принимайте Pageable и ограничьте размер страницы:

Было · order/OrderController.java
@GetMapping
public List<OrderDto> list() {
    return service.list().stream().map(OrderDto::from).toList();
}
Стало · order/OrderController.java
@GetMapping
public Page<OrderDto> list(@RequestParam(required = false) OrderStatus status,
                           @PageableDefault(size = 20) Pageable pageable) {
    return service.list(status, pageable).map(OrderDto::from);
}

И spring.data.web.pageable.max-page-size: 100 в настройках, чтобы ?size=100000 не положил сервис.

Желательно поправитьP2-2

Ошибки отдаются как 500 со стектрейсом

Где
order/OrderService.java:63
Время
~40 мин

Недопустимый переход статуса и несуществующий заказ превращаются в 500 Internal Server Error с текстом исключения. Клиент API не может отличить свою ошибку от сбоя сервера.

Было · order/OrderService.java
public OrderEntity changeStatus(long id, OrderStatus status) {
    OrderEntity order = orders.findById(id).orElseThrow();
    if (!order.getStatus().canChangeTo(status)) {
        throw new IllegalStateException("Нельзя перевести заказ из " + order.getStatus() + " в " + status);
    }
    order.setStatus(status);
    return order;
}

Бросайте свои исключения и сопоставьте их с кодами ответа в одном месте:

Стало · order/OrderService.java
OrderEntity order = orders.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
if (!order.getStatus().canChangeTo(status)) {
    throw new IllegalStatusTransitionException(order.getStatus(), status);
}
Стало · common/ApiExceptionHandler.java
@RestControllerAdvice
public class ApiExceptionHandler {
    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail notFound(OrderNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(OutOfStockException.class)
    public ProblemDetail outOfStock(OutOfStockException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
    }

    @ExceptionHandler(IllegalStatusTransitionException.class)
    public ProblemDetail illegalTransition(IllegalStatusTransitionException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
    }
}

И server.error.include-stacktrace: never — стектрейсы в ответах раскрывают устройство сервиса.

Тесты

Желательно поправитьP2-3

Тесты только проверяют, что контекст поднимается

Где
src/test/java/…/OrdersApplicationTests.java
Время
~1,5 ч
Было · src/test/java/…/OrdersApplicationTests.java
@SpringBootTest
class OrdersApplicationTests {
    @Test
    void contextLoads() {
    }
}

Добавьте интеграционный тест на главный сценарий с настоящим PostgreSQL:

Стало · order/OrderApiTest.java
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class OrderApiTest {
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    MockMvc mvc;

    @Test
    void cannotOrderMoreThanInStock() throws Exception {
        mvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"items":[{"productId":1,"quantity":999}]}
                    """))
            .andExpect(status().isConflict());
    }
}
На будущееP3-1

Миграции Flyway вместо ddl-auto

spring.jpa.hibernate.ddl-auto: update удобен в начале, но не показывает, как схема будет меняться дальше. Пара миграций V1__init.sql, V2__stock.sql в Flyway — привычный для проверяющих способ.

На будущееP3-2

Описание API в OpenAPI

springdoc-openapi даст страницу /swagger-ui.html с описанием эндпоинтов. Проверяющему проще посмотреть API в браузере, чем читать контроллеры.

Что дальше

  1. P1-1 Статус параметром и как enum — ~15 мин
  2. P1-3 Транзакция и условный резерв товара — ~20 мин
  3. P1-2 Секреты в переменных окружения, сменить старые — ~20 мин
  4. P1-4 @EntityGraph для списка заказов — ~40 мин
  5. P2-1, P2-2 и P2-3 — если останется время, ~3 ч

Вопросы по отчёту — 1 раунд в течение 7 дней, пишите в тот же чат. Доступ к репозиторию можно отозвать уже сейчас.

Что упомянуть в сопроводительном письме: чистое разделение на слои и запуск одной командой через Docker Compose, а после исправлений — транзакционное создание заказа с защитой от гонок за остаток.