01. Архитектура системы¶
1.1. Контекст¶
┌───────────────────────────────┐
│ iiko Transport │
│ api-ru.iiko.services │
└───────────────────────────────┘
▲ импорт ▲ заказы (этап 2)
│ (pull) │ + webhooks
│ │
┌──────────┐ ┌────┴─────────────────┴────────────────────────┐
│ Витрина │─────▶│ │
│ (сайт, │ │ Монолит (Kotlin + Spring) │
│ моб. │◀─────│ │
│ прилож.)│ │ ┌─────────┐ ┌─────────┐ ┌──────┐ ┌────────┐ │
└──────────┘ │ │iiko-sync│ │ catalog │ │media │ │ admin │ │
│ └─────────┘ └─────────┘ └──────┘ └────────┘ │
┌──────────┐ │ ┌─────────┐ ┌─────────┐ ┌──────┐ ┌────────┐ │
│ Админка │─────▶│ │ order* │ │ pricing*│ │crm* │ │platform│ │
│ React+ │◀─────│ └─────────┘ └─────────┘ └──────┘ └────────┘ │
│ AntD │ │ * — следующие этапы │
└──────────┘ └───────────────┬───────────────┬───────────────┘
│ │
┌───────▼──────┐ ┌─────▼──────┐
│ PostgreSQL │ │ Redis │
└──────────────┘ └────────────┘
│
┌───────▼──────┐
│ S3 / MinIO │ (медиа)
└──────────────┘
1.2. Почему монолит и что это значит на практике¶
Legacy распался на 5 сервисов, из которых два независимо импортировали одно и то же меню из
iiko (site/app/Models/Import/* и menu/internal/domain/handlers/imports/*) в две разные схемы
БД с разной моделью. Это главная цена того разделения: расхождение данных между сайтом и API меню,
двойная поддержка, двойные баги.
Монолит здесь — один процесс и одна база, но с внутренними модулями и явными границами. Границы держит Spring Modulith — это не «ещё одна библиотека», а именно тот инструмент, который делает модульность проверяемой, а не декларативной.
Что даёт Spring Modulith¶
| Возможность | Как используем |
|---|---|
@ApplicationModule + структура пакетов |
модуль = пакет верхнего уровня под com.panam; всё, что не в корне пакета модуля, — приватная реализация, недоступная другим модулям |
@NamedInterface |
явный публичный контракт модуля (iiko::api, catalog::api); остальное закрыто на уровне компиляции проверок |
ApplicationModules.verify() |
один JUnit-тест валит сборку при нарушении границ или цикле зависимостей — заменяет ручные правила ArchUnit |
| Event Publication Registry | транзакционный outbox из коробки: событие сохраняется в event_publication в той же транзакции, обработчик помечает выполнение, незавершённые переигрываются при рестарте |
@ApplicationModuleTest |
тест модуля с поднятием только его контекста и заглушками соседей — быстрые интеграционные тесты импорта и каталога |
Documenter |
автогенерация C4-диаграмм модулей и таблицы зависимостей в CI — документация не расходится с кодом |
| Observability | трассировка на границах модулей: видно, сколько занял catalog внутри обработки события импорта |
Ключевой выигрыш для этого проекта — Event Publication Registry. Схема «импорт закоммитил → опубликовал событие → каталог пересобрал снапшот» без outbox теряет пересборку при падении процесса между коммитом и обработкой, и витрина остаётся со старым меню до следующего импорта. Modulith закрывает это без ручной таблицы outbox и своего диспетчера.
Правила, которые Modulith не проверяет и которые остаются на ревью:
- модули не ходят в чужие таблицы напрямую — только через API модуля-владельца
(SQL-запросы к чужим схемам ловятся отдельным ArchUnit-правилом на пакеты jOOQ);
- модуль iiko не знает про catalog ни в каком виде.
ArchUnit остаётся, но в узкой роли: правила именования, запрет @Transactional на приватных
методах, запрет обращения к схеме чужого модуля. Проверку границ делает Modulith.
Требование к дизайну: iiko и публичное API витрины (storefront) должны быть выделяемы в
отдельный процесс без переписывания домена (нагрузка на витрину и на импорт разная по профилю).
Модульная структура Modulith — прямая заготовка под это: выделение модуля сводится к замене
внутрипроцессных событий на брокер.
1.3. Модули¶
| Модуль | Ответственность | Схема БД |
|---|---|---|
platform |
конфигурация, безопасность, ошибки, аудит, планировщик, кеш, миграции | platform |
iiko |
HTTP-клиент iiko, аутентификация/токены, синхронизация, зеркало данных | iiko |
catalog |
витринный каталог: категории, товары, варианты, модификаторы, публикация | catalog |
media |
загрузка, хранение, обработка изображений, ренditions | media |
admin |
админский HTTP-API, RBAC, представления для админки | — |
storefront |
публичное API витрины, сборка и отдача снапшота меню | — |
order (этап 2) |
корзина, заказ, отправка в iiko, статусы | ordering |
Правила зависимостей:
storefront ──▶ catalog ──▶ iiko(api) ──▶ platform
admin ──▶ catalog, media, iiko(api)
iiko ──▶ platform
catalog ──▶ media(api), platform
iikoне знает оcatalog. Импорт не имеет права трогать витринные данные.catalogчитает зеркало iiko черезiiko.api(read-only порты), но не пишет в него.- Обратная связь — только событиями:
IikoMenuImported,IikoStopListChanged,IikoItemsAppeared.
1.4. Технологический стек¶
| Слой | Выбор | Обоснование |
|---|---|---|
| Язык / рантайм | Kotlin 2.x, JDK 25 (LTS) | virtual threads без ограничений pinning, generational ZGC, compact object headers — заметно для парсинга 19 МБ JSON |
| Фреймворк | Spring Boot 4.x (Spring Framework 7) | web mvc на виртуальных потоках; не webflux — код проще, а профиль нагрузки IO-bound |
| Модульность | Spring Modulith | границы модулей, event publication registry (outbox), модульные тесты, автодокументация |
| БД | PostgreSQL 18 | jsonb для сырья iiko, генерируемые колонки, partial index |
| Доступ к данным | Spring Data JPA (админ CRUD) + jOOQ (витрина, импорт) | CRUD удобен на JPA, тяжёлые выборки и bulk-upsert — на jOOQ |
| Миграции | Flyway | версионные SQL-миграции, без auto-ddl |
| Кеш | Redis | снапшот меню, стоп-листы, rate limit, ShedLock |
| Планировщик | Spring Scheduling + ShedLock | защита от двойного запуска на нескольких инстансах |
| Объектное хранилище | S3-совместимое (MinIO on-prem / Yandex Object Storage) | картинки и их производные |
| Обработка изображений | imgscalr / thumbnailator + webp (libwebp через cwebp или imageio-webp) |
генерация ренditions при загрузке |
| HTTP-клиент | Spring RestClient + Resilience4j |
таймауты, ретраи, circuit breaker для iiko |
| Документация API | springdoc-openapi | OpenAPI 3.1 генерируется из кода, отдаётся в /api/docs |
| Логи | Logback + JSON encoder, MDC (traceId, syncRunId) |
|
| Метрики | Micrometer → Prometheus | |
| Тесты | JUnit 5, Testcontainers (Postgres, Redis), WireMock (iiko), ApplicationModules.verify(), ArchUnit |
|
| Сборка | Gradle (Kotlin DSL), Docker multi-stage, базовый образ на JDK 25 |
Точные версии Boot / Modulith / Kotlin фиксируются в первый день работы над каркасом: связка «JDK 25 + Boot 4 + Modulith» проверяется на пустом проекте до того, как на неё ляжет код. Если на момент старта Modulith под выбранную версию Boot ещё не выпущен — берём предыдущий минорный Boot; версия фреймворка не является определяющим решением, а вот отказ от Modulith означал бы ручной outbox и ручные правила границ.
Фронтенд админки:
| Слой | Выбор |
|---|---|
| База | React 18 + TypeScript, Vite |
| UI | Ant Design 5 |
| Состояние сервера | TanStack Query |
| Формы | React Hook Form + Zod (или antd Form + Zod-резолвер) |
| Роутинг | React Router 6 |
| Таблицы | antd Table + серверная пагинация/фильтрация |
| DnD | dnd-kit (сортировка категорий и товаров) |
| Клиент API | сгенерированный из OpenAPI (openapi-typescript + typed fetch) |
| Загрузка картинок | antd Upload + кроп (react-easy-crop) |
Админка — отдельное SPA, собирается независимо, раздаётся nginx, ходит в /api/admin/v1.
Не встраивается в Spring-шаблоны (это была боль Orchid: верстка админки внутри бэкенда).
1.5. Нефункциональные требования¶
Производительность витрины
- GET /api/v1/menu — p95 ≤ 80 мс при отдаче из снапшота, ≤ 400 мс при холодном кеше.
- Полный снапшот меню одной организации ≈ 300–700 КБ JSON (после нормализации модификаторов),
gzip/br ≈ 60–120 КБ. Сырой ответ iiko на те же данные — 19 МБ; наружу такое отдавать нельзя.
- Поддержка ETag / If-None-Match и revision в ответе — витрина не перекачивает меню без
изменений.
Синхронизация
- Меню: раз в 5 минут инкрементально по revision, полная — раз в сутки ночью.
- Стоп-листы: раз в 30–60 секунд + обработка webhook, задержка появления стоп-листа на витрине
≤ 60 секунд.
- Организации/терминалы/ценовые категории: раз в час.
- Импорт атомарен для потребителя: витрина не должна увидеть полупримененное меню.
Доступность - Недоступность iiko не роняет витрину: каталог отдаётся из последнего успешного снапшота, в админке — явный индикатор «данные устарели, последняя успешная синхронизация N минут назад». - Ошибка импорта не удаляет данные. Удаление позиций — только по успешно завершённому полному импорту (см. 03-iiko-sync, «Мягкое удаление»).
Устойчивость к изменениям и ошибкам на стороне iiko
Практика эксплуатации: сотрудники случайно удаляют или пересоздают блюда в iiko. Для витрины это
означает исчезновение позиции, к которой привязана карточка, а при пересоздании — появление того
же по смыслу блюда с новым itemId. Система обязана переживать это без потери контента и без
молчаливой пропажи товара с сайта.
Механизм — снимок привязки: в момент, когда контент-менеджер связывает вариант товара с позицией iiko, сохраняется слепок состояния этой позиции (id, sku, название, учётная группа, категория внешнего меню, цена, вес). Снимок обновляется при каждом успешном импорте, пока позиция жива, и замораживается, когда позиция пропала.
Что это даёт:
- Карточка не «обнуляется»: даже если блюда в iiko больше нет, в админке видно, к чему она была привязана, каким было название и цена, и с какого момента позиция пропала.
- Автоматический подбор замены: при пересоздании блюда в iiko совпадает
sku(и/или название и учётная группа) — система предлагает перепривязать вариант на новыйitemIdодним действием. - Разделение «пропало навсегда» и «мигнуло»: позиция, отсутствовавшая один импорт и вернувшаяся, не порождает инцидента; отсутствие дольше настраиваемого порога — порождает.
- Точная диагностика: на вопрос «почему товара нет на сайте» отвечает не догадка, а запись инцидента с датой, прежним состоянием и виновным импортом.
Правила поведения: - удаление позиции в iiko никогда не удаляет ни вариант, ни товар; - товар снимается с публикации автоматически только когда не осталось ни одного живого варианта, с записью причины и уведомлением в админку; - если у товара с вариантами пропал один из размеров — товар остаётся опубликованным, вариант отдаётся как недоступный.
Модель данных — 02-catalog-domain.md §2.4 (catalog.variant_binding, catalog.binding_incident),
поведение импорта — 03-iiko-sync.md §3.4.
Безопасность
- Админка: JWT (access 15 мин + refresh в httpOnly cookie), RBAC по правам, аудит всех
изменяющих операций.
- Публичное API — только чтение, без персональных данных, rate limit по IP.
- Секреты iiko (apiLogin) — в переменных окружения/секрет-хранилище, в БД не хранятся открыто.
Наблюдаемость
- Каждый запуск синхронизации — запись в iiko.sync_run со статистикой (создано/обновлено/
удалено/пропущено, длительность, ревизия).
- Алерты: импорт меню не завершался успешно > 30 мин; стоп-листы не обновлялись > 5 мин;
доля ошибок запросов к iiko > 5%.
1.6. Окружения и данные¶
| Окружение | iiko | Назначение |
|---|---|---|
| local | WireMock со слепком response.json |
разработка без сети |
| dev | тестовый apiLogin iiko | интеграционные проверки |
| prod | боевой apiLogin |
Слепок response.json (19 МБ) кладётся в тестовые ресурсы как фикстура импорта — на нём же
гоняются нагрузочные проверки парсинга и сборки снапшота.
1.7. Отношение к legacy: проектируем с нуля¶
Каталог не мигрируется. Схема legacy не является ориентиром.
Обоснование — в самой схеме. Товары там разложены по таблицам dish_pizza, dish_snacks,
dish_drinks, dish_non_food, каждая со своим набором колонок и своими таблицами связей
(dish_pizza_ingredients, dish_pizza_sauces, dish_pizza_toppings, dish_pizza_dough,
dish_snack_sauces, dish_drink_toppings — и так далее, суммарно десятки таблиц под то, что
является одним понятием «товар»). Тип товара стал структурой БД: добавление категории требовало
миграции, модели, репозитория, экрана и ветки в сборке меню. Позиции из реальной выгрузки — мерч,
разливное пиво, бакалея, посуда — в эту типизацию не помещаются вовсе.
Плюс два параллельных дерева категорий (dictionary_categories и dictionary_site_categories) и
дублирующий импорт iiko в двух сервисах с разными моделями.
Переносить такие данные означало бы наследовать разбиение по типам вместе с ними. Поэтому:
| Что | Решение |
|---|---|
| Категории каталога | создаются заново в новой админке |
| Карточки товаров, привязки к iiko, сортировка | клиент пересобирает в новой админке; помогает экран «Новые позиции iiko», где непривязанные блюда лежат готовой очередью с автоподсказкой вариантов |
| Изображения товаров | загружаются заново — нарезка по новым пресетам (см. 07-media.md), старые файлы нарезаны неверно и переносить их смысла нет |
| Заказы | не мигрируются, по решению клиента удаляются |
| Пользователи | единственное, что мигрируется — вместе с модулем клиентов на следующем этапе, не в этапе каталога |
| Slug'и | не наследуются автоматически; список URL, которые важно сохранить для SEO, собирается отдельно и задаётся руками при заведении категорий/товаров |
Единственное, для чего legacy используется в проектировании, — разбор ошибок: каждое решение в этих документах сверено с тем, как было сделано раньше и почему это не работало. Данные из legacy в новую систему не переносятся.
Что нужно проверить до старта: какие URL каталога участвуют в поисковой выдаче и платном трафике. Это единственное реальное последствие пересборки с нуля — потеря соответствия старым адресам. Решается списком редиректов на nginx, но список нужно составить заранее.
1.8. Изображения — отдельное ТЗ¶
Обработка изображений вынесена в самостоятельный документ 07-media.md, потому что в эксплуатации это оказалось источником постоянных дефектов: клиент загружал картинку, а на витрине она оказывалась обрезанной не по центру, размытой или в неверных пропорциях.
Первым пунктом этого ТЗ идёт работа, которую нельзя сделать в одиночку и нельзя пропустить: инвентаризация всех мест, где на витрине и в админке показываются изображения, с точными размерами и пропорциями. Пока эта таблица не заполнена, набор пресетов нарезки задать невозможно — а именно расхождение «загрузили одно, показали в другом соотношении» и порождало кривые картинки.
Пресеты не подбираются разработчиком «на глаз» и не выводятся из старых файлов: они собираются с фронтенда витрины (макеты, реальные CSS-размеры блоков, точки перелома адаптива) и фиксируются в конфигурации. Правила валидации при загрузке (минимальное разрешение, пропорции, запрет апскейла), точка фокуса при кропе и перегенерация всех ренditions при изменении пресета — там же.