https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&q=80&auto=format&fit=crop
https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&q=80&auto=format&fit=crop

Маленькое признание перед тем, как мы начнём

В прошлой статье я обещал, что следующим возьмусь за Senior System Design. Не обманул, возьмусь, но не сейчас.

За эти две недели в комментариях и в личке мне прислали несколько десятков расшифровок реальных собеседований. Я думал — отдохну. А потом сел смотреть и наткнулся на одну и ту же задачу подряд в девяти разных интервью: ASTON, ТБанк, Альфа, Совкомбанк, Иннотех. Везде одно и то же — «найдите минимум восемь багов в этом куске Spring-кода, у вас двадцать минут». Разные обёртки, разные домены: сервис вознаграждений, бронирование места в самолёте, заполнение цен из прайс-листа, обработка платежей. Но скелет один: контроллер на полсотни строк, в котором зашиты ловушки от Junior до Senior уровня.

Я выписал восемь типовых багов, собрал в один контроллер обработки платежей — и публикую как тест. Хочу, чтобы вы попробовали сами, до того, как я начну разбирать. Это та самая задача, которую вам, скорее всего, дадут на следующем собесе в крупном банке.

Засеките пятнадцать минут на таймере. Семь багов — норма Middle, четыре — Junior, восемь и выше — Senior с опытом code review. Бонусный девятый я зашил отдельно: его в этой задаче обычно вообще не находят, и он не про код, а про архитектуру.


Контекст задачи

Дано — контроллер платежей в Spring. На входе: счёт списания, счёт зачисления, сумма. Внутри — проверка через антифрод, сохранение в БД, публикация события в Kafka, нотификация плательщику. Стек как у большинства банков из моей базы: Spring Boot 3, PostgreSQL, Kafka, JPA, REST-клиент для антифрода.

Формулировка от интервьюера (близко к оригиналу из интервью ТБанк #531):

«У вас двадцать минут. Найдите минимум восемь проблем — функциональных, архитектурных, любых. Считаются найденными только те, что вы можете объяснить вслух: почему это баг, что произойдёт в проде, как исправить. Назвать ошибку, но не объяснить — не считается.»

Дальше — код. Прокрутите страницу до конца кода и не подсматривайте в разбор — иначе вся история теряет смысл.


Код

@RestController
@RequestMapping("/payments")
public class PaymentController {
    @Autowired private PaymentRepository repo;
    @Autowired private KafkaTemplate<String, String> kafka;
    @Autowired private NotificationService notifications;
    @Autowired private AntifraudClient antifraud;
    private static Map<UUID, Payment> cache = new HashMap<>();

    @PostMapping
    @Transactional
    public ResponseEntity<?> create(@RequestBody Map<String, Object> body) {
        try {
            String from = (String) body.get("from");
            String to = (String) body.get("to");
            double amount = (double) body.get("amount");

            if (amount > 1000000) {
                System.out.println("Large: " + amount);
            }

            Payment p = new Payment();
            p.setId(UUID.randomUUID());
            p.setFromAccount(from);
            p.setToAccount(to);
            p.setAmount(amount);
            p.setCreatedAt(new Date());
            p.setStatus("PENDING");

            String r = antifraud.check(p);
            if (!"OK".equals(r))
                throw new RuntimeException("Declined: " + r);

            repo.save(p);
            cache.put(p.getId(), p);
            kafka.send("payments", p.toString());
            this.sendNotification(p);
            return ResponseEntity.ok(p);
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(500).body(e.getMessage());
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Payment p) {
        notifications.send(p.getFromAccount(),
            "Payment " + p.getAmount() + " sent");
    }
}
Pause. https://images.unsplash.com/photo-1495364141860-b0d03eccd065?w=1200&q=80&auto=format&fit=crop
Pause. https://images.unsplash.com/photo-1495364141860-b0d03eccd065?w=1200&q=80&auto=format&fit=crop

Подсказка: прежде чем читать дальше, прокрутите вверх и реально посмотрите на код пятнадцать минут. Иначе разбор не отложится — глаза просто пробегут готовые ответы.

Готовы? Поехали.


Разбор. Восемь багов от Junior до Senior

Я разложил баги по сложности — от самого очевидного до самого коварного. У каждого в конце укажу качественную оценку — насколько часто этот баг подсвечивают как обязательный пункт Code Review-секции в банковских интервью.

Баг №1. Field injection вместо конструктора

Четыре @Autowired на полях, и ни одно из них не final. Это первое, что должен увидеть Middle-разработчик. Field injection делает класс непригодным к юнит-тестированию без поднятия Spring-контекста, скрывает циклические зависимости до runtime, и — что хуже всего — позволяет случайно мутировать зависимость через рефлексию.

// ❌
@Autowired
private PaymentRepository repo;

// ✅
private final PaymentRepository repo;
public PaymentController(PaymentRepository repo, /* остальные */) { ... }
// или Lombok @RequiredArgsConstructor + private final

Частота на банковских собесах: практически в каждой Code Review-задаче. Без него подборка вообще редкость.

Баг №2. double для денег

Сумма приходит как double, дальше с ней работают арифметически. Это уровень детского сада в финтехе. 0.1 + 0.2 в Java даёт 0.30000000000000004. На одном платеже это копейка. На миллионе платежей — расхождение со счётом банка, на которое к вам через неделю придёт бухгалтерия с вопросами.

// ❌
double amount = (double) body.get("amount");

// ✅
BigDecimal amount = new BigDecimal(body.get("amount").toString());
// дальше — amount.add(...), amount.multiply(...).setScale(2, RoundingMode.HALF_EVEN)

И отдельно — (double) body.get("amount") упадёт с ClassCastException, если фронт пришлёт число как Integer (например, ровно 100 вместо 100.0). На проде такое случается — у меня в базе три случая, когда продакшн упал именно из-за этого.

Частота: очень часто. В платёжных и финтех-сценариях — практически обязательно.

Баг №3. System.out.println вместо логгера

Самая каноничная мелочь, которую упускает каждый третий Junior. System.out — это синхронизированный PrintStream, который блокирует поток, не пишется в файл по умолчанию, не уровнем INFO/WARN/ERROR и теряется при ротации stdout. В контейнере на Kubernetes ваш «вижу же — выводит» уходит в /dev/null после первого rotate.

// ❌
System.out.println("Large: " + amount);

// ✅
log.warn("Large payment detected: amount={}, from={}, to={}", amount, from, to);

Бонус: магическое число 1000000 в if (amount > 1000000) нужно вынести в константу LARGE_PAYMENT_THRESHOLD или @ConfigurationProperties. Иначе через полгода никто не вспомнит, рубли это или копейки.

Частота: очень часто. Канонический пункт чек-листа Code Review.

Баг №4. catch (Exception e) + e.printStackTrace() + утечка ошибок в response

Три проблемы в одном блоке.

Первая — catch (Exception e) глотает абсолютно всё, включая OutOfMemoryError-обёртки, прерывания потоков и checked-исключения, которые мы должны были не глотать, а пробрасывать. Правильная практика — обрабатывать конкретные доменные исключения, а всё остальное отдавать через @ControllerAdvice.

Вторая — e.printStackTrace() пишет в System.err. Это тот же ад, что и с println, только хуже: в Kibana вы свой stack trace не найдёте никогда.

Третья — e.getMessage() уходит клиенту в теле ответа. Если внутри слетел Hibernate, в getMessage() будет половина SQL-запроса с именами таблиц и колонок. Это утечка структуры базы наружу, которую любой пентестер использует первым делом.

// ❌
} catch (Exception e) {
    e.printStackTrace();
    return ResponseEntity.status(500).body(e.getMessage());
}

// ✅ — глобально через @ControllerAdvice
@ExceptionHandler(AntifraudDeclinedException.class)
public ResponseEntity<ProblemDetail> handle(AntifraudDeclinedException ex) {
    log.warn("Antifraud declined", ex);
    return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
        .body(ProblemDetail.forStatusAndDetail(422, "Operation declined"));
}

Частота: часто. Любимый пункт сениоров-интервьюеров — особенно та часть, что про e.getMessage() в response. Но полную тройку проблем в одном блоке кандидаты обычно расчленяют не сразу.

Баг №5. ? this.sendNotification(p) — self-invocation

Вот он — тот самый вопрос №7 из прошлой статьи, в развёрнутом виде и зашитый в реальный код.

В коде есть метод sendNotification, помеченный @Transactional(propagation = REQUIRES_NEW). По задумке, нотификация должна сохраняться в отдельной транзакции — чтобы если основная транзакция платежа откатится, запись о попытке нотификации всё равно осталась. Это типичный аудит-сценарий.

Но вызывается она как this.sendNotification(p) — то есть напрямую через байт-код, минуя Spring-прокси. Аннотация @Transactional(REQUIRES_NEW) молча игнорируется. Никакой новой транзакции не создаётся. Нотификация идёт в той же транзакции, что и платёж. Если основная транзакция падает — запись о нотификации откатывается вместе с платежом.

В проде это выстрелит как «отправили SMS, а платежа в системе нет». Или наоборот — «платёж прошёл, а SMS не отправили». В любом случае служба поддержки получит обращение, разработчик через неделю разведёт руками.

// ❌
this.sendNotification(p);

// ✅ — четыре варианта (по убыванию частоты использования)
// 1. Self-injection через @Lazy
public PaymentController(@Lazy PaymentController self, ...) { this.self = self; }
self.sendNotification(p);

// 2. Вынести в отдельный сервис NotificationOrchestrator
notificationOrchestrator.sendNotification(p);

// 3. AspectJ Mode для @Transactional
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)

// 4. TransactionTemplate вручную
transactionTemplate.execute(status -> { notifications.send(...); return null; });

Частота: один из самых любимых вопросов сениоров-интервьюеров. Спрашивают и устно, и зашитым в код — как здесь. Подвох в том, что @Transactional(REQUIRES_NEW) визуально кричит «я работаю», а на деле молча игнорируется.

? Притормозите на секунду — здесь обычно и проходит граница. Если вы только что поймали себя на мысли «ну я бы сказал REQUIRES_NEW» — вы в хорошей компании: на живых собесах этот баг проходит мимо 9 кандидатов из 10. И самое неприятное — в разных компаниях его прячут по-разному: у ТБанка он зашит в нотификацию, у Альфы — в аудит-лог, у Иннотеха — в вызов соседнего сервиса.

Сделайте одно действие: зайдите в @Java_Jub и напишите в первом сообщении компанию и грейд, куда идёте на собес — например, «Альфа, Middle». Я пришлю, в каком виде эту ловушку дают именно там и на чём в ней срезают. Это тридцать секунд, а на собесе экономит тот самый вопрос, который валит девятерых из десяти.

Баг №6. Antifraud REST-вызов внутри @Transactional

antifraud.check(p) — это, скорее всего, HTTP-запрос во внешний сервис. По дефолту таймаут у RestTemplate или WebClientбесконечность. Если антифрод залип на минуту, ваше соединение с PostgreSQL держится открытым всю эту минуту. У HikariCP по дефолту в пуле двадцать соединений. Двадцать одновременных платежей при залипшем антифроде — и сервис встал колом, потому что новые потоки ждут свободного соединения с БД.

Это классический case study, который любят давать ТБанк (#419, #531) и Иннотех. Лечится либо выносом внешнего вызова до транзакции, либо аккуратно настроенным таймаутом + circuit breaker.

// ✅ — вызвать до открытия транзакции
public ResponseEntity<?> create(...) {
    AntifraudVerdict verdict = antifraud.check(request); // вне транзакции!
    if (verdict.declined()) throw new AntifraudDeclinedException(verdict);

    paymentService.persist(request); // вот теперь @Transactional на сервисе
    ...
}

Частота: часто. Архитектурный класс ловушек — после Code Review его любят перевести в обсуждение «а как у вас в проде, и сколько у вас в HikariCP в пуле».

Баг №7. Kafka внутри транзакции — нет Outbox

repo.save(p) сохраняет платёж в Postgres. kafka.send(...) публикует событие в брокер. Эти два действия должны быть атомарными — либо оба, либо ни одно. Сейчас они не атомарны.

Сценарии, в которых вы попадаете:

  1. БД ответила «commit», Kafka в этот момент недоступна — Kafka-сообщение не уйдёт. Платёж сохранён, downstream-системы (баланс, антифрод-аналитика, BI) о нём не узнают.

  2. Kafka подтвердила приём, БД упала на commit — Kafka-сообщение уже улетело. Downstream вычитает событие, обработает «платёж», на самом деле платежа нет.

Лечение — Transactional Outbox: сохранить в БД и событие, и проводку в одной транзакции, отдельный воркер вычитывает таблицу outbox и публикует в Kafka. Гарантия — at-least-once с идемпотентным consumer’ом.

// ✅ — Outbox-таблица в той же транзакции
@Transactional
public void process(...) {
    repo.save(payment);
    outboxRepo.save(new OutboxEvent("payments", serialize(payment)));
}
// + отдельный @Scheduled / debezium-pipeline publisher

Частота: часто на Middle+ и Senior. Поверхностно — «у вас Kafka в транзакции, это плохо». Глубоко — выходим на Transactional Outbox и сценарии разъезда commit БД и публикации события.

Баг №8. static Map cache — гонка, утечка и stale data

Поле private static Map<UUID, Payment> cache = new HashMap<>(); — это, пожалуй, мой любимый баг во всей задаче.

Во-первых, HashMap не потокобезопасен. В Spring-контроллере, который обрабатывает запросы в нескольких потоках Tomcat (по дефолту двести), параллельный put может привести к бесконечному циклу при resize. До Java 8 это был классический «hashmap loop» с 100% CPU; в Java 8+ это просто потеря данных и NullPointerException при чтении.

Во-вторых, static означает, что ссылка живёт всё время жизни приложения. Платежи в неё добавляются, никогда не удаляются. За неделю в проде там накопится несколько миллионов записей. Это утечка памяти, которая вылезет наружу через две недели OOMKilled-перезагрузками в Kubernetes.

В-третьих, эта кэш-копия будет разъезжаться с реальным состоянием в БД, как только кто-то поменяет платёж напрямую через миграцию или другой инстанс. Stale data в платёжной системе — это эвфемизм для «деньги не там, где надо».

// ❌ — мина замедленного действия
private static Map<UUID, Payment> cache = new HashMap<>();

// ✅ — Caffeine с TTL и max size
private final Cache<UUID, Payment> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .build();

// или вообще убрать — кэшировать на уровне БД через Hibernate L2 cache

Частота: реже остальных, но почти всегда — на Senior-задачах. Это самый Senior-level баг из основной восьмёрки, и его умеют разглядеть только те, кто уже хоть раз ловил у себя production memory leak.


? Бонус-баг №9. Архитектурная катастрофа: нет идемпотентности

Это тот баг, который в формате «найдите 8 проблем» обычно не называют вообще — потому что задача толкает искать локальные ошибки в коде, а не системные пробелы дизайна. Я в своих разборах подготовки под банки видел его упомянутым отдельно считаное число раз — и почти всегда не в Code Review-секции, а на System Design.

Контекст. Клиент нажал «оплатить». Запрос ушёл на сервер. По дороге — connection timeout. Клиент видит «не удалось», нажимает «оплатить» ещё раз. На сервер прилетает второй запрос с теми же данными. Что произойдёт?

В этом коде — два платежа. Деньги списались дважды. Клиент пишет в поддержку, поддержка пишет вам, вы извиняетесь и делаете возврат вручную. В крупном банке это будет повторяться сотни раз в день.

Правильное решение — Idempotency-Key header. Клиент генерирует UUID, кладёт в заголовок Idempotency-Key. Сервер хранит таблицу обработанных ключей с UNIQUE constraint. Повторный запрос с тем же ключом возвращает тот же результат, не создавая нового платежа.

// ✅
@PostMapping
public ResponseEntity<PaymentDto> create(
    @RequestHeader("Idempotency-Key") UUID idempotencyKey,
    @Valid @RequestBody CreatePaymentRequest request) {

    return idempotencyService.executeOnce(idempotencyKey,
        () -> paymentService.create(request));
}

В платёжных интервью этот вопрос отдельно проверяют почти всегда — но как продолжение, не как часть Code Review. Кандидат называет восемь пунктов в коде, доходит до Senior-уровня, но архитектурный пробел в виде «а если фронт повторит запрос» обычно остаётся за кадром. Это не вина кандидата — формат «найдите N багов» сам по себе толкает искать локальные ошибки, а не системные.

Дочитавшие — обратите внимание на это место. Здесь самая большая практическая разница между «прочитал Bloch’а» и «разбирался с прод-инцидентами». Идемпотентность — это не то, что вы выучите за вечер. Это привычка.


Что должен заметить кандидат на каждом уровне

https://images.unsplash.com/photo-1551434678-e076c223a692?w=1200&q=80&auto=format&fit=cro
https://images.unsplash.com/photo-1551434678-e076c223a692?w=1200&q=80&auto=format&fit=cro

Уровень

Минимум, который ждут

Баги №№

Junior

3 бага: field injection, double, System.out

1, 2, 3

Junior+

4-5 багов + поднятый вопрос про error handling

1–4

Middle

6 багов с разбором transactional-границ

1–6

Middle+

7 багов + Outbox-паттерн

1–7

Senior

8 багов + статический cache как анти-паттерн

1–8

Senior+

8 + архитектурный вопрос идемпотентности

1–9

Если вы при разборе сами назвали восемь — поздравляю, у вас уровень Senior-разработчика с опытом code review в проде. Шесть — это уверенный Middle, нормально. Если меньше — обычно дело не в незнании, а в темпе чтения кода: правильная стратегия в любом Code Review-задании — не читать сверху вниз, а сначала бегло пройти весь код, отметить «странности» галочкой на полях, и только потом возвращаться к каждой по очереди. Иначе вы зависаете на третьей строке и до конца просто не доходите.


Топ-25 «зашитых багов» — мини-чек-лист на следующий собес

Я составил список того, что вообще встречается в Code Review-задачах банковских собесов. Сгруппировал по частоте, с которой эти пункты подсвечивают как обязательные при разборе. Не для заучивания — для калибровки. Если вы знаете каждый пункт и знаете как именно он лечится, проблем с этой секцией собеса у вас не будет.

? Развернуть топ-25

Легенда групп: ? — практически всегда (без этих пунктов Code Review-секция собеса не закрывается) ? — часто (типичные пункты для Middle+) ? — регулярно (характерны для Senior-уровневых задач) ? — реже, но иконично (отличают сениоров с прод-опытом)

#

Баг

Группа

1

Field injection вместо constructor + final

?

2

double для денег вместо BigDecimal

?

3

System.out.println вместо SLF4J

?

4

catch (Exception e) + e.printStackTrace()

?

5

Optional.get() без isPresent()

?

6

if (opt.isPresent()) ... else ... вместо .map().orElseGet()

?

7

findAll().stream().filter() вместо findById / existsById

?

8

@Transactional без isolation / propagation / rollbackFor

?

9

REST-вызов внутри @Transactional

?

10

this.method() обходит прокси (self-invocation)

?

11

Kafka.send без Outbox в транзакции БД

?

12

Возврат Entity наружу без DTO

?

13

new RestTemplate() в методе вместо бина с пулом

?

14

static HashMap кэш без потокобезопасности

?

15

Hardcoded URL / config вместо @ConfigurationProperties

?

16

Endpoint без пагинации (@PageableDefault)

?

17

N+1 в Hibernate через .forEach(x -> x.getRelation())

?

18

Нет идемпотентности

?

19

Map<String, Object> как тело запроса вместо typed DTO

?

20

Возврат null из метода вместо Optional

?

21

Нет @Valid на входных DTO

?

22

new Date() вместо Instant.now()

?

23

Магические числа и строки вместо констант

?

24

Использование Date / SimpleDateFormat вместо java.time

?

25

e.getMessage() уходит клиенту наружу

?


Что дальше

В прошлой статье я обещал Senior System Design. Не забыл — он идёт следующим. Но обещаю и кое-что побочное: если в комментариях соберётся хотя бы пятьдесят человек со своим кодом из реальных собесов, я сделаю второй раунд этой задачи — уже с другими, более изощрёнными ловушками. Уровень будет Middle+ / Senior.

https://images.unsplash.com/photo-1542435503-956c469947f6?w=1200&q=80&auto=format&fit=crop
https://images.unsplash.com/photo-1542435503-956c469947f6?w=1200&q=80&auto=format&fit=crop

Свой улов — в комментарии. Какие из восьми багов нашли с первого захода, какие пропустили, что добавили сверху своего. Через сутки лучший комментарий — в закреп, а самые интересные «находки сверх восьми» я разберу в отдельной заметке у себя в канале @Java_Jub.

Особое спасибо тем, кто реально засёк пятнадцать минут таймера и попробовал сам. Если попало в больное место — значит, статья работает. Удачи на следующем собесе. ?


? Кому понравилось

Если зашло — расскажите, и я пишу следующую статью тут же. Если не зашло — тоже расскажите, поправлю формат. Серия про разбор русских банковских собесов продолжается, и я делаю её для вас.

Ваш @Java_Jub

P.S. Это была только верхушка. В моём канале @Java_Jub база уже перевалила за 10 000 вопросов, и под каждую вакансию есть отдельный гайд — что именно спрашивают в конкретной компании на конкретной позиции, с разбором ответов и примерами кода. Заходите: проще готовиться точечно под свой собес, чем учить «топ-50 вопросов».

Комментарии (14)


  1. OlegIct
    08.06.2026 14:18

    Первая — catch (Exception e) глотает абсолютно всё, включая OutOfMemoryError-обёртки

    можно поподробнее что имеете в виду? это же не Throwable


  1. mrsantak
    08.06.2026 14:18

    Формулировка задания и ожидаемые ответы как-то не соответствуют друг другу.

    Считаются найденными только те, что вы можете объяснить вслух: почему это баг, что произойдёт в проде, как исправить

    Тут прям большой акцент на том, что искать нужно только баги, которые вызывают проблему на проде. И первым же пунктом история про Autowired vs constructor injection, которая не является багой и не вызывает проблем на проде. Это уже скорее стилевая проблема, а такие проблемы довольно неоднозначные. Если уж в них закапываться, то тут вам и отсутствие интерфейса, и Map в качестве body, и error reporting в случае всяких bad request’ов никакой, и метрик нет, и т.д. В общем, тут только на таких проблемах легко за 8 можно выйти.

    Ну и ожидать, что кандидат прочитает ваши мысли и догадается, что AntifraudClient - это хождение в соседний сервис по http - это, имхо, такое себе. Точно так же можно и NotificationService заподозрить за хождением в соседний сервис. Тем более, что к интерфейсу AntifraudClient возвращающему “OK” строкой в принципе есть вопросики.

    Про кеш вообще бред какой-то, ибо это не кеш, а непонятно что - в него пишут, но не читают. Его так-то просто удалить нужно, а не на кофеин менять.

    В целом - неплохо, но разбор оставляет ощущение, что ожидалось, что кандидат должен мысли интервьюера прочитать.


    1. atyumentsev
      08.06.2026 14:18

      А еще:

      • этот кэш для каждого инстанса свой,

      • на api отсутствует версионность,

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

      • Сам контроллер, по-хорошему, должен проверять авторизацию, валидировать входные данные, выделять тред на выполнение запроса, но не содержать в себе бизнес-логику,

      • В примерах упущены фигурные скобки на однострочные блоки кода,

      • Неспецифический RuntimeException для весьма понятной ошибки. В той же кибане проще будет собирать статистику по специфичным ошибкам,

      • … и так далее.

      В целом - все не так плохо, можно довольно быстро поправить, причем стандартные линтеры (Sonar, Qodana) отловят заметную часть этих проблем.

      Бывает, дают такие примеры, что аж руки опускаются, насколько все запутано.


  1. alexander-java
    08.06.2026 14:18

    А что-то аналогичное есть в виде книги?


    1. alexander-java
      08.06.2026 14:18

      И, в целом, про банковский домен и обеспечение надёжности?


  1. svz
    08.06.2026 14:18

    Ощущение, что написано пополам с нейронкой. Сам по себе набор ошибок подобран неплохо. Есть вопросы к реализации.

    Я правильно понимаю, что транзакция в create() никогда не откатится, потому что обёртка реагирует на исключения, а вы их все проглотили? Это не упоминается в тексте, хотя кажется серьёзным дефектом.

    sendNotification() по неймингу выглядит как отправка сообщения во внешний канал, где транзакция в принципе бессмысленна, а не запись в бд.

    Про кеш упомянули - непонятно зачем он и как вообще используется.

    Набор ошибок хороший, пример стоит доработать.


    1. alexander-java
      08.06.2026 14:18

      Насчёт транзакции в create. Исключение может кинутся при попытке зафиксировать транзакцию. Это будет в прокси объекте, и, соответственно, обработчик исключений в этом методе не будет задействован.


  1. Snaret
    08.06.2026 14:18

    Спасибо за код. Если это действительно из собесов, то тут поле не паханное))

    Каюсь, сначала нашел не то что вы указали)

    Но предлагаю в список добавить и эти (сори не читал комментарии может уже что-то есть):

    Отсутствие версионирования (/v1/payments) - API без версии ломает обратную совместимость при любом изменении. Клиенты, завязанные на этой версии, перестанут работать. Вообще показывает способность к расширению

    ResponseEntity<?>
    сырой дженерик теряет информацию о типе тела ответа. В Swagger/OpenAPI это превратится в object, клиенты не смогут сгенерировать типизированные модели

    @RequestBody Map<String, Object> 
    теряется контракт API. Нет @Valid, нет документации полей, нет контроля типов. Любое изменение структуры ломается в рантайме ClassCastException

    new Date()
    java.util.Date - мутабельный, устаревший класс, не рекомендуется к использованию хотя и допустимф

    p.setStatus("PENDING")
    строковый литерал для статуса - это нонсенс. Опечатка ("PENING") не будет поймана компилятором, думаю выводы сделаете сами)

    !"OK".equals(r)
    Не совсем плохо но лучше - !Objects.equals("OK", r), более явно проверяет что r != null

    Вызов kafka.send без обработки результата
    метод асинхронный, но исключения теряются. Нужно добавить callback или использовать синхронную отправку.

    RuntimeException - слишком общее исключение. Оно не говорит вызывающему коду (и глобальному обработчику) о том, что именно пошло не так. Лучше - кастомную

    Далее более архитектурная проблема:

    @Transactional на методе контроллера и отсутствие сервисного слоя
    Когда вы ставите @Transactional на контроллер, контроллер берёт на себя ответственность за управление транзакциями - это нарушает принцип SR
    Что ждет с таким подходом:
    - нормально не протестировать - придется кучу зависимостей тянуть и все либо мокать либо подымать
    - невозможно переиспользовать логику, для написания похожей обработки - новая ручка
    - расширение приведет только к росту проблем




  1. asamson
    08.06.2026 14:18

    Вообще, обычно в контроллере нет такой каши - вся логика должна быть вынесена в сервис. И однобуквенных переменных быть не должно. Ну и так, по мелочи - в @RequestMapping в пути отсутствует версия api


  1. Yuri_BY
    08.06.2026 14:18

    Вот так ответила локальная gemma-4-12B-it-Q5_K_M:

    1. Архитектурные проблемы (High Severity)

    А. Проблема "Dual Write" (Консистентность данных)

    • Почему это баг: Код пытается одновременно обновить базу данных (repo.save), отправить сообщение в Kafka (kafka.send) и отправить уведомление (notifications.send). Эти системы не связаны в единую распределенную транзакцию.

    • Что произойдет в проде: Если repo.save(p) пройдет успешно, но Kafka упадет или произойдет сетевой сбой при отправке сообщения, платеж будет создан в БД, но система обработки заказов/логистики его не увидит. И наоборот: сообщение уйдет, а транзакция в БД откатится. Это приведет к "фантомным" платежам или потере заказов.

    • Как исправить: Использовать паттерн Transactional Outbox. Сохраняйте событие (сообщение для Kafka) в ту же БД в рамках одной транзакции с платежом. Отдельный сервис (Relay) будет читать эти события и отправлять их в Kafka.

    Б. Самовызов транзакционного метода (Self-invocation)

    • Почему это баг: Метод sendNotification помечен @Transactional(propagation = Propagation.REQUIRES_NEW), но он вызывается внутри метода create через this.sendNotification(p).

    • Что произойдет в проде: Spring использует AOP-прокси для управления транзакциями. При вызове через this прокси-объект обходится, и аннотация @Transactional игнорируется. Уведомление будет выполняться в той же транзакции, что и основной метод create. Если уведомление упадет, откатится весь платеж (и наоборот).

    • Как исправить: Вынести sendNotification в отдельный сервис (например, NotificationServiceили новый NotificationManager) и внедрить его в контроллер.

    В. Утечка соединений БД (Connection Pool Exhaustion)

    • Почему это баг: Аннотация @Transactional стоит на всем методе create. Внутри этого метода вызывается antifraud.check(p). Если внешний сервис антифрода работает медленно (например, 2-5 секунд), соединение с базой данных будет удерживаться всё это время.

    • Что произойдет в проде: При росте нагрузки количество свободных соединений в пуле (HikariCP) быстро закончится. Система начнет выдавать ошибки ConnectionTimeout, и приложение перестанет принимать новые платежи.

    • Как исправить: Вынести вызов antifraud.check(p) за пределы транзакции. Сначала проверяем антифрод, и только если он "OK", открываем транзакцию для сохранения в БД.

    2. Проблемы многопоточности и памяти (Medium/High Severity)

    А. Непотокобезопасный кэш и утечка памяти

    • Почему это баг: private static Map<UUID, Payment> cache = new HashMap<>();

    • Что произойдет в проде:

      1. Race Condition: HashMap не потокобезопасна. Одновременная запись из разных потоков может привести к бесконечному циклу (зависание CPU) или повреждению структуры данных.

      2. Memory Leak: Карта никогда не очищается. С каждым новым платежом память будет забиваться, пока приложение не упадет с OutOfMemoryError.

    • Как исправить: Использовать распределенный кэш (Redis) или локальный кэш с политикой вытеснения и TTL (например, Caffeine или Guava Cache).

    3. Проблемы кода и данных (Medium Severity)

    А. Использование double для финансовых операций

    • Почему это баг: Тип double использует двоичную плавающую запятую, которая не может точно точно выразить десятичные дроби (например, 0.1 + 0.2 != 0.3).

    • Что произойдет в проде: Накопление микроскопических ошибок при расчетах. В финансовой системе это недопустимо и приведет к расхождениям в балансах.

    • Как исправить: Использовать BigDecimal с указанием RoundingMode.

    Б. Слабая типизация входных данных

    • Почему это баг: Использование Map<String, Object> body и ручное приведение типов (double) body.get("amount").

    • Что произойдет в проде: Если клиент пришлет число как целое (Integer) в JSON, body.get("amount")вернет Integer, и попытка привести его к double вызовет ClassCastException (500 ошибка). Также нет валидации (например, отрицательный amount).

    • Как исправить: Создать DTO класс (например, PaymentRequest) с использованием @Valid и аннотаций @Positive@NotNull.

    В. Некорректное логирование и обработка ошибок

    • Почему это баг: e.printStackTrace() и return ... body(e.getMessage()).

    • Что произойдет в проде:

      1. printStackTrace() не пишет в стандартные системы сбора логов (ELK, Graylog) и не позволяет фильтровать ошибки.

      2. e.getMessage() может содержать конфиденциальные данные (SQL ошибки, пути к файлам, данные БД), что является дырой в безопасности.

    • Как исправить: Использовать SLF4J (log.error("...", e)), создать @ControllerAdvice для обработки исключений и возвращать клиенту понятный, безопасный код ошибки.

    Г. Использование System.out.println

    • Почему это баг: Это синхронная операция, которая может замедлять работу приложения под нагрузкой.

    • Что произойдет в проде: Засорение стандартного вывода и потеря возможности управлять уровнем логирования.

    • Как исправить: Заменить на log.info().

    Резюме (Checklist для рефакторинга):

    1.  Заменить double на BigDecimal.

    2.  Заменить Map<String, Object> на PaymentRequestDTO.

    3.  Вынести antifraud.check за пределы @Transactional.

    4.  Перенести sendNotification в отдельный сервис.

    5.  Удалить static HashMap и заменить на Redis/Caffeine.

    6.  Реализовать Transactional Outbox для Kafka.

    7.  Заменить System.out и printStackTrace на нормальный логгер.

    8.  Добавить валидацию входных данных.

    Jun 9, 2026, 10:30 AM

    31 tokens/sec

    (3208 tokens)


    1. Yuri_BY
      08.06.2026 14:18

      ibm-granite_granite-4.1-8b-Q6_K_L/ibm-granite_granite-4.1-8b-Q6_K_L нашла 10!

      Общие рекомендации для продукта

      Export

      КатегорияРекомендацияПричинаПример реализацииВалидацияДобавить @Valid и DTO для запросаУстранить неопределенность типов и отсутствие проверок@Valid @RequestBody PaymentRequest bodyЛогированиеЗаменить System.out на SLF4JУлучшить контроль уровней и централизацию логовlogger.info("Large payment: {}", amount);ТранзакцииСделать sendNotificationчастью одной транзакции или очистить кэш при ошибкеИзбежать несогласованного состояния@Transactional на sendNotificationКэшИспользовать Spring Cache или ConcurrentHashMapс TTLУстранить гонки и утечки памяти@Cacheable("payments")ИдемпотентностьТребовать Idempotency-KeyПредотвратить дубликаты платежей@RequestHeader("Idempotency-Key")Обработка исключенийРазделить перехваты на конкретные типыПредоставить точные ошибки клиентамcatch (AntifraudException e) { … }Сообщения KafkaОтправлять структурированный JSON без PIIСоответствовать требованиям безопасностиkafka.send("payments", new PaymentEvent(...))Повторы/Обратный отсчетДобавить Retry и Backoff для асинхронных вызововУстойчивость к временным сетевым сбоям@Retryable(...)МониторингЭкспортировать метрики (создание платежей, успех антифрауда, задержки Kafka)Повысить отказоустойчивость и быстрое реагированиеSpring Actuator, Prometheus

      Применение этих исправлений сделает контроллер PaymentController более надежным, масштабируемым, соблюдающим требования безопасности и легко тестируемым в производственной среде.

      Jun 9, 2026, 10:42 AM

      29 tokens/sec

      (3457 tokens)


  1. s366315
    08.06.2026 14:18

    if (opt.isPresent()) ... else ... вместо .map().orElseGet()

    Придираться к таком эту уже жлобство.


  1. amarkevich
    08.06.2026 14:18

    У HikariCP по дефолту в пуле двадцать соединений

    10


  1. guestfromEarth
    08.06.2026 14:18

    Благодарю, набор вполне стандартный и полноценный. Тут вдогонку еще валидация amount на уровне API, Map лучше в ДТО. Еще - сервис уведомлений - черный ящик. Важен контекст. Здесь они отправляются сразу, или кладутся куда-нибудь в Redis, например? Тут может потребоваться его отмена в случае отката, то есть компенсирующая операция.