
Анализ, архитектура, вайбкодинг и даже комиксы — всё это здесь.
Цели статьи: 1) проверить жизнеспособность концепции максимально простого и дешевого интернет-банкинга и заодно 2) протестировать возможности ИИ в качестве инструмента прототипирования.
Спойлер: пожертвовал брокерами сообщений и Redis. В итоге, на бэке только сервисы (Go) с реляционными БД (PostgreSQL).
Вот, что получилось - https://justabank.ru
Что касается ИИ: использовал поочередно Claude Sonnet 4.5, deepseek reasoner, gpt-5.4.
Теперь к делу…
Наверное, не надо объяснять почему хочется просто и дёшево. Но надо проверить, будет ли “просто” вести к “дешево” и получится ли такой интернет-банкинг (ИБ) хорошим.
Хороший, в моем понимании, — это когда ИБ функциональный (делает то, что нужно клиенту), дешевый, надежный, масштабируемый и быстро расширяемый по функциональности.
Начнем…
Определим минимальные функциональные требования для нашего пилотного ИБ. Что он должен уметь:
отображать счета,
создавать счета до востребования,
переводить деньги между счетами,
выдавать кредиты (сделать примитивную выдачу просто, чтобы на счетах появились деньги).
Нет цели во всех подробностях воссоздать банковский бэкенд. Цель – проверить концепцию возможности создания очень простого и дешевого ИБ.
Желания.
То, что в обычных сложных и душных банках называют распоряжениями, поручениями и заказами (orders), я в своем простом проекте ИБ буду называть “желаниями”. Кроме того, что это понятие простое (а у меня все просто), такое название лучше всего отражает суть данной сущности. Ее смысл зафиксировать, что клиент хочет от банка, т.е. это буквально желание клиента.
Можно даже сравнить банковское приложение со сказочным джинном: вместо лампы трем телефон, появляется джин приложение, говорим желание (перевести деньги, взять кредит) и оно исполняется. Да, спектр желаний не тот, что у джинна, но зато исполнение желаний под контролем регуляторов.
Вообще, я считаю, что функционал предоставления клиенту возможности рассказать банку о его намерениях (желаниях) касательно банковских продуктов/услуг — это главная функция системы интернет-банкинга.
И эта функция простая, ведь технически, задача ИБ сводится к тому, чтобы подготовить и передать в банковский бэкенд json вида:
{ “wish”: { “some_parameter”: “some value”, “another_parameter”: “some value”, … } }
Хочу подчеркнуть, цель существования ИБ – обеспечить поступление в банк “wish-json’ов”, которые понятны сервисам-исполнителям желаний, и которые банк может исполнить с юридической и технической точек зрения.
Т. е. первое на чем я предлагаю сфокусироваться — это формирование желаний. Что интересно, это действительно очень просто. Процесс выглядит так:

Кроме удобства формирования желаний, важно сделать удобное и понятное подтверждение желаний. Чтобы не получилось, как в анекдоте про золотую рыбку (“... мужики, давайте еще раз ее поймаем, а то я какую-то фигню загадал...”). Клиенту нужна уверенность, что банк исполнит желание именно так, как оно описано, а банку нужно убедиться в том, что клиент ознакомился со всеми параметрами желания и они его устраивают.
На этом формирование желания закончено. Дальше исполнение, которое относится больше к “банк”, чем к “интернет”. Важно понимать, где заканчивается желание и начинается исполнение. Даже если в банке “Гринготтс” нет никакой банковской бэкенд системы, а счета и вклады ведут гоблины, делая записи в больших бухгалтерских книгах, все равно там можно сделать ИБ: веб/мобильное приложение + сервис, который будет высылать сформированный “wish”: {...} на email гоблина.
Далее, периодически, я буду приводить правила (информация, которую ИИ держит в голове при обработке промптов) в сокращенном виде, но я проверял, что их достаточно, чтобы ИИ правильно понимал промпты и адекватно запрашивал уточнения.
В файле правил ИИ опишем концепцию желаний:
Скрытый текст
## Концепция желаний (Wishes)
Правило: Банковская операция выполняется только через желание
- Клиент никогда не вызывает операцию напрямую — он создаёт желание (Wish)
- Желание проходит полный жизненный цикл: черновик → подтверждение → исполнение
Правило: Жизненный цикл желания
- draft — клиент заполняет форму; поля сохраняются инкрементально через SetField
- pending — форма отправлена: PDF сгенерирован, код подтверждения отправлен на email (TTL 10 мин)
- executing — клиент успешно подтвердил операцию, после чего запущено исполнение
- completed — polling-job обнаружил успех исполнения
- cancelled — polling-job обнаружил отмену исполнения (компенсация прошла)
- failed — polling-job обнаружил ошибку исполнения (dirty state) или истёк TTL pending
Универсально описанные желания.
Универсальность желаний особенно важная вещь. Желания на исполнение могут принимать разные сервисы – если каждый будет делать это по-своему, то будет дорого и сложно разрабатывать эти сервисы, поддерживать их и писать к ним UI. Поэтому нужно это дело стандартизировать.
Скрытый текст
Правило: Универсальная форма желания
- Все операции описываются через единый формат
- Каждое поле содержит метаданные для отображения (тип, название, …)
- Форма возвращается фронтенду как есть — включая опции select, текущие значения и ошибки валидации
Подтверждение желаний.
Тема важная, но в данной статье я не хочу смещать фокус читателя в сторону юридических и технических тонкостей процесса подтверждения операций. Ограничусь заметкой, что в данном приложении я применил свой, как мне кажется простой и дешевый вариант подтверждения операций, т.е. это не пример как надо делать, а просто видение реализации, которое отличается от наиболее распространенных банковских решений. Короче говоря, это кастомная реализация подтверждения операции кодом.
Исполнение желаний.
Исполнение желания должно быть реализовано как единый бизнес-процесс с явным состоянием, компенсациями и финальным согласованным результатом. Важно понимать, что, приняв желание, банк берет на себя обязательство либо исполнить его полностью, либо не исполнить вообще. Частичное исполнение не должно считаться успешным; при сбое система обязана компенсировать выполненные шаги или перевести исполнение в состояние ручного разбора.
Важный аспект: регистрация желаний — это интерактивный процесс взаимодействия с клиентом, а исполнение — это чистый бэкенд под капотом. Эти процессы нужно разделять. У них разная ответственность, сложность, исполнители и характер нагрузки. Например, желания сложнее масштабировать, чем исполнения; исполнения можно поставить в очередь, а желания у тысяч клиентов могут возникнуть одновременно. В “зарплатный день” сильно больше желаний сделать перевод или открыть вклад, чем за день до этого.
Скрытый текст
Правило: Сага-оркестратор
- Инфраструктурный движок саги вынесен в отдельную библиотеку
- Движок не знает про Wish — он работает только с Execution и SagaStep
- Доменные шаги реализуются в каждом сервисе-исполнителе через интерфейс StepHandler:Execute и Compensate
- Новый сервис регистрирует обработчики через orchestrator.New(...).WithStepHandler(...)
- Шаги саги персистируются в таблице processing.saga_steps до начала исполнения
- Каждый шаг имеет статус: pending → executing → completed / failed
- При ошибке шага — компенсация выполненных шагов в обратном порядке через Compensate
- Опрос статуса внешних операций (polling) — интервал 500ms, таймаут контекста 2 минуты
- Оркестратор обновляет только execution.status; wish.status не трогает
Правило: Синхронизация статуса желания (polling-job)
- Сервис-исполнитель содержит фоновый job runStatusSyncJob с интервалом 2 секунды
- Job опрашивает все желания в статусе executing с непустым execution_id
- Для каждой пары (wish_id, execution_id) запрашивает execution.status
- Если статус финальный (completed/cancelled/failed) — обновляет wish.status соответственно
Правило: Документы желания
- PDF генерируется в момент перехода draft → pending (до отправки кода)
Правило: Execution.Payload
- Execution не хранит wish_id — связь только в обратную сторону (wishes.execution_id)
- Оркестратор и хендлеры шагов берут все параметры операции из execution.payload
Ближе к делу. Набросаем архитектуру.
Банк – не банк, если у него нет бухгалтерской системы. Такая система — это сервис (или группа сервисов), в котором живут счета и проводки. В нашем прототипе это будет сервис accounts.
Это сердце банка. Он ни в коем случае не должен быть доступен снаружи. В сервисе живут не только счета, которые мы хотим показывать на дашборде ИБ, но и технические счета. Это вообще может быть коробочная система, которую нельзя дорабатывать. Accounts не обязан знать про желания клиента, но должен предоставлять внутренний api для действий с сущностями, за которые отвечает (счета, проводки).
Дашборд клиентского приложения (пусть это будет какое-нибудь SPA) нуждается в сервисе, который предоставит api получения счетов. Назовем этот сервис “funds”. В нем же я реализую api формирования желаний переводов, ведь этот сервис отвечает за манипулирование деньгами клиентов. Здесь, я так же реализую формирование желаний создания счетов и кредитов.
Почему бы и нет? Нет, потому что funds не владелец счетов и кредитов. В настоящем банке будут отдельные домены счетов, вкладов и кредитов, но в этом проекте я упрощу доменную модель, поскольку сейчас мне важно проверить реализацию создания и исполнения желаний на примере переводов между счетами. А создание счетов и кредитов в моей реализации — это вспомогательные элементы. Каким образом funds будет получать данные по счетам клиента – это отдельная песня, которую обсудим позже.
Сервис авторизации. Куда же без него. Сделаю двухфакторную авторизацию через почту.
Отдельный микросервис уведомлений делать не буду, ограничусь общей библиотекой отправки email. У меня же всё максимально просто.
Итак, архитектура такая:

Само собой сервисы stateless и работают в multi instance кластере. Cостояние находится в БД, а фоновые процессы должны быть идемпотентны или защищены offset/locking. Поднимать буду просто в docker контейнерах. Пока обойдемся без кубера.
Поскольку проект простой выберу простой язык - Go. Он действительно простой, популярный, много библиотек, много успешных проектов. Я никогда на нем не писал, но от этого только интереснее.
Почему не python? Ведь он считается простым. Дело вот в чем:
Скрытый текст
Python мне просто не нравится.
СУБД - Postgres. Простая классика, зарекомендовавшая себя в финтехе.
Транспорт.
Внешний api особо без вариантов - http. Внутренний межсервисный синхронный транспорт - gRPC + protobuf, поскольку преимущества над http очевидны (не буду копировать сюда результаты гугления “rest vs grpc”), а усложнений практически не несёт и все знают эту технологию.
Брокеры сообщений.
Kafka, по моим личным наблюдениям, в качестве mission critical брокера сообщений это вещь очень дорогая во всех смыслах: и по железу, и по обслуживанию. В интернете есть расчеты и объяснения почему сочетание “Kafka уровня критичности MC + большая пропускная способность” это дорого. Это дорого даже для Kafka в качестве брокера.
RabbitMq, опять же по моим наблюдениям, сильно дешевле, но тем не менее это отдельный общий компонент архитектуры, который её усложняет.
Думаю, стоит проверить, можно ли построить систему без брокеров, и не приведет ли такая попытка упрощения и удешевления к обратному эффекту – к дорогому усложнению.
Да кому эти брокеры сообщений вообще нужны?
Сначала комикс (рисовал GPT), а потом разберем этот вопрос вместе с ИИ…

Спросим у ИИ (ChatGPT): “Скажи мне ИИшечка, зачем люди используют брокеры сообщений?”
Спойлер, краткий итог моей переписки с ИИ (моя интерпретация): Для сообщений типа “команда” (речь о конкретном кейсе реализации банковских операций) – лучше RPC. Для сообщений типа: “событие-факт” – брокер.
Т.е. в контексте разработки банковского бэкенда мы с ИИ пришли к выводу (возможно, сказалось мое психологическое давление на ИИ), что исполнение операции лучше сделать по схеме: saga + rpc polling сервисов исполнителей. А, к примеру, отгрузку событий изменения баланса клиентских счетов для обновления дашборда лучше сделать с использованием брокера. Но я сделаю все без брокера, просто потому что интересно насколько сложным получится итоговое решение репликации событий.
Подробная переписка с ИИ. Она длинная, поэтому можете “перемотать” к выводу, но мне кажется, что это интересный диалог и его стоит посмотреть.
Скрытый текст
ИИ:
🚀 Зачем они реально нужны
1. Развязка сервисов (decoupling)
👉 A не знает, кто читает сообщения
👉 B может упасть — A продолжит работать2. Надёжность (не теряем данные)
Если сервис-получатель недоступен:
сообщение лежит в очереди
обработается позже
3. Асинхронность (ускорение системы)
Вместо: ждать ответ 5 сервисов
Ты: отправил событие и пошёл дальше
👉 быстрее UX
👉 меньше таймаутов4. Масштабирование
Если нагрузка растёт:
добавляешь ещё consumer'ов
брокер распределяет сообщения
👉 горизонтальный рост без боли
5. Буферизация нагрузки
Если внезапный пик:
брокер “гасит волну”
система не падает
6. Event-driven архитектура
Можно строить систему как поток событий:
"Платёж создан" →
"Платёж проверен" →
"Платёж отправлен"
👉 удобно для сложных бизнес-процессов
7. Логи и аудит
Особенно в Kafka:
сообщения хранятся
можно “перепроиграть” события
👉 очень полезно в банках
Пример из твоей темы (платёж)
Без брокера:
сервис платёжки вызывает антифрод, бухгалтерию, уведомления
если что-то упало — всё ломается
С брокером:
"payment_created" → брокер →
- antifraud
- accounting
- notifications
👉 каждый сервис живёт своей жизнью
Я:
Давай разберемся на примере платежного функционала… Сравним с реализацией исполнения поручения, когда сага “платеж” в сервисе платежей оркестрирует исполнение поручения вызывая только rpc api, который предоставляет возможность ставить запросы во внутреннюю очередь исполнителя, а сага полингом узнает статус запросов и получив нужный результат двигает сагу дальше.
В кейсе с брокером сервис создающий платеж посылает сообщение-команду в antifraud “проверь поручение”, в accounting - “сделай проводку”, в notification - “оповести клиента об успехе”. На практике все эти сообщения должны строго соответствовать контрактам сервисов исполнителей. Эти контракты сервисы исполнители публикуют для сервисов потребителей (в какой-то общедоступный репозиторий контрактов). Потребители знают кто исполнитель этих команд, потому что в процессе разработки/отладки/тестирования/написания интеграционных и e2e тестов разработчики поднимают эти сервисы или хотя бы проверяют их работу.
Поэтому “A не знает, кто читает сообщения” - это, мягко говоря, лукавство.
“B может упасть — A продолжит работать” - да, но это вызовет проблемы если сообщения накопятся, а ретеншн их почистит.
Не лучше ли в случае rpc api остановить выполнение платежа и использовать ретрай (или просто отменить исполнение всей операции) вместо того, чтобы надеяться на транспорт?
“Надёжность (не теряем данные)”
Опять же, большие топики/очереди — это иллюзия - они слишком дорогие. Ретеншн по времени и размеру все равно придется настроить.
“отправил событие и пошёл дальше”
rpc api можно реализовать тоже асинхронно с полингом. Для исполнения поручения платежа всё равно нужно делать сагу (иначе не получится согласованная бизнес-транзакция) - она сможет сделать два вызова “поставь платеж в очередь”, “дай статус по платежу”. Пусть очередь будет на стороне исполнителя - более четкая ответственность, более надёжный и однонаправленный транспорт (запросы всегда типа: платежи -> accounting, платежи -> antifraud). Так точно более надежно. Разве нет?
“4. Масштабирование…”
В случае с rpc api и внутренней очередью все то же самое ведь сервисы stateless - просто поднимай новые экземпляры сервисов и получай масштабируемость.
“5. Буферизация нагрузки…”
Разве буферизировать нагрузку на общей инфраструктуре лучше, чем делать это на стороне исполнителя? Разве правильно доверять распределение нагрузки в транспортной системе?
“6. Event-driven архитектура
Можно строить систему как поток событий:”
Для банковской системы нужна надёжность и предсказуемость. Нужно четкое последовательное исполнение команд. Сам по себе поток событий не нужен. Нужны гарантии доставки и исполнения.
“7. Логи и аудит…”
Логи хранятся в эластике. В кафе хранить все события очень дорого. Никто в банках не переигрывает события. Особенно в операционных системах. Переигрывать может быть полезно для аналитических систем, но опять же это дорого.
ИИ:
ИИ:
Разберу это как инженерный спор, а не как рекламу брокеров: у тебя уже есть сильная альтернатива, и она во многих банковских сценариях правда лучше.
Ты очень точно раскритиковал “брокерную проповедь”. Во многом я с тобой согласен: для исполнения платёжного поручения брокер сообщений очень часто не даёт магического преимущества, а иногда делает систему хуже.
Ниже мой честный вывод на твоём примере
Сначала главный тезис
Для core-процесса типа “исполнить платёжное поручение” обычно важнее:
явная оркестрация
предсказуемый порядок шагов
управляемые ретраи
идемпотентность
наблюдаемость статуса
чёткая ответственность сервиса-исполнителя
И под это твоя модель:payment saga -> RPC "enqueue/check" -> internal executor queue
выглядит очень здраво.
Во многих банках я бы предпочёл именно её для операционного контура.
Где ты прав по сути1. “A не знает, кто читает сообщения” — действительно лукавство
Да, в реальной системе это почти всегда неправда.
На практике producer знает:
какой bounded context исполняет команду
какой контракт он использует
кто владелец схемы
где искать тестовый стенд
кто дежурит при падении интеграции
То есть “полная развязка” — миф.
Есть не runtime coupling, но есть очень сильный:
semantic coupling
contract coupling
operational coupling
И для команд это особенно заметно.
Команда:
"accounting, сделай проводку”
уже означает, что отправитель прекрасно знает исполнителя.
Так что тут ты прав: для command-driven flow брокер не убирает связанность, а в основном меняет её форму.
2. “B может упасть — A продолжит работать” не всегда благо
Тоже согласен.
Для платежа “продолжить работать” может означать:
нагенерили хвост необработанных команд
потеряли SLA
создали лавину ретраев
уткнулись в retention / DLQ / backpressure
То есть это не бесплатная отказоустойчивость, а перенос проблемы во времени.
Иногда это полезно.
Но для платёжного процесса часто честнее сказать: “Шаг accounting сейчас недоступен, платёж завис в статусе PENDING_ACCOUNTING”,
чем “мы радостно приняли 500k сообщений в транспорт”.
3. Надёжность “за счёт транспорта” часто переоценена
Да.
Брокер не отменяет:
идемпотентность
дедупликацию
poison messages
частичные сбои
order guarantees
exactly-once illusions
reconciliation
Он лишь даёт один слой durability.
Но бизнес-надёжность всё равно живёт в приложении.
В платёжке почти всегда нужно уметь сказать:
какой именно шаг выполнен
какой не выполнен
что можно безопасно повторить
кто владеет retry policy
как сделать ручное восстановление
И это удобнее, когда у тебя orchestration явная, а не размазана по топикам.
4. Буферизация на стороне исполнителя часто лучше
Тут я тоже скорее на твоей стороне.
Если antifraud знает:
сколько он реально может переварить
какие приоритеты важнее
какие типы проверок дорогие
как делать degradation
Потому что очередь исполнителя учитывает семантику работы, а брокер — в основном только доставку.
Для банка это очень важное различие.
5. Replay в операционном контуре правда не главный аргумент
Согласен.
В банке “давайте переиграем поток платежей” звучит не как фича, а как потенциальный инцидент.
Replay полезен в:
аналитике
materialized views
downstream projections
мониторинге
anti-fraud feature pipelines
Но в контуре исполнения денег replay — это редко основной паттерн.
Где я бы всё же возразил тебе
Не по сути, а по границам применимости.
1. RPC + polling тоже не бесплатно
Твоя схема хорошая, но у неё есть цена.
Ты покупаешь:
явный orchestration
понятный state machine
чёткий контроль
Но платишь:
polling storm
много технических статусов
отдельная логика очередей у каждого исполнителя
дублирование retry semantics по сервисам
дублирование протокола enqueue/status/cancel/result
сложный timeout management
“расползание” внутреннего job model по всей системе
То есть это не “просто RPC”.
Это фактически стандартизованный асинхронный command protocol поверх RPC.
И если таких интеграций десятки, твоя система незаметно строит собственный мини-брокер, только распределённо и по-разному в каждом сервисе.
Вот это главный контраргумент.
2. Если исполнителей много, orchestration через RPC начинает тяжелеть
Если у тебя процесс реально линейный:
payment -> antifraud -> accounting -> notification
RPC enqueue/status очень хорош.
Но если появляется:
несколько независимых подписчиков
необязательные реакции
вторичные процессы
fan-out в audit, AML, reporting, customer timeline, ops metrics
то брокер или event bus начинают выигрывать.
Не для команд ядра, а для побочных реакций на факт.
Например:
PaymentExecuted
это очень хороший кандидат на событие, которое читают:
notifications
analytics
CRM timeline
customer statement builder
fraud feedback loop
Вот тут RPC orchestration уже выглядит искусственно.
3. Команды и события нельзя смешивать в одну корзину
У тебя критика в основном бьёт по тезису “давайте всё через брокер”.
И правильно бьёт.
Но нужно разделять:
Команда
“anti-fraud, проверь поручение”
Это directed intent.
Тут очень часто лучше:
RPC enqueue/status
или point-to-point command queue
Событие
“поручение успешно исполнено”
Это fact, который могут читать многие.
Тут брокер часто естественнее.То есть спор не “RPC или broker”, а:
для каких взаимодействий что лучше
На твоём примере — моя честная архитектурная оценка
Для шага payment -> antifraud
Я бы выбрал один из двух вариантов:
Вариант A
RPC submitCheck, RPC getCheckStatus
если:
antifraud — строго один исполнитель
проверка обязательна
результат нужен синхронно для саги
важен tight control
Вариант B
Выделенная command queue antifraud
если:
нужна сильная развязка по доступности
большой объём асинхронных задач
antifraud сам управляет admission control
Оба варианта лучше, чем “широковещательный event без хозяина”.
Для шага payment -> accounting
Для core ledger/shadow posting я бы очень осторожно шёл в брокер.
Почему:
критичен порядок
критичны идемпотентность и reconcile
нужна строгая ответственность
это directed business command
Твоя модель с очередью на стороне accounting выглядит очень разумно.
Для шага payment -> notification
Вот здесь брокер чаще всего уместнее.
Потому что уведомление:
не должно тормозить core flow
часто вторично
может иметь ретраи отдельно
обычно не должно ломать проведение платежа
То есть:
core payment + accounting — оркестрация
notification — событие после факта
Это очень здоровая граница.
Где брокер реально полезен в банковской системе
Не как “замена всему”, а в конкретных зонах.
Полезен:
публикация фактов домена
fan-out на вторичные сервисы
асинхронные неблокирующие реакции
интеграция с аналитикой/мониторингом
сглаживание нагрузки в некритичных фоновых контурах
CDC/event distribution из core систем наружу
Менее полезен:
пошаговая оркестрация критичного денежного процесса
directed commands с одним хозяином
места, где нужен жёсткий контроль статуса каждого шага
места, где “накопили в топике” хуже, чем “остановили приём”
Практический компромисс, который я бы рекомендовал
Для платёжного поручения я бы делил так:
1. Команды core-контура — через directed integration
Например:
payment -> antifraud
payment -> accounting
Через:
RPC submit/status
или point-to-point command queue владельца
2. События о фактах — через broker/event bus
Например:
PaymentCreated
PaymentChecked
PaymentExecuted
PaymentFailed
Их читают:
notificationsanalytics
reporting
customer history
operational monitoring
Это обычно самый здоровый баланс.
Самый важный вывод
Твоя критика верна против лозунга:
“Брокер делает систему автоматически надёжной, слабосвязанной и масштабируемой”
Нет, не делает.
Для платёжной саги часто лучше:явный оркестратор
directed commands
очередь у исполнителя
polling/status API
строгие state transitions
Но брокер всё ещё очень полезен для:публикации фактов
вторичных потребителей
не-критичных асинхронных реакций
Если сжать до одной фразы
Для исполнения денег я бы чаще выбрал твою модель orchestration + executor-owned queue, а брокер оставил бы для распространения фактов, а не для управления шагами core-процесса.
Сравним схемы работы оркестратора с брокером и без него.
Брокер:

gRPC:

Схемы упрощенные. Например, во втором случае не раскрыта механика компенсации, статусов команд будет больше, и нет описания таблицы внутренней очереди команд, которая по совместительству является inbox. Но схемы показывают интересный факт: отсутствие брокера не лишает сервис очереди, как средства распределения нагрузки и при этом таблиц больше не стало, ведь в первом случае всё равно нужен inbox.
Кроме того, во втором случае, то есть при использовании синхронного транспорта и внутренней очереди, получаем киллер-фичу, упрощающую обслуживание: в случае проблем на воркере у сервиса будет возможность сказать: «Стоп, очередь переполнена, хватит», либо просто упасть без последствий.
Пример последствий: переполнение очереди «токсичными» сообщениями, которые сервис не может прочитать или обработать по разным причинам.
Что в случае проблем на исполнителе лучше?
1. Сагам встать в очередь, заполнить неразгребаемую брокерскую очередь сервиса B либо разгребаемую в сторону DLQ для каждого ретрая саги, а затем перевести сагу в статус «ошибка».
2. Сагам встать в очередь и делать ретрай шага, то есть вызов gRPC с экспоненциальной задержкой три раза, получая ошибку валидации команды, после чего перевести сагу в статус «ошибка».
То есть в первом случае у нас будет очередь саг, застрявших на исполнении шага обращения к сервису B, и очередь сообщений сервиса B под угрозой либо retention, либо отправки в DLQ.
Зачем нам две очереди плюс опциональная третья, DLQ?
Я отвечу: вторая нужна для распределения нагрузки на воркер. Плюс есть поверье, что, когда сервис B поднимется или устранят баг, он обработает очередь брокера, и всё будет хорошо. Не спорю, это поверье часто оправдывается. Но и в первом случае всё будет хорошо: отработает retry саги, только с меньшими заботами об инфраструктуре и без необходимости придумывать, как обслуживать DLQ. А её нужно мониторить и принимать решения о том, что делать с сообщениями, которые туда попали.
Кейс: на шаге саги формируется кривое сообщение по причине добавления неправильных данных в справочник, используемый в маппинге. Можно придумать и другие примеры ошибок или падений воркеров.
В случае с брокером получим в очереди 10 тысяч неправильных сообщений. В случае с внутренней очередью у сервиса есть возможность проверить валидность сообщений и сразу выдать ошибку, а не брать в работу то, что он не может обработать.
Да, могут быть кейсы, когда валидация сервиса пропустит ошибку в сообщении, и тогда тоже нужно будет разбираться в ручном режиме. Но после валидации вероятность принять команды с ошибками сильно меньше. На такие вещи разработчики сервисов-исполнителей пишут юнит-тесты очень внимательно, поскольку это их зона ответственности.
DLQ вообще достойна комикса…

Основные концепции определены. Начнем вайбкодинг.
Правила.
Для начала определим минимальные правила. Правила я создавал так же не без помощи ИИ.
Скрытый текст
## Общие правила архитектуры
Правило: Микросервис должен быть независимым и самодостаточным
- Каждый микросервис должен иметь собственную базу данных
- Запрещено прямое обращение к БД другого микросервиса
- Совместное использование данных только через gRPC API
- Каждый микросервис должен быть stateless
Правило: Единый стиль именования
- Названия микросервисов должны быть в формате: service-{domain}
- Пакеты в Go именовать кратко, одним словом: user, order, payment
- Интерфейсы именовать по действию: UserRepository, OrderProcessor
## Правила работы с PostgreSQL
Правило: Работа с базой данных
- Использовать только подготовленные запросы (prepared statements)
- Обязательно использовать транзакции для операций записи
- Добавлять таймауты на все запросы к БД (не более 5 секунд)
- Индексировать поля, используемые в WHERE и JOIN
Правило: Миграции базы данных
- Все изменения схемы БД через миграции (github.com/golang-migrate/migrate)
- Миграции должны быть идемпотентными
- Откат миграции должен быть всегда возможен
## Правила gRPC коммуникации
Правило: Взаимодействие между сервисами
- Все внутренние коммуникации только через gRPC
- Обязательно использовать TLS/mTLS для продакшена
- Добавлять retry механизмы с экспоненциальной задержкой
- Устанавливать deadlines на все gRPC вызовы (не более 10 секунд)
Правило: Контракты protobuf
- Хранить .proto файлы в отдельном репозитории или модуле
- Версионировать API: /v1/, /v2/
- Не удалять поля из сообщений, только помечать deprecated
## Правила REST API для внешних потребителей
Правило: Дизайн REST API
- Следовать принципам RESTful: ресурсы, HTTP методы, статус коды
- Формат ответов: всегда JSON
- Пагинация для списковых endpoint-ов: limit и offset или cursor
- Версионирование через URL: /api/v1/users
Правило: Обработка ошибок в REST
- Возвращать стандартизированный формат ошибки
```json
{
"error": {
"code": "ERROR_CODE",
"message": "Описание ошибки"
}
}
```
- Использовать соответствующие HTTP статусы (400, 401, 403, 404, 500)
## Правила для длительных операций (Polling)
Правило: Длительные операции в проекте
- Формирование желания и его подтверждение выполняются синхронно через обычные HTTP-запросы
- Длительное исполнение желания не блокирует клиентский запрос и выносится в отдельный backend-процесс
- После подтверждения желания сервис создаёт Execution, сохраняет шаги саги и переводит желание в статус executing
- Клиентское приложение не управляет отдельным ресурсом задачи вида /tasks/{id}, а получает актуальный статус желания через API сервиса
- Фактическое продвижение исполнения происходит во внутренних worker/polling-processes
- Финальный статус исполнения синхронизируется обратно в wish.status
- Таким образом, для клиента длительная операция выглядит как смена статусов желания, а не как отдельная техническая задача
## Правила логирования и мониторинга
Правило: Структурированное логирование
- Использовать JSON формат логов
- Обязательные поля: timestamp, level, service, trace_id, message
- Уровни логирования: debug, info, warn, error
Правило: Метрики
- Экспортировать метрики в Prometheus формате
- Обязательные метрики: request_count, request_duration, error_count
- Добавлять business метрики (количество заказов, активные пользователи)
## Правила безопасности
Правило: Аутентификация и авторизация
- JWT токены для REST API
- API ключи или mTLS для gRPC
- Передавать контекст пользователя через заголовки
Правило: Защита данных
- Шифровать чувствительные данные в БД
- Не логировать персональные данные
- Использовать переменные окружения для секретов
## Правила тестирования
Правило: Покрытие тестами
- Unit тесты: минимум 70% покрытия
- Integration тесты: тестирование с реальной БД
- Поднимать тестовый контейнер с PostgreSQL
Правило: Тестирование gRPC
- Мокать gRPC клиенты для тестирования
- Тестировать retry механизмы
- Проверять обработку timeout-ов
## Технический стек
Обязательные библиотеки и инструменты:
- Основной фреймворк: net/http или gin-gonic/gin для REST
- gRPC: google.golang.org/grpc
- Работа с БД: database/sql + lib/pq или jackc/pgx
- Миграции: golang-migrate/migrate
- Конфигурация: spf13/viper
- Логирование: uber-go/zap или sirupsen/logrus
- Метрики: prometheus/client_golang
- Трассировка: open-telemetry/opentelemetry-go
- Тестирование: stretchr/testify, go.uber.org/mock
## Правила code style Go
- Правило: Следовать стандартным практикам Go
- Использовать gofmt / goimports
## Структура проекта
Обязательная структура микросервиса:
service-{domain}/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── api/
│ │ ├── grpc/
│ │ └── rest/
│ │ ├── handler/
│ │ └── middleware/
│ ├── repository/
│ ├── service/
│ ├── orchestrator/ (только для сервисов с сагами)
│ ├── accounts/ (gRPC-клиент к service-accounts, если нужен)
│ └── config/
├── migrations/
├── proto/ (или ссылка на модуль с proto)
├── .env
├── .env.example
├── Dockerfile
├── docker-compose.yml
├── Makefile
└── README.md
Структура общих библиотек:
lib-{name}/
├── {package}/
│ └── *.go
├── go.mod
└── go.sum
Сервис счетов.
Сервис счетов для проверки концепции простого ИБ не особо интересен – он просто нужен чтобы банк работал. Предоставляет непубличный gRPC api создания счетов и проводок. Единственное, нужно отметить, что в нем живут счета для учета долга (активные счета) и учета собственных денег (пассивные счета). Как антиматерия и материя (сравнение образное, прошу физиков отложить помидоры). При встрече они аннигилируют. Например, так работает погашение кредита: если сделать проводку “Счет клиента -> 100р -> Счет основного долга кредита”, то баланс обоих счетов уменьшиться на 100р. При выдаче кредита ровно обратная ситуация: если выдать 500р, то на обоих счетах возникнет 500р буквально ниоткуда. Говорят, подобным образом появилась наша вселенная (разделение антиматерии и материи), что подпитывает мое убеждение в том, что кредит — это источник жизни. Я это к тому, что если в приложении (https://justabank.ru) вы сделаете перевод СЧЕТ -> КРЕД и заметите, что балансы уменьшились, то это не баг.
Промпт:
Скрытый текст
Создай Go-микросервис service-accounts для учебного банковского симулятора.
Сервис владеет банковскими счетами и проводками, хранит данные в PostgreSQL и предоставляет gRPC API для других сервисов.
Нужно реализовать:
- счета клиента: demand, credit
- статусы счетов: pending, active, closed
- создание счёта
- получение счёта и списка счетов клиента
- создание перевода между счетами
- статусы перевода: pending, completed, failed
- атомарное изменение балансов в PostgreSQL-транзакции
- запрет ухода дебетового счёта в минус
- запрет обычного перевода с кредитного счёта
- разрешение внутреннего перевода credit -> demand для выдачи кредита
- gRPC API:
- CreateAccount
- GetAccount
- GetAccounts
- GetAccountStatus
- CreateTransfer
- GetTransferStatus
- миграции PostgreSQL
- таблицу событий replication.events для репликации проекции счетов
- unit-тесты доменной логики
- Dockerfile и конфигурацию запуска
Не смог удержаться - попросил ИИ нарисовать комикс про деньги…

Сервис авторизации.
Просто сервис на основе golang-jwt/jwt/v5.
Промпт:
Скрытый текст
Создай Go-микросервис service-auth для учебного банковского симулятора.
Сервис отвечает за:
- регистрацию клиента
- подтверждение email кодом
- login по email/password
- второй фактор входа через email-код
- выпуск JWT access token
- gRPC API для получения клиента другими сервисами
Нужно реализовать:
- REST API:
- POST /api/v1/auth/register
- POST /api/v1/auth/register/confirm
- POST /api/v1/auth/login
- POST /api/v1/auth/login/verify
- GET /healthz
- gRPC API:
- GetClient(client_id)
- PostgreSQL tables:
- clients
- verification_codes
- bcrypt для паролей
- одноразовые verification codes с TTL
- RS256 JWT
- SMTP/MailHog отправку кодов
- фиксированный код 111111 для email, содержащих test, чтобы упростить e2e
- unit-тесты доменной логики
- Dockerfile и docker-compose интеграцию
Уведомления.
Для уведомлений ограничился созданием библиотеки
Промпт:
Скрытый текст
Создай Go-библиотеку lib-notify для общего использования в микросервисах.
Библиотека должна содержать три пакета:
1. code
- функция Generate(length int) (string, error)
- генерирует цифровой OTP-код заданной длины
- использует crypto/rand
- не использует math/rand
- валидирует длину
2. email
- функция Render(templateBody string, data any) (string, error)
- рендерит HTML email через html/template
- возвращает понятные ошибки парсинга/рендера
3. smtp
- Config{Host, Port, Username, Password, From}
- New(config Config) *Sender
- Send(to, subject, htmlBody string) error
- отправляет HTML email
- поддерживает SMTP без auth для MailHog
- поддерживает SMTP auth для production SMTP
Добавь unit-тесты для всех пакетов и проверь go test ./....
Желания. Библиотека.
Теперь самое интересное... В данной реализации ИБ желание переводов между счетами будет формироваться в сервисе funds, но желания могут формироваться и в других сервисах.
Например:
желание оплатить телефон – в сервисе платежей;
желание открыть вклад – в сервисе вкладов;
желание подать заявку на потреб. кредит – в сервисе кредитных потреб. заявок и т. д.
При этом чаще всего UI формирования желаний будет выглядеть как набор полей ввода текста, суммы, выпадашек и ввода дат. И что ещё более важно, flow оформления желания практически всегда одинаковый: заполнить параметры на форме, затем на еще одной или нескольких формах и, наконец, когда все нужные параметры желание будут определены, получить форму подтверждения желания с ключевыми параметрами и документами. В итоге, после подтверждения клиентом, желание готово к передаче на исполнение.
Значит нужно как-то стандартизировать формирование желаний.
Что может быть проще, чем просто договорится с разработчиками использовать одинаковый api в разных сервисах? Все, что угодно проще, чем договориться разработчиками использовать одинаковый api в разных сервисах разных бизнес доменов. Плохой вариант.
Написать свой фреймворк, который позволит быстро генерить функционал формирования желаний (вместе с api) с предусмотренной возможностью расширения функционала, и обязать всех использовать этот фреймворк. Он будет такой классный! Почему нет? Нет, потому что дорого. Нужна отдельная команда, которая будет заниматься только этим. Кроме того, такой фреймворк с большой вероятностью не будет удовлетворять всех, и, если разработчики фреймворка не смогут его быстро дополнять нужными для разных доменов фичами, то фреймворк станет тормозом развития. А если он будет сложным настолько, что потребует дополнительного обучения разработчиков, то еще и замедлит разработку вместо того, чтобы ускорить. Удачные фреймворки существует, но на этот путь лучше ступать, только если есть четкий план и требования к фреймворку от всех потребителей. Пожалуй, не стану рисковать.
Библиотека контрактов желаний. Просто пишем библиотеку с контрактами. Все, кто ее используют, получают стандартизованный и предсказуемый для фронт-приложения api, а реализацию можно писать как хочешь. Если же кто-то не хочет использовать библиотеку, то пусть пишет, что хочет и сам договаривается с фронтами по реализации UI. Библиотеку можно даже светить аутсорсу, если нужно срочно сделать функционал желания без найма новых разрабов. Жалко что-ли? Это же практически swagger. В ней нет никакого секрета. Ее можно даже использовать в качестве контрактов Open API, если такой вообще имеется в банке. Годится. Делаем...
Промпт:
Скрытый текст
Создай Go-библиотеку lib-wishes согласно правилам.
Библиотека должна содержать:
1. Пакет model
- доменные модели желаний:
- Wish
- WishField
- WishDocument
- WishForm
- FormField
- FieldOption
- VerificationCode
- Account
- типы:
- WishType: transfer, credit_issue, create_account
- WishStatus: draft, pending, executing, completed, cancelled, failed
- FieldType: text, decimal, select, date
- AccountType: demand, credit
- AccountStatus: pending, active, closed
2. Пакет pdf
- генерация PDF-документов:
- платёжное поручение
- заявление на открытие дебетового счёта
- документ должен содержать учебный disclaimer
- PDF должен поддерживать кириллицу
Библиотека не должна зависеть от базы данных, HTTP, gRPC или конкретных сервисов.
Используй Go, чистые структуры, точные денежные типы из lib-money, unit-тесты и go test ./....
Можно заметить, что в модели желаний я позволил себе некоторую нестрогость, добавив модель “Счет” (Account). Но здесь надо отметить, что Account в lib-wishes не является источником истины по счетам. Источником истины остаётся service-accounts. Эта структура нужна как локальное представление счёта внутри сценариев желаний. Короче говоря, я посчитал, что стандартизация “счета” допустима в банковских желаниях.
Исполнение желаний.
Здесь в целом всё по классике: сформированное желание это основание для исполнения. Джоб сервиса создаёт экземпляр исполнения желания (например, исполнение желания перевода) и запускает сагу с предопределенными шагами. Исполнение всех шагов – успех, а иначе запускаются компенсации всех исполненных шагов, и исполнение переходит в отмену.
Исполнение также поддается стандартизации. Создадим библиотеку.
Промпт:
Скрытый текст
Создай Go-библиотеку lib-execution для исполнения подтверждённых операций через saga workflow.
Библиотека должна содержать:
1. Пакет model
- Execution
- SagaStep
- ExecutionStatus: executing, completed, cancelled, failed
- SagaStepType: create_transfer, create_credit_account, create_demand_account
- SagaStepStatus: pending, executing, completed, failed
2. Пакет orchestrator
- интерфейсы ExecutionRepository и SagaStepRepository
- интерфейс StepHandler
- тип Orchestrator
- методы:
- WithStepHandler
- Execute
- ExecuteAsync
- RunWorker
Логика:
- execution состоит из ordered saga steps
- steps выполняются по StepIndex
- completed steps пропускаются
- ошибки шага приводят к retry
- после превышения attempts execution становится failed
- completed steps компенсируются в обратном порядке
- конкретная бизнес-логика шагов находится не в библиотеке, а в сервисе-потребителе через StepHandler
Ограничения:
- не зависеть от PostgreSQL
- не знать про HTTP/gRPC
- не знать про service-funds или service-accounts
- не содержать бизнес-логику переводов или счетов
Добавь unit-тесты и проверь go test ./....
Сервис Funds. Желание перевода денег между счетами.
Теперь, собственно, само желание.
Промпт:
Скрытый текст
Реализуй функциональность “желание перевода между счетами”.
Важно: сфокусируйся только на сценарии формировании желания. Исполнение будем делать потом.
Нужно реализовать сценарий, в котором клиент выбирает счет списания, счет зачисления, сумму, валюту. После отправки формы создается объект желания перевода. Этот объект фиксирует намерение клиента выполнить перевод, но деньги еще не перемещаются между счетами.
Желание должно иметь статусную модель и жизненный цикл согласно концепции желаний.
На этапе формирования желания необходимо:
- принять входные данные перевода;
- проверить, что счета существуют;
- проверить, что счет списания принадлежит клиенту;
- проверить валюту и сумму;
- создать запись желания в базе данных;
- сформировать человекочитаемый документ или описание желания;
- вернуть клиенту ID желания и данные для экрана подтверждения.
Перед вводом кода подтверждения клиент должен увидеть:
- сумму перевода;
- счет списания;
- счет зачисления;
- назначение;
-ID операции;
Формирование дашборда.
То, что без брокера сделать сложнее всего...
API дашборда в сервисе funds, но счета живут в сервисе счетов. События об изменении состояния счетов нужно передавать в funds для формирования витринной таблицы, чтобы сервис funds показал счета клиенту, когда тот откроет дашборд. Можно ли решить эту задачу без брокеров?
Если кратко: сервис funds периодически запрашивает инкремент внутренней таблицы событий в сервисе accounts.
Подробнее, идея такая:
Сервис accounts является источником истины по счетам. В нем есть gRPC replication API, через который funds может получить не только поток изменений, но и полный снимок текущего состояния счетов.
Когда service-funds стартует, его AccountsPoller смотрит, есть ли сохраненный offset репликации. Если offset еще не найден, значит funds раньше не синхронизировался. Тогда он делает bootstrap через snapshot.
Формирование snapshot происходит в service-accounts.
Репозиторий выбирает счета из БД пачками, превращает каждый счет в SnapshotItem с payload_json, и возвращает их наружу. Вместе со списком элементов возвращается snapshot_high_watermark — максимальный offset событий на момент построения снимка.
Использование snapshot происходит в service-funds.
Он постранично запрашивает snapshot из accounts, декодирует элементы в локальную модель счета и сохраняет их в таблицу: replication.accounts_projection
После того как все страницы snapshot загружены, funds сохраняет offset равным snapshot_high_watermark. Это важный момент: дальше funds уже не перечитывает все счета заново, а начинает читать только события, которые появились после этого offset.
После bootstrap-снимка начинается обычная инкрементальная репликация.
Практический смысл snapshot: он дает funds стартовую консистентную локальную копию счетов, чтобы потом не тянуть всю историю событий с самого начала. А события после snapshot уже поддерживают эту копию актуальной.
Промпт:
Скрытый текст
Реализуй репликацию состояния счетов из service-accounts в service-funds.
service-accounts является источником истины по счетам. При каждом создании счета, изменении баланса или изменении статуса счета он должен записывать replication event в локальный журнал событий. Событие должно содержать offset, feed, entity_type, entity_id, event_type, event_version, occurred_at, payload_json и metadata_json.
Для первичной загрузки service-accounts должен предоставлять gRPC ReplicationService с двумя методами:
- GetSnapshot(feed, cursor, limit): возвращает текущие счета пачками как SnapshotItem и snapshot_high_watermark;
- ListEvents(feed, after_offset, limit): возвращает события после указанного offset.
service-funds должен иметь локальную таблицу-проекцию accounts_projection и таблицу offsets для сохранения позиции чтения. При старте funds проверяет offset. Если offset отсутствует, он делает bootstrap: вызывает GetSnapshot, постранично загружает все счета, сохраняет их в accounts_projection и фиксирует offset = snapshot_high_watermark.
После bootstrap service-funds периодически вызывает ListEvents(after_offset), применяет события account.upserted к accounts_projection через идемпотентный upsert и обновляет offset только после успешного применения пачки.
Проекция должна использоваться в funds для быстрого построения форм желаний без постоянных live-запросов в accounts. Если проекция временно пуста или недоступна, допускается fallback на live gRPC.
Важно:
- service-accounts остается единственным источником истины;
- snapshot формируется по запросу из текущего состояния БД;
- snapshot не хранится отдельной таблицей, он формируется по запросу из текущего состояния счетов в accounts;
- replication.accounts_projection поддерживают проекцию актуальной после snapshot;
- replication.consumer_offsets защищает от применения старых событий поверх новых;
- при потере событий проекция должна перестраиваться через новый snapshot.
На этом в общем все. Процесс разработки с ИИ был больше похож на общение. Сам я не написал ни одной строчки. В код SPA я вообще не заглядывал. Ошибки ИИ, конечно, делает и довольно много, но быстро исправляет если эти ошибки ему показать. Большая часть правил была написана ИИ по моим промптам. Например: “Запиши в правила то, что мы сейчас обсудили”. Всего по моим подсчетам я потратил на разработку и отладку приложения 20 часов (без учета “админско-девопсных” дел типа покупки VPS, почтового хостинга и доменного имени).
Выводы:
Протестировал ИИ как инструмент прототипирования. Все три модели (Cloud, GPT, Deepseek) сильно превзошли мои ожидания. Как минимум с этими тремя можно работать.
Проверил “broker-less архитектуру” для ИБ. Без брокеров жить можно, но в сценариях “публикация для неизвестных подписчиков” приходится изобретать велосипед и, наверное, делать этого не надо. Зато для операционной логики, где нужно раздавать команды конкретным исполнителям, синхронный транспорт (gRPC), на мой взгляд, хорошо себя показывает. Только не надо путать синхронный транспорт и синхронное исполнение. Можно синхронно поставить задачу, а потом синхронно спросить статус выполнения задачи.
Рассмотрел банковские распоряжения как клиентские желания. Надеюсь, такая интерпретация была интересной.
В рамках данной реализации ИБ я позаботился о масштабируемости: stateless сервисы запускаются в нескольких экземплярах за гейтвеями. Но я не провел никакого нагрузочного тестирования и даже не затронул темы инфраструктуры, уровней критичности и секционирования таблиц БД. Если данная статья вызовет интерес, то по масштабированию и критичности напишу отдельную статью, так как все это заслуживает отдельного рассмотрения.
По надежности системы так же нужно провести тесты, прежде чем делать яркие заявления типа “без брокера точно надежнее”, но лично мое мнение – взаимодействие “оркестратор-исполнитель” лучше делать без посредников, поскольку так проще и как следствие прозрачнее, надежнее и дешевле в обслуживании.
P.S.: Наверное не будет лишним повторить, что это учебный симулятор для проверки концепций, а не пример для реализации банковского бизнес ядра.
