
Маленькое признание перед тем, как мы начнём
В прошлой статье я обещал, что следующим возьмусь за 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"); } }

Подсказка: прежде чем читать дальше, прокрутите вверх и реально посмотрите на код пятнадцать минут. Иначе разбор не отложится — глаза просто пробегут готовые ответы.
Готовы? Поехали.
Разбор. Восемь багов от 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(...) публикует событие в брокер. Эти два действия должны быть атомарными — либо оба, либо ни одно. Сейчас они не атомарны.
Сценарии, в которых вы попадаете:
БД ответила «commit», Kafka в этот момент недоступна — Kafka-сообщение не уйдёт. Платёж сохранён, downstream-системы (баланс, антифрод-аналитика, BI) о нём не узнают.
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’а» и «разбирался с прод-инцидентами». Идемпотентность — это не то, что вы выучите за вечер. Это привычка.
Что должен заметить кандидат на каждом уровне

Уровень | Минимум, который ждут | Баги №№ |
|---|---|---|
Junior | 3 бага: field injection, | 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 + | 🔴 |
2 |
| 🔴 |
3 |
| 🔴 |
4 |
| 🔴 |
5 |
| 🔴 |
6 |
| 🟠 |
7 |
| 🟠 |
8 |
| 🟠 |
9 | REST-вызов внутри | 🟠 |
10 |
| 🟠 |
11 | Kafka.send без Outbox в транзакции БД | 🟠 |
12 | Возврат | 🟠 |
13 |
| 🟠 |
14 |
| 🟡 |
15 | Hardcoded URL / config вместо | 🟡 |
16 | Endpoint без пагинации ( | 🟡 |
17 | N+1 в Hibernate через | 🟡 |
18 | Нет идемпотентности | 🟡 |
19 |
| 🟡 |
20 | Возврат | 🟢 |
21 | Нет | 🟢 |
22 |
| 🟢 |
23 | Магические числа и строки вместо констант | 🟢 |
24 | Использование | 🟢 |
25 |
| 🟢 |
Что дальше
В прошлой статье я обещал Senior System Design. Не забыл — он идёт следующим. Но обещаю и кое-что побочное: если в комментариях соберётся хотя бы пятьдесят человек со своим кодом из реальных собесов, я сделаю второй раунд этой задачи — уже с другими, более изощрёнными ловушками. Уровень будет Middle+ / Senior.

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