Один разработчик, пять сервисов, три базы: что я понял об архитектуре, когда пришлось самому всё деплоить и чинить

Через год после закрытия проекта я открыл его код и устроил себе code review. Это был автомобильный маркетплейс объявлений, который я в одиночку спроектировал, написал, задеплоил и сопровождал: React‑фронтенд, три бэкенда на NestJS, PostgreSQL, чат на Socket.IO, интеграция платежей, Kubernetes в GKE, CI/CD, CronJob'ы и E2E.
Я нашёл несколько ошибок, которые почти наверняка проявились бы при росте трафика. Среди них — сообщения, которые не доходят между pod'ами, гонка при списании баланса и недостаточная авторизация в чате. Были и менее заметные проблемы эксплуатации. Ни одна не была сложной. Интереснее другое: почти все они возникли не внутри какой‑то технологии, а на стыках — между репликами, между чтением и записью, между кодом и расписанием, между сборкой и выкаткой.
Сразу о рамках. Реальной нагрузки у проекта не было: он прошёл софт‑ланч, а потом я его закрыл, потому что коммерческого интереса оказалось недостаточно. Платёжные функции были реализованы, но для пользователей так и не включены. 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: OnFailureconcurrencyPolicy не задан (по умолчанию 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
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.