Общий код¶
Платформы экосистемы снаружи разные, внутри одна. Для пользователя у каждой свой бренд, домен и набор услуг — но более 90% кода приходит из одного источника.
Эта страница объясняет, как этот общий код распространяется.
Почему это понадобилось¶
Вначале каждая платформа копировалась из шаблона. В результате одно исправление приходилось повторять вручную в 8 репозиториях: пропустишь один — и эта платформа молча отстаёт.
Измерение показало, что ~95% фронтенда действительно общие; реальное расхождение составляло около 40 строк с названием бренда. То есть дублирование было не техническим требованием, а наследием копирования.
Три механизма¶
Общий код распространяется по-разному в зависимости от слоя. Ни один из способов не является «копипастом» — все версионируются и обратимы.
| Слой | Форма | Механизм |
|---|---|---|
| Ядро backend | модуль Go | зависимость в go.mod |
| Слой frontend | пакет npm | зависимость в package.json |
| Каркас платформы | история git | git merge + ежедневный autosync |
1. Ядро backend — модуль Go¶
Аутентификация, ролевой контроль доступа (RBAC), API gateway, аудит,
AI-конвейер, интеграция eID/SSO — всё это находится в одном модуле Go.
Файл main.go платформы обычно занимает около 30 строк: запустить ядро и
добавить маршруты, специфичные для этой платформы.
Ядро имеет один прямой слой:
open-gerege-core— открытая основа, которую напрямую используют все backend-платформы государственной линии и Gerege.
До 2026-08-02 между ними находился private-gerege-core. Он не содержал
дополнительной логики или миграций, поэтому был удалён из цепочки и архивирован.
Коммерческая логика приложений остаётся в репозитории каждого продукта.
2. Слой frontend — @gerege/ui-core¶
Та же задача, которую ядро решило на backend, решена заново на frontend. Пакет содержит:
lib/**— клиент API, помощники BFF, словарь i18n, тема, сессия,components/**— оболочка, админка, личный кабинет, eID, gateway,api/**— логика 158 маршрутов BFF.
Пакет поставляется исходным кодом TypeScript (без сборки), поэтому
приложение компилирует его через transpilePackages в Next.js.
Распространение — открытый HTTPS-tarball: аутентификация не нужна, работает
и внутри сборки Docker.
Почему у маршрутов BFF остаётся обёртка
Next.js регистрирует маршруты через файловую систему, поэтому приложение хранит однострочный реэкспорт на каждый путь:
// src/app/api/org/[id]/route.ts
export { GET, PUT, DELETE } from '@gerege/ui-core/api/org/[id]';
export const dynamic = 'force-dynamic';
Все 158 файлов можно было бы свернуть в один [...path], но это уничтожило
бы список разрешений безопасности: перечень маршрутов определяет, какие
пути backend вообще доступны браузеру. Обёртка — осознанная цена.
3. Каркас платформы — наследование git¶
То, что не входит в пакет (структура страниц, globals.css, конфигурация
развёртывания), наследуется от шаблона через git merge. Ежедневный autosync
забирает изменения из вышестоящего шаблона и открывает pull request в
репозиториях приложений — каждое изменение, доходящее до production, проходит
проверку человеком.
Файлы, которые должны оставаться собственными для платформы (бренд,
развёртывание, CI, документация), защищены директивой merge=ours в
.gitattributes.
merge=ours не защищает от односторонних изменений
Этот драйвер разрешает только конфликты. Если вышестоящий шаблон удалит файл, слияние последует за ним — драйвер не будет вызван. Настоящая защита в том, чтобы каждый файл бренда/конфигурации имел разное содержимое с обеих сторон.
Что остаётся собственностью платформы¶
| В пакете / ядре | Принадлежит платформе |
|---|---|
lib/**, components/**, логика BFF |
brand.config.ts — имя, домен, цвета, адрес документации |
| Аутентификация, RBAC, gateway, аудит | components/landing/** — маркетинговый текст |
| Интеграция eID / SSO | app/**/page.tsx — регистрация маршрутов (тонкие обёртки) |
| Общий словарь i18n (846 ключей × 7 языков) | lib/<platform>I18n.ts — платформенная терминология |
Структура меню (AppShell) |
nav.config.ts — какие разделы платформа обслуживает |
app/globals.css — токены фирменных цветов |
|
deploy/**, .github/** — развёртывание, CI |
Почему платформенная терминология остаётся в приложении¶
Правило: общий словарь знает только общую поверхность. Слова, относящиеся лишь к одной платформе — лексика выписок и IBAN в кошельке, каталог API портала разработчика, терминология бизнес-процессов Ring — лежат в собственном словаре приложения.
Причина — стоимость: если поместить 1,104 термина Ring в общий словарь, их понесут и kiosk, и POS, и кошелёк, причём с каждым добавленным языком эта стоимость вырастает семикратно.
Реализация выглядит одинаково во всех репозиториях:
// lib/walletI18n.ts — 15 терминов кошелька × 4 языка
export function useWalletT() { … } // откат на английский для непереведённых языков
Если компонент передаёт T дочерним компонентам пропом, то разделение на
две функции (T + wt) заставит разделить и каждый проп. В таком случае
пишется один резолвер: если ключ платформенный — берётся из собственного
словаря, иначе из пакета (lib/lang.ts в ring-dgov).
Меню — структура общая, набор услуг платформенный¶
AppShell устроен одинаково на всех платформах (Суперадмин · Админ · Менеджер ·
Гражданин), но каждая платформа реализует лишь его подмножество: у кошелька
нет модулей gateway, relay и реестров.
| Настройка | Назначение |
|---|---|
navRoutes |
Маршруты, которые приложение действительно обслуживает; по ним фильтруется меню |
navSystemLabels |
Названия систем на rail (me → «Кошелёк») |
navExtra |
Пункты меню, существующие только на этой платформе (21 пункт BPM в Ring) |
navExtra приходит из CLIENT-компонента
UiCoreProvider — это client-компонент, вызываемый из серверного root
layout. Иконки меню (React-компоненты) и функции названий не пересекают
границу server→client. Поэтому приложение создаёт тонкую client-обёртку и
передаёт их изнутри неё:
// src/nav.config.tsx
'use client';
export default function AppNav({ children }) {
return <UiCoreProvider navExtra={NAV_EXTRA}>{children}</UiCoreProvider>;
}
Если пункт меню, который обслуживают лишь немногие платформы, помечен в
пакете как optIn: true, он появится только на тех платформах, которые
явно перечислили его в navRoutes.
Три автоматических барьера¶
Общий код порождает три разных вида зависимости. При поломке каждый проявляется по-своему, поэтому и барьеров три:
| Зависимость | Барьер | Что происходит при поломке |
|---|---|---|
| Код пакета ← код приложения | tsc |
Компиляция падает — видно сразу |
| Маршрут пакета ← BFF-обёртка приложения | check-routes |
Endpoint молча исчезает |
| Класс пакета ← CSS приложения | check-styles |
Экран молча теряет оформление |
-
check-brand— сборка падает, если название платформы встречается в коде внеbrand.config.ts. Название своей платформы читается изbrand.config.ts, поэтому список не устаревает вручную.Что поймал барьер
Страница входа предлагала входить через «Gerege SSO (sso.gerege.mn)», хотя платформы государственной линии фактически перенаправляли на
sso.dgov.mn. Теперь хост читается изSSO_ISSUERна backend. -
check-routes— требует обёртку в приложении для каждого маршрута пакета. Без неё новый endpoint пакета молча исчезнет на этой платформе (логика не видна внутри приложения, поэтому визуально всё выглядит целым).Барьер не доказывает правильность EXCLUDE
Если маршрут намеренно не открывается, его вносят в
EXCLUDE— расхождение становится явным. Но ошибочную запись барьер не поймает. Именно так на одной платформе оказался закрытpublic/languages, из-за чего переключатель языков стал пустым: все барьеры зелёные, а экран сломан. -
check-styles— пакет не содержит CSS: оформление лежит вglobals.cssкаждого репозитория. Когда пакет вводит новое имя класса или CSS репозитория устаревает, компонент молча теряет оформление — кнопка остаётся с серой отделкой браузера по умолчанию, а таблица без рамок. Этот барьер сверяетclassNameпакета с CSS репозитория.
Версионирование¶
Все три механизма следуют semver. При выпуске новой версии Dependabot
открывает pull request в репозиториях-потребителях; само обновление —
однострочное изменение в go.mod или package.json.
Поднять версию в шаблоне недостаточно¶
Раз каждая платформа наследует от шаблона, кажется, будто «подними шаблон — и разойдётся ко всем». На деле два вида зависимостей работают по-разному:
| Файл | merge=ours? |
Расходится из шаблона? |
|---|---|---|
backend/go.mod |
да | ❌ никогда |
frontend/package.json |
нет | ✅ да |
Защита go.mod — структурное требование: строка module в каждом
репозитории своя (…/gerege-app-mn/backend против
…/wallet-gerege-mn/backend), поэтому любое слияние конфликтовало бы на самой
первой строке. Значит, поднятие версии ядра backend в шаблоне не разносит
ничего — в каждый репозиторий нужен свой pull request.
Дерево наследования к тому же трёхуровневое (public template → private
template → приложение), так что даже способный расходиться файл добирается до
листьев за несколько циклов autosync.
Ломающее изменение подвешивает pull request'ы по зависимостям
Расширение словаря с четырёх языков до семи сломало каждое место, где было
написано Record<Lang, …>. В результате каждый pull request Dependabot
падал на tsc, никто их не сливал, а следующий накладывался поверх — и
флот разошёлся с v0.4.0 до v0.10.2.
Поэтому, внося в пакет ломающее изменение: (а) опишите порядок миграции в release notes, (б) выпустите исправление в репозиториях-потребителях одновременно. Автоматическое обновление требует ручного шага при ломающих изменениях.
Отставание происходит молча
Если pull request'ы по зависимостям накапливаются, платформы расходятся по разным версиям и обещание «одно исправление доходит до всех» нарушается. Своевременное закрытие этих pull request'ов — условие работы этой структуры, а не приятное дополнение.
Связанное¶
- Технологический стек
- Общие принципы
- Аутентификация и права — параметр
AUTH_MODE