Через год после закрытия проекта я открыл его код и устроил себе code review. Это был автомобильный маркетплейс объявлений, который я в одиночку спроектировал, написал, задеплоил и сопровождал: React‑фронтенд, три бэкенда на NestJS, PostgreSQL, чат на Socket.IO, интеграция платежей, Kubernetes в GKE, CI/CD, CronJob'ы и E2E.
Я нашёл несколько ошибок, которые почти наверняка проявились бы при росте трафика. Среди них — сообщения, которые не доходят между pod'ами, гонка при списании баланса и недостаточная авторизация в чате. Были и менее заметные проблемы эксплуатации. Ни одна не была сложной. Интереснее другое: почти все они возникли не внутри какой‑то технологии, а на стыках — между репликами, между чтением и записью, между кодом и расписанием, между сборкой и выкаткой.
Коротко — 8 находок:
Два корректных pod'а ломают реалтайм: без Redis-адаптера Socket.IO сообщения теряются между репликами.
Guard на каждом событии — это аутентификация, а не авторизация: любой залогиненный мог читать чужой чат по UUID.
Транзакция не защищает баланс: «прочитал → посчитал → записал» даёт двойное списание (lost update).
Webhook был идемпотентным только на бумаге: проверка статуса вне атомарного обновления.
Видимость объявлений зависела от ночного CronJob, а не от даты при чтении.
CronJob не гарантирует «ровно один раз»: нужны concurrencyPolicy: Forbid и идемпотентные задачи.
Тег :latest + rollout restart = деплой, который нельзя откатить.
Границы роутов не совпали с границами состояния: 22 сущности в глобальном Redux.
Дальше — разбор каждой с кодом и исправлением, плюс что сработало и что бы я сделал иначе.
Сразу о рамках. Реальной нагрузки у проекта не было: он прошёл софт‑ланч, а потом я его закрыл, потому что коммерческого интереса оказалось недостаточно. Платёжные функции были реализованы, но для пользователей так и не включены. Kubernetes по нагрузке был не нужен. Часть описанных ошибок не проявилась именно потому, что трафик был маленьким. Поэтому здесь не будет RPS и историй про масштаб. Будет разбор собственных решений: почему каждое казалось разумным, что с ним не так и как я сделал бы сейчас.
Система
Пять независимо деплоящихся единиц и три базы PostgreSQL:
Сервис | Стек | Профиль работы | База |
|---|---|---|---|
Публичный фронтенд | React, TypeScript, CRA, nginx | SPA | — |
Фронтенд админки | React | Внутренний инструмент | — |
Основной API | NestJS, TypeORM | Stateless REST, публичный трафик | своя |
API админки | NestJS, TypeORM | Операторы, роли, аудит | своя + доступ к основной |
Чат | NestJS, Socket.IO | Долгоживущие соединения | своя |
Каждый сервис — отдельный Helm‑чарт в своём namespace в GKE, ingress на Traefik, HPA на каждом деплойменте. Образы собирает kaniko в GitLab CI, доступ пайплайнов к кластеру — через GitLab Agent. Фоновые задачи — три CronJob, по одному на бэкенд.

Почему не модульный монолит, если нагрузки не было? Я делил систему не по доменам, а по профилю работы: короткие stateless‑запросы публичного API, внутренний инструмент с правом менять балансы (его не хотелось держать в одном процессе и за одним ingress с публичным API), долгоживущие соединения чата.
Граница при этом протекла. API админки работал не только со своей базой, но и напрямую с основной: два писателя, одна схема, сущности TypeORM в двух репозиториях, сверяемые вручную. Разделение по деплою и безопасности я получил, по данным — нет. Гонки между менеджером и пользователем исключались организационно (баланс менялся только по обращению клиента) — это гарантия процесса, а не базы.
При такой степени связности сейчас я, скорее всего, оставил бы фронтенд админки отдельным приложением, а бэкенд админки сделал бы модулем основного API с отдельной авторизацией и правами. Независимый деплой здесь дал меньше, чем добавила связность через общую схему.
Дальше — восемь находок из code review. Формат у всех одинаковый: решение, почему оно казалось разумным, что с ним не так, как я сделал бы сейчас.
1. Два корректных pod'а ломают реалтайм
Сначала о двух решениях в чате, которые я бы сохранил. Чат не ходит в основной API: у него своя база с проекцией пользователя, которая лениво создаётся из claims JWT, и свои списки блокировок. Пока у пользователя валидный токен, чат работает, даже если основной API недоступен. И сообщение сначала сохраняется, потом рассылается:
@SubscribeMessage(events.MESSAGE_ADD) async handleNewMessage(@UserWS() user, @MessageBody() data, @ConnectedSocket() client) { const { chat_uuid, newMessage } = await this.messageService.addMessage(user, data); client.to(chat_uuid).emit(events.MESSAGES_NEW_MESSAGE, { chat_uuid, newMessage }); return { event: events.MESSAGES_NEW_MESSAGE, data: { chat_uuid, newMessage } }; }
База — источник истины, сокет — только ускорение доставки. Если emit не дошёл, сообщение появится при следующей загрузке диалога.
Теперь цепочка, каждое звено которой по отдельности выглядит разумно.
Токен клиент передавал в заголовке:
io(websocketApiURL, { transports: ['polling', 'websocket'], withCredentials: true, extraHeaders: { Authorization: `Token ${getAuthToken(authToken)}` }, })
В браузере extraHeaders применяются только к HTTP long‑polling: WebSocket API не позволяет задать собственные заголовки. Поэтому соединение обязано начинаться с polling — сервер проверял токен на handshake, после чего транспорт апгрейдился до WebSocket.
Polling в Socket.IO — серия HTTP‑запросов, которые должны попадать на один и тот же процесс. При нескольких репликах это требует sticky‑сессий, и они были настроены на уровне Traefik:
annotations: traefik.ingress.kubernetes.io/service.sticky.cookie: "true" traefik.ingress.kubernetes.io/service.sticky.cookie.name: server_id
В проде у чата было две реплики:
autoscaling: minReplicas: 2 maxReplicas: 10
А комнаты Socket.IO по умолчанию живут в памяти процесса, и адаптера для обмена событиями между процессами (Redis или другого) не было.
Итог: покупатель подключён к pod'у A, продавец — к pod'у B. client.to(chat_uuid).emit(...) на pod'е A рассылает только своим сокетам. Сообщение сохранено, но в реальном времени до продавца не доходит и появится, только когда он заново откроет диалог. Ни ошибки, ни алерта, ни потерянных данных.

Во время софт‑ланча этого никто не заметил: локально всегда один процесс, а трафика, при котором собеседники надолго оказываются на разных pod'ах, не было. Нашёл я это чтением main.ts, где не оказалось useWebSocketAdapter. Sticky‑сессии создавали ощущение, что «с репликами всё учтено», но они закрывают только требование polling‑транспорта и не связаны с доставкой между процессами.
Сейчас я сделал бы так. Токен передаётся в auth handshake'а — это работает и для чистого WebSocket:
// клиент io(websocketApiURL, { transports: ['websocket'], auth: { token: getAuthToken(authToken) } }); // сервер handleConnection(socket: Socket) { socket.data.user = verify(socket.handshake.auth?.token, JWT_SECRET).data; }
Межпроцессная доставка — через Redis‑адаптер:
export class RedisIoAdapter extends IoAdapter { private adapterConstructor: ReturnType<typeof createAdapter>; async connect() { const pub = createClient({ url: process.env.REDIS_URL }); const sub = pub.duplicate(); await Promise.all([pub.connect(), sub.connect()]); this.adapterConstructor = createAdapter(pub, sub); } createIOServer(port: number, options?: ServerOptions) { const server = super.createIOServer(port, options); server.adapter(this.adapterConstructor); return server; } } // main.ts const adapter = new RedisIoAdapter(app); await adapter.connect(); app.useWebSocketAdapter(adapter);
Если отказаться от polling, sticky‑сессии больше не нужны. Если polling оставить как fallback, они нужны и с адаптером: адаптер решает доставку событий, а не маршрутизацию HTTP‑запросов handshake'а.
Вывод: каждая реплика по отдельности работала правильно. Сломалась система из двух правильных реплик, и ни один тест, запущенный против одного процесса, этого бы не показал.
2. Guard на каждом событии — это ещё не авторизация
Каждый обработчик чата был обёрнут так же, как REST‑эндпоинт в NestJS: guard, pipe для валидации DTO, filter для логирования. Ощущение защищённости было полным. Но guard проверял только, что пользователь залогинен:
@SubscribeMessage(events.CHAT_SUBSCRIBE) async handleSubscribeChat(@UserWS() authUser, @MessageBody() data, @ConnectedSocket() client) { client.join(data.chat_uuid); // authUser не используется } async getMessages(data: GetMessagePayload) { // пользователь в метод не передаётся return this.messageRepository.find({ where: { chat: { uuid: data.chat_uuid } }, ... }); }
Любой залогиненный пользователь, знающий chat_uuid, мог подписаться на чужую комнату и прочитать историю. В addMessage собеседник вычислялся как «участник чата, который не я», но принадлежность самого отправителя к чату не проверялась. readMessages помечал сообщение прочитанным по последовательному числовому id без проверки владельца.
Частично это прикрывалось тем, что chat_uuid — UUID v4. Но это защита через неизвестность: UUID попадает в URL, логи и ответы API. Проект закрыт, поэтому показываю как есть.
Исправление — одна проверка, которую вызывает каждый обработчик:
async assertParticipant(chatUuid: string, userId: number): Promise<ChatEntity> { const chat = await this.chatRepository .createQueryBuilder('chat') .innerJoin('chat.users', 'u', 'u.id = :userId', { userId }) .where('chat.uuid = :chatUuid', { chatUuid }) .getOne(); if (!chat) throw new WsException(exceptions.NOT_ALLOWED); return chat; }
Вывод: единообразная обвязка вокруг обработчиков отвечает на вопрос «кто ты». Вопрос «что тебе можно с этим ресурсом» — отдельная проверка в каждом обработчике, и без второго человека на ревью её легко пропустить везде сразу.
3. Транзакция, которая не защищает баланс
Контекст: монетизация была реализована полностью, но на софт‑ланче выключена флагами окружения (env.paymentsEnabled и другие убирали роуты). Оплата — через внутренний кошелёк: пополнение через LiqPay, покупка услуг с баланса. Поднятие объявления — временная метка top_at, закрепление и топ‑каталог — членство в группе плюс запись с expire_at.
Списание было устроено так:
if (PRICE > Number(car.user.balance)) throw new HttpException(NOT_ENOUGH_BALANCE, 422); serviceRecord.user.balance = Number(serviceRecord.user.balance) - Number(PRICE); car.top_at = new Date(); await getManager().transaction(async (m) => { await m.save(serviceRecord); // cascade сохраняет и user.balance await m.save(car); });
Выглядит аккуратно: запись услуги, баланс и объявление сохраняются в одной транзакции. Но баланс прочитан до транзакции, новое значение посчитано в JS, а записывается абсолютное число. Транзакция гарантирует атомарность записи, но ничего не знает о том, что было прочитано до неё. Классический lost update:
два параллельных запроса на покупку видят баланс 100, оба проходят проверку и оба записывают 40 — две услуги за одно списание;
покупка, идущая одновременно с зачислением пополнения, записывает «старый баланс минус цена» поверх зачисленного депозита;
ручная корректировка из API админки пишет в ту же колонку по той же схеме.

Исправление — условный атомарный UPDATE внутри транзакции:
await dataSource.transaction(async (m) => { const res = await m.createQueryBuilder() .update(UserEntity) .set({ balance: () => 'balance - :price' }) .where('id = :id AND balance >= :price') .setParameters({ id: user.id, price: PRICE }) .execute(); if (res.affected === 0) throw new HttpException(NOT_ENOUGH_BALANCE, 422); await m.insert(PayServiceEntity, { type, reason, price: PRICE, user: { id: user.id } }); await m.update(CarEntity, car.id, { top_at: () => 'now()' }); });
В PostgreSQL второй конкурентный UPDATE той же строки ждёт блокировку, а после коммита первого перепроверяет условие уже на новой версии строки. Проверка и списание становятся одной операцией. Альтернатива — SELECT ... FOR UPDATE в той же транзакции.
Вывод: граница транзакции должна включать чтение, на котором основано решение. Транзакция вокруг одной только записи даёт ложное чувство безопасности.
4. Webhook, который я считал идемпотентным
Пополнение создаёт транзакцию в статусе PENDING с собственным UUID. Webhook проверяет подпись (sha1(secret + data + secret)), сверяет сумму и валюту и принимает только транзакцию в статусе PENDING:
if (existedTransaction.status !== TransactionStatus.PENDING) throw new HttpException(exceptions.TRANSACTION_IS_NOT_FOUND, HttpStatus.CREATED); existedTransaction.status = data.status; existedTransaction.user.balance = Number(existedTransaction.user.balance) + Number(data.amount); await this.transactionRepository.save(existedTransaction);
Я считал это идемпотентным, и для последовательной повторной доставки так и есть: вторая доставка видит уже не PENDING и ничего не меняет.
Для конкурентной доставки защиты нет. Две доставки одновременно читают PENDING и обе проходят проверку. Иронично, что lost update из предыдущего раздела здесь частично маскирует проблему: обе доставки читают один и тот же баланс и записывают одно и то же значение, так что двойного зачисления, скорее всего, не будет. Но если пополнение несло с собой услугу, обе доставки затем пойдут её применять, а это снова гонка из раздела 3. Корректность держится на совпадении, а не на гарантии.
Настоящая защита — условный переход статуса, и зачисление в той же транзакции:
await dataSource.transaction(async (m) => { const res = await m.createQueryBuilder() .update(TransactionEntity) .set({ status: TransactionStatus.SUCCESS }) .where('uuid = :uuid AND status = :pending', { uuid, pending: TransactionStatus.PENDING }) .execute(); if (res.affected === 0) return; // уже обработано другой доставкой await m.createQueryBuilder() .update(UserEntity) .set({ balance: () => 'balance + :amount' }) .where('id = :userId') .setParameters({ amount, userId }) .execute(); if (serviceProperties) { // durable-запись о том, что услугу ещё нужно применить await m.insert(PendingOperationEntity, { type: 'APPLY_SERVICE', transactionUuid: uuid, payload: serviceProperties, status: 'PENDING', }); } });
Но атомарного перехода статуса недостаточно, если применение услуги — отдельный side effect после коммита. Транзакция PENDING → SUCCESS закоммитилась, баланс зачислен, процесс падает до применения услуги, провайдер повторяет webhook — а повторный запрос уже видит SUCCESS и ничего не делает. Деньги на балансе, услуги нет. Поэтому в той же транзакции, где меняется статус и зачисляется баланс, создаётся запись о необходимости применить услугу. Отдельный обработчик забирает такие записи и применяет услугу идемпотентно (ключ — UUID транзакции), отмечая запись выполненной. Если процесс упадёт после коммита, операция не потеряется, а повторный webhook по‑прежнему ничего не зачислит повторно.

В общем виде это transactional outbox. Для проекта такого размера полноценный outbox с брокером избыточен, и простой таблицы отложенных операций, которую разбирает тот же CronJob или фоновый воркер, достаточно — идея та же.
Одно в этой схеме я сделал правильно: если услугу применить не удалось, деньги остаются на балансе. При сбое система приходит в состояние, из которого пользователь выходит сам.
Вывод: проверка «если статус X, то перейти в Y» идемпотентна только тогда, когда проверка и переход — одна атомарная операция. А side effect после коммита должен быть durable, иначе между коммитом и его выполнением остаётся окно, в котором операция теряется.
5. Корректность продукта, которая жила в расписании
Платное закрепление истекает по expire_at. Выдача при этом фильтровала не по дате, а по членству в группе, а группу снимал ночной CronJob. После окончания оплаченного периода объявление оставалось в топе до ближайшего запуска задачи — до суток бесплатного продвижения.

Решение казалось естественным: фильтр по группе уже был, админке удобно «выдать» услугу добавлением группы, а CronJob всё равно нужен для уборки. Но правильность продуктового правила стала зависеть от расписания: упади CronJob на несколько дней — закрепление бесплатно продлится на столько же.
Сейчас проверка была бы при чтении (имена связей здесь условные):
qb.innerJoin('car.topSearch', 'ts', 'ts.expire_at > now()');
CronJob остаётся, но только для уборки: если он не отработает, данные будут грязнее, но выдача останется правильной.
Вывод: если задача по расписанию влияет на то, что видит пользователь, это уже не инфраструктура, а часть бизнес‑логики, и её отказ — продуктовый баг.
6. CronJob не гарантирует выполнение ровно один раз
Фоновые задачи (истечение услуг, архивация объявлений, чистка кодов подтверждения, временных файлов, старых сообщений) сначала планировались через node-cron, потом переехали в отдельный образ, который запускается как CronJob. Это закрыло очевидную ловушку: с node-cron внутри API и двумя репликами каждая задача выполнялась бы дважды.
Манифест выглядел так:
kind: CronJob spec: schedule: "00 03 * * *" jobTemplate: spec: template: spec: containers: - name: car-b command: ['npm', 'run', 'manager-cars:run'] restartPolicy: OnFailure
concurrencyPolicy не задан (по умолчанию Allow), restartPolicy: OnFailure перезапускает упавший контейнер, а у Job есть backoffLimit (по умолчанию 6). Kubernetes CronJob не гарантирует выполнение ровно один раз: запуск может повториться после частичного выполнения, пересечься со следующим запуском или при определённых условиях быть пропущен. Поэтому задача должна быть безопасной при повторном и частичном выполнении, а там, где это возможно, идемпотентной.
Пример задачи, которая оказалась корректной почти случайно:
for (let start = 0; start < countChats; start += chatsPerInterval) { const page = await repo.find({ where: { created_at: LessThan(daysAgo) }, relations: ['users', 'messages'], skip: start, take: chatsPerInterval, }); // удаляем часть строк этой страницы... }
Пагинация через skip с одновременным удалением: удалённые строки сдвигают смещения, и часть записей пропускается. Их подбирает следующий запуск, поэтому система всё равно сходится. Соседняя задача загружала все старые сообщения в память и удаляла их по одному.
Для объёма этого проекта обе задачи заменил бы один идемпотентный запрос:
DELETE FROM message_entity WHERE created_at < now() - interval '30 days';
На большой таблице я бы удалял записи пакетами через ограниченную выборку id, чтобы не держать одну длинную транзакцию:
WITH batch AS ( SELECT id FROM message_entity WHERE created_at < now() - interval '30 days' ORDER BY id LIMIT 1000 ) DELETE FROM message_entity WHERE id IN (SELECT id FROM batch);
А в манифест я добавил бы:
spec: concurrencyPolicy: Forbid startingDeadlineSeconds: 600 jobTemplate: spec: backoffLimit: 2
Вывод: задача по расписанию должна давать одинаковый результат при повторном запуске, частичном выполнении и пересечении с самой собой. Тогда ошибки вроде пропущенной страницы становятся вопросом эффективности, а не корректности.
7. Хороший код, который нельзя откатить
Миграции были устроены правильно. Раз в основную базу писали два сервиса, действовало правило «только расширение»: новые колонки nullable или с default, удаление и переименование — отдельным релизом.
public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query( `ALTER TABLE "car_entity" ADD "top_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`, ); await queryRunner.query(`CREATE INDEX "IDX_car_top_at" ON "car_entity" ("top_at")`); }
API админки, ещё не знающий о top_at, продолжает создавать и читать объявления. Это первая половина expand/contract, и она же делает возможным откат кода без отката схемы.
Сам откат кода при этом был невозможен. Образы собирались без версионного тега (:latest или без тега), деплой — kubectl rollout restart с imagePullPolicy: Always. Это перезапуск на тот же тег: новый ReplicaSet отличается от старого только аннотацией времени рестарта, поэтому kubectl rollout undo возвращает тот же тег и снова тянет последний образ. Вернуть предыдущую версию можно было только пересборкой.
Причина у этого решения была практическая: образы большие, и хранить по образу на каждый коммит не хотелось. Но размер реестра решается не отказом от версий, а политикой очистки: в GitLab Container Registry cleanup policy оставляет, например, последние 5–10 тегов и удаляет остальные. К тому же реестр хранит слои, а не образы целиком: если зависимости лежат в Dockerfile отдельным слоем до кода, новый тег добавляет только изменившиеся слои. Для отката нужна не вся история, а несколько последних версий.
Вторая деталь — стратегия выкатки. В процессе релиза я сознательно отказался от rolling update: не хотел, чтобы старая и новая версии обслуживали трафик одновременно, и выкатывался с плановым простоем — фронт в режим обслуживания, миграции, перезапуск, фронт обратно. Но это решение жило в процессе, а не в манифестах: стратегия в Deployment не задана, значит, сам Kubernetes по‑прежнему делал RollingUpdate. От рассинхрона версий защищал режим обслуживания на фронте, а не кластер, и сокет‑клиенты, подключающиеся к чату напрямую, этой защитой не покрывались.
Ещё в манифестах нашлось:
нет readiness‑проб: pod попадает в endpoints сразу после старта контейнера, ещё до того, как приложение начало слушать порт; нет и liveness‑проб, так что зависший процесс никто не перезапустит;
нет
resources.requests: HPA считает утилизацию относительно requests, и работал он только потому, что GKE Autopilot проставляет requests по умолчанию.
containers: - name: car-b image: registry.gitlab.com/.../api:{{ .Values.image.tag }} # $CI_COMMIT_SHORT_SHA readinessProbe: httpGet: { path: /health, port: 3333 } periodSeconds: 5 resources: requests: { cpu: 250m, memory: 256Mi } limits: { memory: 512Mi }
Выкатка — helm upgrade --set image.tag=$CI_COMMIT_SHORT_SHA, откат — helm rollback.
Вывод: обратимость — свойство всей цепочки «схема → образ → выкатка». Миграции у меня были обратимы, а цепочка в целом — нет.
8. Границы роутов не совпали с границами состояния
Фронтенд — SPA на React и TypeScript с Redux Toolkit, redux‑saga и redux-injectors. Каждая из 22 сущностей (Car, Search, Favorites, MyCars, Payments, ActiveChat и другие) — модуль со slice, saga, селекторами и хуком внедрения:
const useInjectCar = () => { useInjectReducer({ key: sliceKey, reducer }); useInjectSaga({ key: sliceKey, saga }); };
Идея — регистрировать редьюсеры и саги только на роутах, которые их используют. В маркетплейсе сущности оказались сквозными: модалка чата на каждой странице, авторизация везде, сущность авто — в карточке, редактировании и кабинете. У каждой страницы появился список зависимостей, который приходилось синхронизировать. Забыл одну — страница ломается, причём только при прямом заходе по ссылке.
Все внедрения переехали в корень:
const App: React.FC = () => { useInjectEntities(); // все 22 сущности ... };
Модульная структура осталась, ленивая загрузка — нет. Все саги‑наблюдатели работают на любой странице, а код состояния попадает в главный бандл, хотя сами страницы грузятся через React.lazy.
Корень проблемы — в модели данных. Большая часть этого «состояния» была серверными данными: загрузка, кеширование, инвалидация, повторные запросы. Сегодня я отдал бы это TanStack Query: компонент сам объявляет, какие данные ему нужны, кеш общий, и вопрос «на каком роуте зарегистрировать модуль» исчезает. Redux остался бы только для действительно клиентского состояния, если бы оно вообще понадобилось. Попутно ушла бы значительная часть шаблонного кода саг.
С SEO похоже: canonical, hreflang и около тридцати статических sitemap поддерживались вручную. Индексация работала, но сегодня я отдал бы это фреймворку (Next.js).
Вывод: паттерн разделения кода был правильным сам по себе, но граница, по которой он делил систему, не совпала с тем, как данные реально используются.
Что сработало
Кроме уже упомянутых (чат без синхронных зависимостей, сохранение до рассылки, миграции «только расширение»), я сохранил бы два решения.
OpenAPI здесь был не документацией, а контрактом на этапе компиляции. Клиенты для основного API и чата генерировались openapi-generator из спецификации и собирались в CI раньше приложения, поэтому часть несовместимых изменений бэкенда превращалась в ошибку TypeScript при сборке фронта.
Отдельные базы для production, development и E2E. Около 90 E2E‑тестов поднимали настоящее NestJS‑приложение и очищали все таблицы перед прогоном. Такой хелпер безопасен только потому, что структурно не может дотянуться до нужных данных. Чего не было — нагрузочного тестирования и сценария с двумя пользователями на разных репликах чата. Поэтому находку № 1 сделало чтение кода, а не тест.
Kubernetes: был ли он нужен
По нагрузке — нет, Docker Compose на виртуалке справился бы. Я выбирал Kubernetes как платформу, которой хотел уметь управлять. Он дал перезапуск упавших pod'ов, расписание на стороне платформы и одинаковый деплой пяти сервисов. Взамен потребовал сопровождения Helm‑чартов, раннера, агента и Traefik, а находка № 7 прямо следует из того, что платформу я настраивал сам и без ревью. Я выбрал бы его снова, но команде без той же цели не рекомендовал бы.
Что бы я сделал иначе
Решение | Чем обернулось | Что сделал бы сейчас |
|---|---|---|
Токен в заголовке сокета, две реплики без адаптера | Сообщения не доходят между pod'ами |
|
Guard на каждом событии чата | Аутентификация без авторизации на ресурс | Проверка участия в каждом обработчике |
Баланс «прочитал → посчитал → записал» | Lost update | Условный атомарный |
Проверка | Идемпотентность только для последовательных доставок | Атомарный переход статуса + баланс + запись pending operation в одной транзакции |
Видимость по группе + ночной CronJob | Корректность зависит от расписания |
|
CronJob без | Корректность держалась на повторных запусках |
|
| Нет отката | Тег по SHA + cleanup policy реестра, |
| Не совпало с границами данных | TanStack Query для серверного состояния |
Общая база у API админки | Связность через схему | Админка как модуль основного API со своей авторизацией |
Построить всё до проверки рынка | Инженерия выдержала, продукт — нет | Проверять спрос системой на порядок меньше |
Вместо вывода
Когда отвечаешь за кусок системы, архитектуру легко оценивать по диаграмме. Когда сам её деплоишь, откатываешь, мигрируешь данные и разбираешь сбои, критерии меняются. Хорошее архитектурное решение — не то, которое правильно выглядит на схеме, а то, последствия которого ты понимаешь и умеешь контролировать.
Большинство найденных ошибок не были сложными. Просто рядом не было второго инженера, который спросил бы: «а что будет при двух pod'ах?» или «почему этот пользователь имеет право читать этот chat_uuid?». В команде эти вопросы задают на ревью. В одиночку их приходится задавать себе самому — лучше раньше, чем через год.
Короткое видео с обзором продукта: https://youtu.be/dFaDsj-0L7o
Об авторе. Я Виталий Фалькевич, Software Architect / Tech Lead. Проектирую и довожу до продакшена веб-системы — от фронтенда до инфраструктуры. Здесь делюсь архитектурными решениями и ошибками из собственной практики. Обсудить статью, архитектурное ревью или сотрудничество можно в LinkedIn.

