Маленькое признание перед тем, как мы начнём
В прошлой статье я обещал, что следующим возьмусь за 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, и — что хуже всего — позволяет случайно мутировать зависимость через рефлексию.
// ❌@Autowiredprivate 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 через @Lazypublic PaymentController(@Lazy PaymentController self, ...) { this.self = self; }self.sendNotification(p);// 2. Вынести в отдельный сервис NotificationOrchestratornotificationOrchestrator.sendNotification(p);// 3. AspectJ Mode для @Transactional@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)// 4. TransactionTemplate вручнуюtransactionTemplate.execute(status -> { notifications.send(...); return null; });
Частота: один из самых любимых вопросов сениоров-интервьюеров. Спрашивают и устно, и зашитым в код — как здесь. Подвох в том, что @Transactional(REQUIRES_NEW) визуально кричит «я работаю», а на деле молча игнорируется.
Баг №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-таблица в той же транзакции@Transactionalpublic 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 sizeprivate 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. Повторный запрос с тем же ключом возвращает тот же результат, не создавая нового платежа.
// ✅@PostMappingpublic 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 вопросов».
ссылка на оригинал статьи https://habr.com/ru/articles/1045050/