Эту задачу спрашивают в ASTON, Альфе и Совкомбанке на java-собесе. Найдите 8 багов в 47 строках Spring — попробуйте сами

от автора

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, и — что хуже всего — позволяет случайно мутировать зависимость через рефлексию.

// ❌@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(...) публикует событие в брокер. Эти два действия должны быть атомарными — либо оба, либо ни одно. Сейчас они не атомарны.

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

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

  2. 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’а» и «разбирался с прод-инцидентами». Идемпотентность — это не то, что вы выучите за вечер. Это привычка.


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

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 вопросов».

ссылка на оригинал статьи https://habr.com/ru/articles/1045050/