Принципы сервисной архитектуры: NestJS-гейтвей + gRPC-домены¶
Статус: зафиксировано, требования дорабатываются под это. Документ задаёт рамку, внутри которой пишутся требования, контракты и код бэкенда. Отдельные решения внутри рамки ещё обсуждаются, сами границы — нет: на них завязаны валидатор, экспорт и демо.
Это дополнение к 03-architecture-draft.md, который описывает слои
(ядро / API / фронт). Здесь описано, как эти слои разложены по процессам и по какому протоколу
они разговаривают. Расхождения с черновиком — в разделе «Что меняется в других документах».
Откуда взято¶
Образец — внутренний монорепозиторий fairflow-crm/backend: NestJS 11 + Fastify + gRPC, 18
воркспейсов, единственная публичная точка входа gateway. Паттерн обкатан в проде, включая грабли,
которых в учебниках нет (см. A-03, A-08). Берём структуру и инварианты, не берём объём: там
мультиарендность, ABAC, реестр модулей, RBAC-каталог, Mongo + Postgres и шина на ~100 ключей
маршрутизации — для 14 дней это мёртвый груз. Список «что не берём» — в конце.
Ссылки на образец даны как fairflow-crm/backend/<путь> — читать при реализации, не переписывать
вслепую.
Принципы¶
Каждый принцип имеет ID A-xx, чтобы требования и код ссылались на него так же, как на R-xx из
00-brief/04-requirements-checklist.md (../00-brief/04-requirements-checklist.md).
A-01. Единственная публичная точка входа — gateway¶
Наружу (фронт, жюри, curl, QGroundControl) торчит один процесс: gateway на NestJS + Fastify,
HTTP :3000. Он отдаёт REST /api/..., OpenAPI /docs, поток прогресса и файлы экспорта. Ни один
доменный сервис не публикуется через ingress.
Следствие для k3s: в k3s/geoscan/ Ingress ведёт только на gateway. Всё остальное — ClusterIP.
A-02. Домены общаются только по gRPC; HTTP в домене — только ops-контракт¶
Доменный сервис отдаёт по HTTP ровно четыре маршрута и ничего больше:
| Метод | Путь | Ответ |
|---|---|---|
| GET | /healthz |
200 {"status":"ok"} |
| GET | /readyz |
200 {"status":"ok"} / 503 {"status":"error","message":…} |
| GET | /status |
200 {service, version, commit, bootTime, uptimeSec, timestamp} |
| GET | /metrics |
Prometheus text, text/plain; version=0.0.4 |
Бизнес-логика по HTTP в домене — запрещена. Не «нежелательна», а запрещена: как только у домена
появляется REST-ручка, вокруг неё немедленно вырастает второй, неконтролируемый контур авторизации.
Образец: fairflow-crm/backend/shared/src/ops-http-contract.ts — тело /status собирается одной
функцией, чтобы форма была идентична во всех процессах.
A-03. .proto — источник правды контракта, опции загрузчика зафиксированы¶
Все контракты между процессами лежат в одном каталоге proto/geoscan/*.proto и версионируются
вместе с кодом. Поля — snake_case. Клиенты и серверы для TS и Python генерируются из этих файлов,
руками структуры не дублируются.
Опции @grpc/proto-loader не оставляются по умолчанию — фиксируются одной общей функцией:
{ keepCase: true, longs: Number, arrays: true }
Это не стилистика. В образце одна и та же ошибка прилетала четыре раза и каждый раз проявлялась как «работает, но пусто»:
- без
keepCaseзагрузчик camelCase-итproject_id→ поле просто не доезжает, ошибки нет; - без
longs: Numberкаждоеint64декодируется в объектLong {low, high, unsigned}, а TypeScript видит объявленныйnumberи молчит:new Date(long)→ Invalid Date,Number(long)→ NaN; - без
arrays: trueпустойrepeatedприходит какundefined, и «нет галсов» неотличимо от «поле отсутствует».
Ни один тайпчекер этого не ловит. Ловит только pin-тест (A-13). Образец:
fairflow-crm/backend/shared/src/grpc/loader-options.ts — там же разобран случай, когда longs:
String легитимен.
A-04. Авторизация в два слоя, домены не видят пользовательский JWT¶
- Клиент → gateway: JWT в
Authorization: Bearer. Подпись проверяется на гейтвее. - Gateway → домен: сервисный ключ в gRPC-metadata (
x-service-api-key,x-request-id, при необходимостиx-user-id). Домен валидирует ключ и не парсит пользовательский токен.
Для хакатонного контура допустим упрощённый режим: один статический сервисный ключ из creds-store
(project/geoscan), без сервиса auth и без пользовательских ролей. Но форма вызова остаётся той
же — метаданные проставляются всегда, иначе потом это не вкрутить. Решение «нужна ли вообще
аутентификация пользователей» — открытый вопрос (см. конец).
A-05. Сквозной контекст запроса передаётся явно¶
x-request-id рождается на гейтвее (или берётся из входящего заголовка), кладётся в metadata каждого
gRPC-вызова, попадает в каждую строку лога и в каждый span. По нему и только по нему сшивается
история одного расчёта: POST /api/plans → planner → validator → export.
Это то, что на защите превращает «у нас есть логи» в «покажите, что происходило с этим планом» за одну команду.
A-06. Единый контракт ошибок, маппинг gRPC → HTTP в одном месте¶
Домены бросают RpcException с кодом gRPC. Гейтвей переводит код в HTTP одной таблицей и отдаёт тело
единой формы {errorCode, message, details?, errors?}. Таблица — ровно как в образце
(shared/src/grpc/grpc-to-http.ts):
| gRPC | HTTP | Наш случай |
|---|---|---|
INVALID_ARGUMENT |
400 | сценарий не проходит схему |
NOT_FOUND |
404 | нет плана с таким id |
FAILED_PRECONDITION |
422 | нет борта, способного закрыть требуемый GSD; площадка вне разрешённого ВП |
DEADLINE_EXCEEDED |
504 | решатель не уложился в time_limit_s |
RESOURCE_EXHAUSTED |
429 | очередь расчётов заполнена |
UNAVAILABLE |
503 | planner не поднят |
Отдельно важное для нашей задачи: «задача нерешаема» — это не 500. Infeasible из ядра
(03-architecture-draft (03-architecture-draft.md), altitude_for_gsd) доезжает до фронта как 422 с
внятным details, а не как «что-то пошло не так».
A-07. Долгий расчёт — не синхронный вызов, а задача со статусом¶
Расчёт плана идёт секунды-минуты и должен показывать прогресс. Поэтому:
POST /api/plans → 202 {planId, status: "queued"}
GET /api/plans/{id} → {status, metrics?, plan?}
GET /api/plans/{id}/events→ SSE: прогресс решателя, промежуточная целевая функция
GET /api/plans/{id}/export/{fmt}
Внутри: Planner.Plan — server-streaming RPC, гейтвей ретранслирует поток в SSE. Синхронный
ответ на POST /api/plans остаётся только для time_limit_s ≤ 5 (демо-сценарии, тесты) —
это исключение, а не режим по умолчанию.
Почему это принцип, а не деталь: фронт (слайдер Парето, таймлайн) строится вокруг потока прогресса. Если решить это поздно, переписывать придётся и фронт, и контракт.
A-08. Асинхронные события — транзакционный outbox, одна топология шины¶
Применяется только если заводим шину (см. открытые вопросы). Если заводим — то по правилам образца, без самодеятельности:
- запись в БД и строка outbox пишутся в одной транзакции; фоновой relay публикует и только после ack брокера помечает строку опубликованной;
- доставка at-least-once, потребитель дедуплицирует по
idempotencyKey ?? messageId; - топология (exchange, DLX, очереди, retry-очереди с
x-message-ttl) объявляется одним общим модулем. В образце расхождение типов одного и того же exchange между двумя сервисами роняло бутстрап с406 PRECONDITION_FAILED, а retry-очередь с dead-letter обратно в topic-exchange молча теряла сообщения.
Образцы: shared/src/outbox.ts, shared/src/bus-topology.ts.
Для нашего объёма честная оценка: RabbitMQ, скорее всего, избыточен. Прогресс расчёта решается gRPC-стримом (A-07), очередь задач — таблицей в Postgres. Шину заводим, только когда появится второй потребитель одного события.
A-09. Мутации идемпотентны по ключу¶
POST /api/plans с тем же Idempotency-Key не должен посчитать план дважды. Реестр ключей:
{scope, key} → результат, первый запрос занимает ключ и сохраняет полный ответ, повтор отдаёт
сохранённый байт-в-байт. Образец: shared/src/idempotency.ts.
Практический смысл на защите — двойной клик по «Рассчитать» не порождает второй прогон OR-Tools на 30 секунд.
A-10. У домена своя схема данных, кросс-домен — только через gRPC¶
Один Postgres (+PostGIS), но отдельная схема на сервис, никаких запросов в чужую схему и никаких JOIN через границу домена. Нужны данные соседа — вызов его RPC.
Правило из образца: имена каталогов миграций должны быть уникальны в пределах БД — два
..._init в разных схемах ломают применение второй.
A-11. Конфигурация — только из env, секреты — только из creds-store¶
Ни одного адреса, ключа или пароля в коде и в git. Всё через ConfigService, значения — из env,
секреты кладутся в project/geoscan в creds-store (правило из корневого CLAUDE.md). У каждого
сервиса — .env.example с полным перечнем переменных и дефолтами для локального запуска.
A-12. Наблюдаемость заводится вместе с сервисом, а не после¶
/metrics есть с первого коммита сервиса, scrape-target и дашборд — в тот же день (правило
корневого CLAUDE.md). Минимальный набор метрик для нас:
geoscan_plan_duration_seconds(histogram, labelobjective) — сколько считает решатель;geoscan_plan_total{status}— успех / infeasible / таймаут;geoscan_validator_violations_total{kind}— нарушения по видам, это прямой вход в демо;- стандартные gRPC-метрики по методам.
A-13. Инварианты держатся тестами-барьерами, а не договорённостями¶
Каждый принцип, который можно нарушить молча, закрывается тестом, падающим при нарушении:
- pin-тест обходит все регистрации gRPC-клиентов и серверов в репозитории (читая исходники, не по
списку) и падает, если где-то потеряны
keepCase/longs(A-03); - тест проверяет, что ни один доменный сервис не объявляет HTTP-контроллеров кроме health/metrics (A-02);
- тест проверяет, что
validate/не импортируетcoverage/иrouting/(03-architecture-draft (03-architecture-draft.md)).
Это самая дешёвая часть архитектуры и единственная, которая переживает спешку последней недели.
A-14. Новый сервис — из шаблона, по чеклисту¶
- Скопировать каркас существующего домена (
main.ts,application.ts, health, metrics, config, grpc-модуль). - Подключить проверку сервисного ключа.
- HTTP — только ops-контракт (A-02).
- Зарегистрировать клиента в gateway и завести BFF-контроллер.
- Прописать в
docker-compose.yml, вk3s/geoscan/, в Prometheus.
A-15. Соразмерность: минимум процессов¶
14 дней и один ревьюер. Дробление на десяток сервисов убьёт проект вернее, чем монолит. Целевой контур — четыре процесса, и каждый новый требует обоснования, почему он не может быть модулем.
Разрез на сервисы¶
| Сервис | Язык | HTTP | gRPC | Что внутри | Хранилище |
|---|---|---|---|---|---|
| gateway | TS / NestJS + Fastify | 3000 (публичный) | — | REST /api, OpenAPI, SSE-прогресс, отдача файлов экспорта, справочники fleet/payloads, реестр сценариев и планов |
Postgres, схема gateway |
| planner | Python | 3001 (ops) | 5001 | ядро: geometry/, coverage/, routing/ (OR-Tools), export/. Server-streaming прогресса |
без состояния |
| validator | Python | 3002 (ops) | 5002 | независимый счётчик метрик и нарушений | без состояния |
| airspace | TS | 3003 (ops) | 5003 | зоны, ограничения ВП, справочники ФП-138 | Postgres + PostGIS, схема airspace |
Порты — по образцу: HTTP 300x, gRPC 500x, номер совпадает.
planner и validator — два разных процесса намеренно. Правило «валидатор не импортирует ядро»
(03-architecture-draft (03-architecture-draft.md), 70-plan/01) в
одном процессе держится на дисциплине, а разными процессами — на топологии. Заодно валидатор можно
показать жюри как публичную ручку POST /api/validate: «проверьте наш план сами».
airspace заводится последним. Пока зоны лежат в статике — это модуль внутри gateway. Отдельный
процесс появляется, когда появятся внешние источники данных о воздушном пространстве.
Минимальный контур, который должен подняться первым: gateway + planner. Валидатор в этот момент уже существует как CLI (он пишется раньше продукта) и оборачивается в gRPC-сервис вторым шагом.
Полиглотность: чем gRPC оправдан именно здесь¶
Обычный аргумент против gRPC внутри — «у нас всё на одном языке, хватило бы HTTP». У нас не один язык, и это не прихоть: OR-Tools, Shapely, pyproj — это Python, а гейтвей, фронт и весь операционный обвес — TypeScript. gRPC — то, что сшивает их по одному описанию контракта, без ручной синхронизации DTO на двух языках.
Правила для полиглотного контура:
- один
.protoна обе стороны, генерация в CI:@grpc/proto-loader/ts-protoдля TS,grpcio-toolsдля Python. Сгенерированный код в git не коммитится (кроме дескрипторов для reflection); snake_caseв proto — по умолчанию для Python, и именно ради него на стороне TS обязателенkeepCase(A-03);- ops-контракт (A-02) одинаковый на обоих языках — Prometheus и k8s-пробы не должны знать, чем написан сервис;
- ядро на Python не знает ни про HTTP, ни про gRPC. gRPC-сервер — тонкий адаптер поверх тех же функций, что вызывает CLI. Это усиление принципа из черновика архитектуры, а не замена ему.
Что меняется в других документах¶
03-architecture-draft.md— FastAPI отпадает. Публичный HTTP теперь на NestJS-гейтвее; Python-часть выставляет gRPC. Слои («ядро не знает про web») и список модулей ядра остаются без изменений — меняется только то, чем ядро обёрнуто снаружи. Разделы «API» и «Фронтенд» черновика надо перечитать под A-07: ручки становятся асинхронными (202+ статус + SSE), а не «синхронно приtime_limit_s ≤ 30».- 40-formats/03-our-mission-schema.md — схема сценария
и плана получает второе представление,
.proto. Источник правды один; JSON-схема для REST и proto для внутреннего транспорта должны генерироваться из него или сверяться тестом. - 00-brief/04-requirements-checklist.md (
../00-brief/04-requirements-checklist.md) — требования к API переформулируются под асинхронную модель; добавляются строки на ops-контракт и метрики. src/backend/— пустой. Раскладка заводится по этому документу:proto/,shared/,gateway/,planner/,validator/.
Что из образца сознательно не берём¶
| Что | Почему не берём |
|---|---|
Мультиарендность (x-project-id как единица изоляции) |
у нас нет арендаторов; изоляция по сценарию — это ключ в БД, а не сквозной контур |
| ABAC / компилируемые предикаты доступа, RBAC-каталог разрешений | недели работы ради модели прав, которой в ТЗ нет |
| Реестр модулей, манифесты, per-project включение модулей | набор функций фиксирован |
| Mongo рядом с Postgres | один Postgres + PostGIS. Два хранилища — двойной обвес на ровном месте |
| GraphQL | в образце его как раз выпиливают; повторять нечего |
| Полноценный сервис auth с выдачей ключей и seed'ом | статический сервисный ключ из creds-store (A-04) |
| RabbitMQ с ~100 ключами маршрутизации | пока нет второго потребителя события — см. A-08 |
Что берём почти дословно: ops-http-contract, loader-options + его pin-тест, grpc-to-http,
метаданные и x-request-id, контракт ошибок, idempotency.
Открытые вопросы (для следующего захода по требованиям)¶
- Нужна ли аутентификация пользователей вообще? ТЗ её не требует явно. Варианты: нет совсем / один демо-пользователь / полноценный JWT. От ответа зависит, появляется ли пятый процесс.
- Очередь расчётов: таблица в Postgres + воркер или сразу брокер? Связано с A-08.
- Хранение планов: Postgres с самого начала или файлы, как предложено в
01-libraries.md? Рекомендация — Postgres сразу, потому что реестр планов и идемпотентность (A-09) всё равно требуют таблицы. - Где живёт
export/— внутри planner (сейчас так) или отдельным сервисом? Отдельный оправдан, только если экспорт понадобится для планов, посчитанных не нами. - Нужен ли PostGIS на старте или geometry целиком в Shapely/pyproj в памяти.