Структура backend
internal/├── api/ # транспорт HTTP: роутинг, DTO, валидация входа├── controlplane/ # транспорт gRPC: стримы агентов + рендер desired state├── domain/ # бизнес-логика, по пакету на фичу├── store/ # доступ к данным: sqlc-запросы, транзакции├── jobs/ # периодические и отложенные задачи (River)└── platform/ # инфраструктурные клиенты: crypto/KMS, ACME, mailer, telemetry, configНаправление зависимостей — только вниз: api/controlplane/jobs → domain → store/platform. Обратных импортов нет; domain не знает про Fiber, gRPC и River.
domain
Section titled “domain”По пакету на фичу: nodes, inbounds, chains, certificates, clients, subscriptions, billing, adminauth, audit. Пакет фичи содержит типы предметной области и сервис с операциями; сервис владеет транзакцией: бизнес-операция (например, «перевести подписку в suspended») выполняет изменение данных, запись в audit_log и инкремент ревизий затронутых нод в одной транзакции.
Инкремент ревизии — единственная точка связи фич с control plane: сервисы зовут nodes.Touch(tx, nodeIDs...), который поднимает desired_revision и после коммита сигналит controlplane отправить свежие снапшоты в открытые стримы.
controlplane
Section titled “controlplane”- Реестр стримов — какие ноды подключены; идентичность из mTLS-сертификата.
- Рендер — сборка
NodeConfigноды из данных (domain-читатели): инбаунды + клиенты групп доступа + сегменты цепочек + сертификаты + торрент-правила. Рендер — чистая функция от состояния БД; снапшот нигде не сохраняется. - Приём —
ApplyResult,StatusReport,UsageReport(транзакция учёта — вdomain/subscriptions),LogBatch(маршрутизируется ожидающему HTTP-запросу/SSE).
Изоляция движка. controlplane/render/xray — единственный пакет backend, знающий синтаксис и семантику конфигурации Xray (XrayConfig). Ни domain, ни api не оперируют Xray-структурами — только предметной моделью. Изменение формата Xray или добавление второго движка (render/singbox) локализовано в одном пакете; проверяется в CI запретом импорта render/xray откуда-либо, кроме controlplane.
Доменные события
Section titled “Доменные события”Сервисы фич публикуют события об изменениях состояния — синхронно, внутри процесса, без брокера:
type DomainEvent interface { EventName() string // "subscription.suspended", "certificate.renewed", ... AggregateID() uuid.UUID OccurredAt() time.Time}Диспетчер вызывает подписчиков в той же транзакции (аудит, инкремент ревизий) или после коммита (уведомления, метрики):
| Подписчик | Когда | Что делает |
|---|---|---|
| Аудит | В транзакции | Запись в audit_log с diff |
| Ревизии | В транзакции | nodes.Touch() для затронутых нод |
| Уведомления | После коммита | Email/webhook админу (сертификат, нода offline) |
| Метрики | После коммита | Счётчики Prometheus |
Событие — внутреннее соглашение кода, а не инфраструктура: очередей, outbox-таблиц и Event Sourcing нет. Ценность — одна точка, где изменение состояния превращается в свои побочные эффекты, вместо ручного вызова аудита и ревизий в каждом сервисе. Аудит и события концептуально различаются: событие фиксирует состоявшийся переход состояния; в аудит попадают также действия без перехода (неудачный вход, отклонённая валидация) — они пишутся напрямую, минуя события.
Хендлеры тонкие: распарсить, вызвать сервис фичи, сериализовать. Три группы роутов с разными middleware-цепочками: /api/v1 (JWT scope admin, RBAC), /portal/v1 (JWT scope client), публичные (/sub, /webhooks). OpenAPI-аннотации живут на хендлерах.
Все фоновые процессы — задачи River в PostgreSQL: истечение подписок, сброс периодов трафика, продление сертификатов, агрегация и retention статистики, ротация DEK, очистка сессий и неподтверждённых регистраций. Периодические задачи регистрируются в коде с cron-выражениями; исполнение сериализуется самим River — при нескольких инстансах backend задача выполняется один раз.
Обработка ошибок
Section titled “Обработка ошибок”domain возвращает типизированные ошибки (ErrNotFound, ErrConflict, ErrValidation{fields}); api отображает их в RFC 9457, controlplane — в статусы gRPC. Ошибки внешних систем (KMS, ACME, DNS) оборачиваются с контекстом на границе platform.