Перейти к основному содержимому

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 (Id short, ShortName?, FullName, IsoCode2, IsoCode3, DefaultCurrencyId?, DefaultLanguageId?), CountryRegion (Id long, CountryId, Name), City (Id long, CountryId, RegionId?, Name), Currency (Id short, Name, IsoCode, Sign?), Language (Id short, 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-ов определяется временем публикации (envelope CreatedAt) и порядком партиции 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 — envelope CreatedAt последнего применённого события источника; собственной per-record версии реплика не вводит, монотонность — по SourceObservedAt).
  • Downstream-сервисы PIN никогда не подписываются на топики Talent напрямую — только на pin.handbook.*. Контракты Talent (имена топиков, версии, состав payload) остаются деталью реализации одного консюмера handbook; смена источника или его контрактов локализуется в одном месте.
  • Синк подчиняется тем же правилам, что и identity-реплика: идемпотентность по событию, snapshot-семантика (перезапись всех синк-владеемых полей), атомарность «реплика + чекпоинт синка + re-publish», bootstrap через пересинхронизацию источника. Отличие от identity-реплики — источник монотонности: у событий справочников нет per-record Version, поэтому порядок применения задаётся не version-guard-ом, а envelope CreatedAt и порядком партиции Kafka (ключ = Id записи гарантирует, что апдейты одной записи не переупорядочиваются). Guard монотонности реплики — «не откатывать назад по CreatedAt источника»; повторная доставка идемпотентна (upsert полного snapshot-а).

2. Id-семантика: идентификаторы источника, без Guid

  • Реплика сохраняет идентификаторы источника как доменные ключи PIN: CountryId/CurrencyId/LanguageIdshort; RegionId/CityIdlong.
  • Guid-идентификаторы для справочных записей не вводятся: прямая адресация без lookup-таблиц соответствий, события источника применимы напрямую, полная пересинхронизация не порождает конфликтов идентичности.
  • ISO-коды (IsoCode2/IsoCode3 стран и языков, IsoCode ISO-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-handbooks re-эмитит все строки источника; строки реплики, не пришедшие в окно пересинхронизации, — кандидаты в архив и попадают в 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-activityid + 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, RegionCodeRegionId и т.п.greenfield: контракты правятся in-place, ContractVersion = 1, без v2-прослоек

Затронутые Контракты И Совместимость

КонтрактВерсияСовместимостьМиграция
pin.handbook.countries.v1 / regions.v1 / cities.v1 / currencies.v1 / languages.v11новые compacted-топики, key = id записиadditive
Консюмируемые handbooks.after-{country|city|currency|language}-information-update2 (upstream)внешние контракты Talent (schema registry — upstream source of truth)подтверждаются интеграционным контрактом при реализации
Internal resolve-API / публичный lookup-API handbook1новые контрактыновый
Geo/ISO-поля контрактов потребителей (chessboard, catalog, crm, profile, user-activity, денежные события)breaking для моделей (строки → id + name/snapshot)greenfield in-place; сводная миграция — по сервисным докам

Альтернативы (рассмотрено и отклонено)

ВариантОписаниеПричина отклонения
Прямая подписка каждого сервиса PIN на топики Talentchessboard/crm/profile и др. сами консюмят handbooks.after-*Связанность каждого сервиса с внешней шиной и контрактами Talent; нет ACL — смена источника/контракта бьёт по всем потребителям; N реализаций delete-gap/derive-региона вместо одной; нарушает принятое правило границ (downstream живёт только на pin.*)
Автономный справочник PIN со своим CRUDPIN ведёт страны/города/валюты сам, без источникаДублирование ведения данных двумя командами; расхождение с 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-решение

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы