ADR-0063 — справочники pin.handbook (репликация из Talent Handbook)
Статус
Accepted
Дата
2026-07-15
Назначение
Зафиксировать способ появления в PIN нормативных справочников (страны, регионы, города, валюты, языки):
отдельный bounded context handbook как ACL-реплика внешнего talent.microservices.handbook, семантика
идентификаторов, границы scope v1, обработка удалений источника и стратегия потребления справочных данных
остальными сервисами PIN.
Контекст
- Справочников в PIN нет, а правила их уже требуют. Правила документирования
(strict-typed-documentation-rules.md)
предписывают ссылки
CurrencyId/CountryId/LanguageId+ snapshot-модели, но сервиса-владельца справочных данных не существует. Фактически спеки используют ad-hoc ISO-строки:Currency char(3)в chessboard/crm/rewards/investor-owner, свободные строкиRegion/Cityв проектах и лидах,RegionCode(ISO 3166-2) как фильтр-ключ каталога,Localeкак строку. Это даёт рассинхрон написаний, невозможность валидации на write-path и нестыкующиеся фильтры между сервисами. - Источник данных уже существует. Внешний сервис
talent.microservices.handbookведёт справочники групп компаний:Country(Idshort,ShortName?,FullName,IsoCode2,IsoCode3,DefaultCurrencyId?,DefaultLanguageId?),CountryRegion(Idlong,CountryId,Name),City(Idlong,CountryId,RegionId?,Name),Currency(Idshort,Name,IsoCode,Sign?),Language(Idshort,Name,IsoCode2,IsoCode3,FlagIsoCode2?),ContactInfoType. Изменения публикуются upsert-событиями в топикиhandbooks.after-{country|city|currency|language|contact}-information-update(ContractVersion = 2, полный snapshot записи). Seed: RU = 1, KZ = 2, BY = 3; имена одноязычные (русские). - У событий источника нет per-record версии. События
country/city/currency/languageнесут только статическийContractVersionсхемы (= 2) — монотонногоVersionна уровне отдельной записи в payload источника нет. Порядок применения upsert-ов определяется временем публикации (envelopeCreatedAt) и порядком партиции Kafka (ключ партиционирования =Idзаписи → все апдейты одной записи идут в одну партицию в порядке публикации). Регион приходит вложенной модельюRegionEventModelвнутри city-события; её состав —{ RegionId (long), Name }безCountryId(страна региона выводится изCountryIdвмещающего city-события). - У источника есть структурные особенности (гэпы). Отдельного события по региону нет — строка региона
приходит только внутри city-события (вложенной моделью). Удаления — жёсткие и без событий: удалённая
запись просто перестаёт упоминаться. Для начальной загрузки/пересинхронизации у источника есть операция
POST sync-handbooks, re-эмитящая все строки заново. - Паттерн интеграции с Talent уже принят и обкатан. Контекст
identityреплицирует компании и пользователей изtalent.microservices.identityчерез ACL-consumer с re-publish собственных событийpin.identity.*, и downstream-сервисы PIN на топики Talent не подписываются (talent-replica.md, talent-identity-sync.md, service-boundaries.md). Для справочников нужен тот же паттерн. - Ограничения платформы. ADR-0056 — simplicity-first, PostgreSQL как основная СУБД; ADR-0048 — платформа RUB-only; целевая нагрузка невысока, справочники — редко меняющиеся данные малого объёма (тысячи строк городов, десятки стран/валют/языков).
Решение
1. Новый bounded context handbook — ACL-реплика Talent Handbook
- Вводится 19-й bounded context
handbook— anti-corruption layer над внешнимtalent.microservices.handbook, по паттернуidentity↔ Talent Identity. handbookконсюмит топики источникаhandbooks.after-{country|city|currency|language}-information-update(ContractVersion = 2), хранит реплику в собственной схемеhandbook(PostgreSQL) и re-publish-ит собственные событияpin.handbook.{countries|regions|cities|currencies|languages}.v1— compacted-топики, key = id записи, ContractVersion = 1, payload = полный snapshot реплики (включаяIsArchivedиSourceObservedAt— envelopeCreatedAtпоследнего применённого события источника; собственной per-record версии реплика не вводит, монотонность — поSourceObservedAt).- Downstream-сервисы PIN никогда не подписываются на топики Talent напрямую — только на
pin.handbook.*. Контракты Talent (имена топиков, версии, состав payload) остаются деталью реализации одного консюмераhandbook; смена источника или его контрактов локализуется в одном месте. - Синк подчиняется тем же правилам, что и identity-реплика: идемпотентность по событию, snapshot-семантика
(перезапись всех синк-владеемых полей), атомарность «реплика + чекпоинт синка + re-publish», bootstrap
через пересинхронизацию источника. Отличие от identity-реплики — источник монотонности: у событий
справочников нет per-record
Version, поэтому порядок применения задаётся не version-guard-ом, а envelopeCreatedAtи порядком партиции Kafka (ключ =Idзаписи гарантирует, что апдейты одной записи не переупорядочиваются). Guard монотонности реплики — «не откатывать назад поCreatedAtисточника»; повторная доставка идемпотентна (upsert полного snapshot-а).
2. Id-семантика: идентификаторы источника, без Guid
- Реплика сохраняет идентификаторы источника как доменные ключи PIN:
CountryId/CurrencyId/LanguageId—short;RegionId/CityId—long. - Guid-идентификаторы для справочных записей не вводятся: прямая адресация без lookup-таблиц соответствий, события источника применимы напрямую, полная пересинхронизация не порождает конфликтов идентичности.
- ISO-коды (
IsoCode2/IsoCode3стран и языков,IsoCodeISO-4217 валют) — атрибуты записей справочника, а не доменные ключи контрактов: контракты API/событий ссылаются на записи по<X>Id, ISO-код используется для отображения, юридического следа и внешних интеграций. - Display-имя страны —
ShortName ?? FullName: у источникаCountry.ShortNameнеобязателен (краткое имя, напр. «Россия»),FullName— обязателен (полное, напр. «Российская Федерация»). Каноническое отображаемое имя страны в PIN (в том числе денормализуемоеCountryName) —ShortName, а при его отсутствии —FullName. У остальных справочников display-имя —Name. Currency.IsoCodeв источнике —varchar(16), а неchar(3): длина в источнике не гарантирована. Реплика хранит ISO-4217 вchar(3); при синке значение длиной ≠ 3 (после trim) — невалидно: событие уходит в reject/DLQ с warn-метрикой (валидный ISO-4217 — ровно 3 буквы; данные правятся в источнике).Currency.IsoCode(нормализованныйchar(3)) — единственное легитимное местоchar(3)в документации PIN.
3. Scope v1: Country, Region, City, Currency, Language
- В scope v1 входят пять справочников: Country, Region, City, Currency, Language.
ContactInfoType— вне scope: в PIN нет ни одного потребителя этого справочника; при появлении потребности добавляется additive (новый консюмируемый топик + реплика + свойpin.handbook.*-топик) без пересмотра этого ADR.- UI-локали и таймзоны — НЕ справочники handbook:
Localeостаётся строкой BCP-47 (напр.ru-RU), дом закрытого списка поддерживаемых локалей — контекстprofile;TimeZoneостаётся строкой IANA (у источника нет timezone-сущности).LanguageIdдля локали интерфейса не вводится: язык справочника (атрибут контента) и локаль интерфейса (настройка пользователя) — разные концепты.
4. Регион — первоклассная сущность PIN, derive из city-событий
- В PIN
Region— полноценная запись справочника со своим топикомpin.handbook.regions.v1(фильтры каталога и карточки проектов ссылаются наRegionId). - У источника отдельного region-события нет — строки регионов derive-ятся из вложенной модели
RegionEventModel = { RegionId (long), Name }city-события: при применении city-события создаётся/ обновляется запись региона, и её изменение re-publish-ится собственным событием региона. RegionEventModelне несётCountryId— страна региона выводится изCountryIdвмещающего city-события (город, регион и страна в одном событии консистентны): при апсерте регионаCountryIdберётся из city-события, а не из вложенной region-модели. Регион без единого пришедшего города в PIN не материализуется (нет источника егоCountryId).- Известное ограничение: переименование региона доезжает до PIN только с событием любого из его городов (источник публикует city-события при изменении региона своих городов; если ни одно city-событие не пришло — имя региона в реплике устаревает). Ограничение принимается как допустимое для редко меняющихся geo-данных и гарантированно закрывается полной пересинхронизацией (п. 5).
5. Delete-gap источника → soft-архив IsArchived + reconciliation
- У Talent Handbook жёсткие удаления без событий, поэтому в PIN физических удалений справочных
записей нет вообще. Единственный механизм вывода записи из оборота — признак
IsArchived(soft-архив): архивная запись исчезает из lookup-выдач, но остаётся резолвимой по id (исторические ссылки не ломаются). IsArchivedуправляется только admin-операцией archive/restore контекстаhandbook(администратор платформы) и reconciliation-процедурой; синк источника архив не снимает и не ставит самовольно — повторный приход заархивированной записи из источника восстанавливает её с warn-метрикой (источник главнее локального архива).- Reconciliation при полной пересинхронизации: Talent
POST sync-handbooksre-эмитит все строки источника; строки реплики, не пришедшие в окно пересинхронизации, — кандидаты в архив и попадают в reconciliation-отчёт; архивирование подтверждает администратор (автоматического архива по отсутствию в окне нет — защита от ложных срабатываний при частичном ресинке).
6. Потребление по сервисам: реплика только там, где hot-path
Канонический паттерн ссылки на справочник во всех контрактах PIN: <X>Id (тип источника) +
денормализованное display-имя <X>Name, снятое в момент записи. Стратегии по сервисам:
| Сервис | Стратегия |
|---|---|
chessboard | Единственная локальная реплика (подписка на pin.handbook.*): hot-path карточек и фильтров, валидация geo/валют на write-path, refresh денормализованных имён при переименованиях (с re-publish затронутых проектов) |
catalog | Без реплики и без подписки: получает id + name из snapshot-ов событий chessboard |
crm, profile | Хранят id + денормализованное имя; валидация на write-path — синхронный вызов internal resolve-API handbook |
user-activity | id + name с клиента, мягкая валидация (невалидный id не блокирует запись факта) |
investor-owner, rewards, promotion, messenger | Только CurrencyId + CurrencySnapshot из событий; resolve — на admin-write |
notifications, dev-stats | Не зависят от handbook (Locale/TimeZone — строки, п. 3) |
Snapshot-модели (CountrySnapshot, RegionSnapshot, CitySnapshot, CurrencySnapshot) имеют единый
дом в handbook; события с денежными суммами несут CurrencyId + CurrencySnapshot, зафиксированный
на момент публикации.
7. Lookup/resolve — PostgreSQL; справочник валют ≠ мультивалютность
- Публичный lookup (выдача справочников фронтам) и internal resolve (батч-валидация id) работают
на PostgreSQL (ADR-0056): объёмы — тысячи строк,
изменения редки, полнотекстовый поиск не нужен. Планировавшиеся ES-индексы
reference-*исключаются из data-architecture.md; поисковая инфраструктура (ADR-0059) на справочники не распространяется. - Наличие справочника валют не означает мультивалютность:
ADR-0048 (платформа RUB-only) не пересматривается.
Справочник даёт типизированную ссылку
CurrencyIdи display-атрибуты; допустимость валюты в денежных операциях по-прежнему ограничена правилами ADR-0048.
Область Действия
| Область | Входит | Не входит |
|---|---|---|
| Сервисы | новый контекст handbook; chessboard (локальная реплика + consumer); write-path-валидация в crm/profile | ведение (CRUD) справочных данных — остаётся в Talent Handbook |
| Контракты | 4 консюмируемых топика Talent handbooks.after-*; 5 топиков pin.handbook.*.v1; публичный lookup-API; internal resolve-API; admin archive/restore; snapshot-модели | handbooks.after-contact-information-update (ContactInfoType); справочники локалей/таймзон |
| Данные | реплики Country/Region/City/Currency/Language + IsArchived + SourceObservedAt; чекпоинты синка; миграция geo/ISO-полей потребителей на <X>Id + <X>Name | физическое удаление справочных записей; Guid-ключи |
| Эксплуатация | метрики лага/stale/DLQ синка; runbook полной пересинхронизации + reconciliation-отчёт | — |
Последствия
| Тип | Последствие | Компенсация |
|---|---|---|
| Архитектура | +1 bounded context (18 → 19); все geo/ISO-поля платформы мигрируют с ad-hoc строк на ссылки справочника | миграция батчами по сервисам; канонический паттерн <X>Id + <X>Name зафиксирован в правилах документирования |
| Данные | реплика вторична: PIN не может завести страну/город, которых нет в источнике | admin-операции ограничены archive/restore; недостающие записи заводятся в Talent Handbook |
| Данные | имя региона может устаревать до ближайшего city-события или пересинхронизации (п. 4) | регламентная полная пересинхронизация + reconciliation; метрика staleness реплики |
| Целостность | удалённая в источнике запись живёт в PIN до reconciliation (delete-gap, п. 5) | ссылки исторических данных не ломаются by design (архив, не удаление); reconciliation-отчёт с подтверждением администратора |
| Эксплуатация | новый синк: лаг, DLQ, конфликты ISO-кодов, IsoCode длиной ≠ 3 из источника | метрики/алерты по образцу identity-синка; replay безопасен (upsert-снапшот + guard монотонности по SourceObservedAt) |
| Совместимость | контракты потребителей меняются: Currency char(3) → CurrencyId short, RegionCode → RegionId и т.п. | greenfield: контракты правятся in-place, ContractVersion = 1, без v2-прослоек |
Затронутые Контракты И Совместимость
| Контракт | Версия | Совместимость | Миграция |
|---|---|---|---|
pin.handbook.countries.v1 / regions.v1 / cities.v1 / currencies.v1 / languages.v1 | 1 | новые compacted-топики, key = id записи | additive |
Консюмируемые handbooks.after-{country|city|currency|language}-information-update | 2 (upstream) | внешние контракты Talent (schema registry — upstream source of truth) | подтверждаются интеграционным контрактом при реализации |
Internal resolve-API / публичный lookup-API handbook | 1 | новые контракты | новый |
Geo/ISO-поля контрактов потребителей (chessboard, catalog, crm, profile, user-activity, денежные события) | — | breaking для моделей (строки → id + name/snapshot) | greenfield in-place; сводная миграция — по сервисным докам |
Альтернативы (рассмотрено и отклонено)
| Вариант | Описание | Причина отклонения |
|---|---|---|
| Прямая подписка каждого сервиса PIN на топики Talent | chessboard/crm/profile и др. сами консюмят handbooks.after-* | Связанность каждого сервиса с внешней шиной и контрактами Talent; нет ACL — смена источника/контракта бьёт по всем потребителям; N реализаций delete-gap/derive-региона вместо одной; нарушает принятое правило границ (downstream живёт только на pin.*) |
| Автономный справочник PIN со своим CRUD | PIN ведёт страны/города/валюты сам, без источника | Дублирование ведения данных двумя командами; расхождение с Talent-экосистемой (id и составы справочников перестают совпадать); admin-нагрузка на ручное пополнение городов без выгоды — источник уже ведёт эти данные |
| Guid-идентификаторы записей реплики | PIN генерирует свои Guid, храня id источника как внешний ключ | Ломает совместимость с источником: каждый апсерт требует lookup-таблицы соответствий, пересинхронизация и replay усложняются, события источника неприменимы напрямую; выгоды нет — id источника стабильны и компактны (short/long) |
Риски
| Риск | Компенсация |
|---|---|
| Источник поменяет контракты (ContractVersion 2 → 3) или имена топиков | ACL локализует изменение в одном консюмере handbook; контроль версии контракта с warn-метрикой |
| Тихое жёсткое удаление в источнике → «зомби-записи» в PIN | Регламентная пересинхронизация + reconciliation-отчёт; архив вместо удаления не ломает исторические ссылки |
| Устаревание имени региона (нет region-событий у источника) | Закрывается любым city-событием региона либо полной пересинхронизацией; допустимо для редко меняющихся geo-данных |
| Конфликт ISO-кодов при апсерте (уникальность среди неархивных) | Событие в DLQ + алерт; разрешение — ручное (данные правятся в источнике) |
Currency.IsoCode из источника длиной ≠ 3 (тип varchar(16)) | Reject/DLQ + warn-метрика; в реплику попадают только валидные ISO-4217 (char(3)) |
Переупорядочивание/дубли событий без per-record Version | Партиционирование по Id записи + guard монотонности по envelope CreatedAt; повторы идемпотентны (upsert полного snapshot-а) |
| Ошибочный архив записи администратором | Restore-операция + re-publish события; архивные записи остаются резолвимыми по id |
| Ползучее расширение scope («давайте и таймзоны, и локали в handbook») | Явная граница в п. 3: locale/timezone — строки; расширение только через новый ADR/additive-решение |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (13): Реплика справочников handbook в chessboard (chessboard.handbook_*), Реплика Talent Handbook: правила синка + Entity HandbookSyncState, DevStatsFilterModel (общий вход метрик dev-stats), Entity / Aggregate: NotificationEntity, Entity / Aggregate: NotificationTemplateEntity, Entity / Aggregate: UserNotificationSettingsEntity, Entity: CityEntity, Entity: CountryEntity, Entity: CurrencyEntity, Entity: LanguageEntity, Entity: RegionEntity, Enum-справочники контекста chessboard (+1)
- API (6): GET /api/v1/admin/handbook/sync-state — Состояние синхронизации справочников с Talent, GET /api/v1/dev-stats/activity-heatmap — Хитмап активности (час × день недели), GET /api/v1/handbook/{countries,regions,cities,currencies,languages} — Публичный lookup справочников, Handbook API — реестр операций областей public / internal / admin, POST /api/v1/admin/handbook/{type}/{id}/{archive,restore} — Архив и восстановление справочной записи, POST /internal/handbook/resolve — Batch-резолв справочных записей по id
- Use Cases (3): UC-HND-001. Администратор поддерживает справочник (archive/restore), UC-HND-002. Система синхронизирует реплику справочника из Talent, US-реестр контекста handbook (US-HND)
- События (5): Event Contract: city-upserted (CityUpsertedEvent), Event Contract: country-upserted (CountryUpsertedEvent), Event Contract: currency-upserted (CurrencyUpsertedEvent), Event Contract: language-upserted (LanguageUpsertedEvent), Event Contract: region-upserted (RegionUpsertedEvent)
- Консюмеры (2): Consumer: Handbook Sync (pin-chessboard-handbook-sync), Consumer: Talent Handbook Sync (pin-handbook-talent-sync)
- Фоновые задачи (1): Background Job: talent-initial-import (bootstrap и reconciliation справочников из Talent Handbook)
- index.md (1): Handbook Service
- entity-documentation-rules.md (1): Правила Документирования Сущностей
- strict-typed-documentation-rules.md (1): Правила Строгой Типизации Документации
Связанные документы
- Образец ACL-паттерна: identity/domain/talent-replica.md, identity/consumers/talent-identity-sync.md.
- Правило границ (downstream не подписывается на Talent): service-boundaries.md.
- Решения: ADR-0048 (RUB-only — не пересматривается), ADR-0056 (PostgreSQL, simplicity-first), ADR-0059 (поиск — на справочники не распространяется).
- Правила ссылок на справочники и snapshot-модели: strict-typed-documentation-rules.md, data-architecture.md.
- Детальное ТЗ контекста:
docs/06-services/handbook/(README, domain, consumers, events, api, background — создаются во исполнение этого ADR); реплика потребителя:docs/06-services/chessboard/domain/handbook-replica.md.