Как устроен бэкенд NDDev: Rust, одна база и агент вместо админки
Шесть сайтов NDDev на пяти языках, формы обращений, запись на встречу, ассистент и публикация материалов работают от одного ядра. Ниже — решения, на которых оно построено, и причины, по которым мы их приняли.
Модульный монолит на Rust
Ядро — один Cargo workspace из трёх слоёв. domain описывает правила без ввода-вывода: что такое ревизия, публикация или обращение и какие переходы состояний допустимы. infrastructure отвечает за PostgreSQL и внешние сервисы. api даёт HTTP, CLI и MCP. SEO и наблюдаемость вынесены в отдельные сервисы на Rust со своими базами: у них другой ритм изменений и другие отказы.
Стек — Rust 1.99, Tokio, axum, SQLx и PostgreSQL. Идентификаторы — UUIDv7: их создаёт приложение, и они упорядочены по времени создания.
Агент вместо админ-панели
У платформы нет визуальной CMS. Ей управляют командами — владелец и агенты, которые работают от имени компании: CLI, HTTP API и MCP вызывают один и тот же слой команд. Каждая команда типизирована, проверяет права и принимает ключ идемпотентности — повтор запроса с тем же ключом возвращает прежний результат, а не создаёт дубликат. Правка указывает ожидаемую версию документа: если его успели изменить, команда отказывает, а не перезаписывает чужую работу молча.
Даже контакты и подписи сайтов — не константы в коде, а ревизии в базе. Их меняют командой и публикуют вместе с контентом.
Одна база и надёжные фоновые задачи
Всё состояние живёт в PostgreSQL. Фоновая работа — SEO, переводы, сборка сайтов, письма — оформлена задачами в той же базе: outbox и inbox, аренда задачи с fencing-токеном, короткие транзакции. Отдельный брокер сообщений для такой нагрузки не нужен. Мы не обещаем exactly-once для внешних эффектов: результат внешнего сервиса принимается, только если он отвечает на заказанную работу и на тот же набор исходных данных.
Ревизии и публикация как снимок
Любая правка создаёт новую неизменяемую ревизию. Публикация собирает пакет из конкретных ревизий и их SEO-артефактов и получает номер поколения. Сайты строятся из манифеста этого пакета, поэтому повторная сборка даёт те же страницы. Чтобы вернуть прежнюю версию страницы, достаточно опубликовать её прежнюю ревизию — она никуда не делась.
SEO — отдельный сервис
Каждая индексируемая ревизия заказывает SEO-работу у отдельного сервиса. Модель предлагает заголовок и описание, отдельный запрос-редактор сверяет их с исходным текстом, а Rust проверяет цитаты, форму сниппета и отсутствие выдуманных цен и метрик. Canonical, robots, hreflang и разметка schema.org строятся детерминированно. Если модель недоступна, результат честно помечается как резервный, и опубликовать его можно только явным решением.
Фронтенд под контролем публикатора
Фронтенд — отдельный репозиторий на Astro. Публикатор ядра собирает его в песочнице: изолированные пространства имён Linux, Landlock, без сети и без доступа к секретам. Результат публикатор измеряет сам и только после этого выкатывает на сайты; предыдущая версия фронтенда возвращается одной командой.
Данные и задержка
Ядро пишет в Амстердаме, а синхронная реплика базы работает в Казахстане. Альтернативу мы измерили: если перенести запись в Казахстан, оставив API в Амстердаме, каждый запрос к базе пересекает канал с задержкой около 90 мс. Отправка обращения выросла бы со 119 мс до 2,58 с, открытие каталога — с 61 до 558 мс, поиск — с 55 до 929 мс. Синхронная реплика стоит один круг по каналу на коммит.
Наблюдаемость
Трассы и метрики уходят в формате OpenTelemetry, но работа ядра от приёмника не зависит: буферы конечны, и если сбор недоступен, запросы продолжают обслуживаться.
Что из этого следует для клиентских проектов
Мы не переносим эту архитектуру в каждый проект целиком — небольшому сайту она не нужна. Но несколько правил применяем везде: явные контракты между частями системы, изменения, которые нельзя потерять или применить дважды, измеримое поведение в production и возврат к прежней версии без ручной правки данных.