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

Entity / Aggregate: AuthorReplica + CompanyReplica

Дом полей read-реплик подписей автора и компании в контексте news. Модели ответов ленты/карточки поста и поисковые проекции ссылаются сюда и описывают только дельту, а не переописывают поля.

Назначение

Реплики — денормализованные read-проекции публичных подписей из контекста identity, дом которых для join-free рендера: лента и карточка поста отдают автора (ФИО/бейдж/аватар) и компанию-автора (название/лого) одной выборкой без синхронного запроса в чужой контекст (аналог паттерна catalog: денормализация identity/company в витрину — catalog-property-object-card.md).

Source of truth подписей — identity; здесь только реплика публичных display-полей. Реплики обновляются только консюмерами (consumers/identity-users.md, consumers/identity-companies.md); прямых пользовательских мутаций нет. Загрузка на рендере — батчем по множеству AuthorUserId/AuthorCompanyId ленты.

Классификация

ПолеAuthorReplicaCompanyReplica
Контекстnewsnews
KindReplica (read-модель, мутируется консюмерами; доменных инвариантов нет, только правила проекции)Replica
Source of truthidentity (UserEntity)identity (CompanyEntity)
Таблицаnews.author_replicasnews.company_replicas
Tenant isolationне tenanted — платформенные подписи (ADR-0052 п.3)не tenanted
Concurrencyоптимистическая блокировка — параллельные консюмеры пишут разные строки; свежесть источника — поле SourceVersion (монотонный guard, INV-REP-001)то же

Поля AuthorReplica

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKidentity (UserUpsertedKafkaEvent.UserId)= identity UserId; прямая адресация без lookup.
DisplayNamestringДаvarchar(384)identity (Name: FirstName + LastName)Публичная подпись автора; MiddleName в подпись не входит.
TypePostAuthorTypeДаenum (хранится как text), varchar(16)identity (UserType, маппинг)Классификация автора; enum — реплика UserType, дом значений post.md; маппинг ниже.
CompanyIdGuid?Нетindex ix_author_replicas_company; логическая ссылка на news.company_replicas.id (без FK — порядок событий user/company не гарантирован; INV-REP-004)identity (CompanyId)Родительская компания менеджера/агента; null — инвестор (ADR-0052).
AvatarFileIdGuid?Нетбез FK (внешний storage/CDN)identity (если появится; иначе null)Аватар автора карточки поста.
StatusAuthorReplicaStatusДаenum (хранится как text), varchar(32), index ix_author_replicas_statusidentity (UserStatus, маппинг)Active/Blocked/Archived; см. INV-REP-002.
SourceVersionlongДа>= 1; монотонный guardidentity (payload.Version)Доменная версия события identity (INV-REP-001).
UpdatedAtDateTimeOffsetДаappПоследняя запись строки.
SyncedAtDateTimeOffsetДаconsumer (время события)Время исходного события последнего применённого upsert (лаг синка).

Поля CompanyReplica

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKidentity (CompanyUpsertedKafkaEvent.CompanyId)= identity CompanyId; прямая адресация без lookup.
NamestringДаvarchar(256)identity (Name)Название компании-автора.
TypeCompanyTypeДаenum (хранится как text), varchar(32)identity (Type)Реплика 1:1 (additive); дом значений — identity/domain/company.md.
LogoFileIdGuid?Нетбез FK (внешний storage/CDN)identity (LogoFileId)Лого для карточки поста.
StatusCompanyReplicaStatusДаenum (хранится как text), varchar(32), index ix_company_replicas_statusidentity (CompanyStatus, маппинг)Active/Suspended; см. INV-REP-003.
SourceVersionlongДа>= 1; монотонный guardidentity (payload.Version)Доменная версия события identity (INV-REP-001).
UpdatedAtDateTimeOffsetДаappПоследняя запись строки.
SyncedAtDateTimeOffsetДаconsumer (время события)Время исходного события последнего upsert.

Перечисления (enums) и маппинг из identity

PostAuthorType — реплика значений UserType

AuthorReplica.Type типизирован PostAuthorType. Дом enum и полный список значений (Investor, DeveloperManager, Agent, AgencyManager, PinEmployee) — post.md (поле AuthorType, snapshot-семантика). Здесь — только правило синка из UserType (дом — identity/domain/user.md):

  • 1:1 по совпадающему имени значения (Investor / DeveloperManager / Agent / PinEmployee);
  • значение UserType без прямого соответствия в PostAuthorType (либо новое/unknown) → ближайшее существующее + warning (метрика news_replica_unknown_enum_total); событие не теряется (additive-правило источника user-upserted.md).

AuthorReplicaStatus (news) — маппинг из UserStatus

ЗначениеUserStatus-источникЭффект
ActiveInvited, ActiveПодпись обычная.
BlockedBlockedINV-REP-002: лента помечает/скрывает посты автора.
ArchivedArchivedУчётка архивирована (soft-delete источника).

Unknown UserStatusBlocked (fail-closed — правило источника user-upserted.md).

CompanyReplicaStatus (news) — маппинг из CompanyStatus

ЗначениеCompanyStatus-источникЭффект
ActivePending, ActiveПосты компании в публичных лентах.
SuspendedSuspended, ArchivedINV-REP-003: посты компании исключаются из публичных лент.

Unknown CompanyStatusSuspended (fail-closed — правило источника company-upserted.md).

Value objects

Value objects не используются: DisplayName — плоская колонка FirstName + ' ' + LastName (обрезка до 384 + warning), вычисляется консюмером из FullNameSnapshot; парных/структурных VO у реплик нет.

Модели JSONB

JSONB-модели не используются.

Связи

Все связи — логические, без FK (чужой контекст источника + порядок событий не гарантирован); загрузка подписей — батч-загрузка батчем при рендере, без джойна в контекст identity.

СвязьТипКлючПравило загрузкиDelete behavior
PostAuthorReplicareferencing (обратная)post.author_user_id → author_replicas.idбатч-загрузка на рендере ленты/карточкинет каскада: посты по id переживают удаление реплики
CommentAuthorReplicareferencingcomment.author_user_id → author_replicas.idбатч-загрузкато же
AuthorSubscriptionAuthorReplicareferencingauthor_subscription.target_user_id → author_replicas.idбатч-загрузкато же
PostCompanyReplicareferencingpost.author_company_id → company_replicas.idбатч-загрузкато же
AuthorSubscriptionCompanyReplicareferencingauthor_subscription.target_company_id → company_replicas.idбатч-загрузкато же
AuthorReplicaCompanyReplicareference (0..1)author_replicas.company_id → company_replicas.idбатч-загрузканет каскада
identity UserEntityreplica-источникIdconsumer identity-users.mdArchived/DeletedStatus = Archived
identity CompanyEntityreplica-источникIdconsumer identity-companies.mdSuspended/ArchivedStatus = Suspended

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-REP-001 MonotonicСобытие применяется только при event.Version > SourceVersion; иначе skip + метрика news_replica_stale_totalconsumer, до мутациинет — событие идемпотентно пропускается
INV-REP-002 BlockedEffectAuthorReplica.Status = Blocked → лента может пометить/скрыть посты автора (решение выдачи API; по умолчанию посты остаются, подпись помечается)query-слой выдачи ленты/постанет — правило выдачи
INV-REP-003 SuspendedEffectCompanyReplica.Status = Suspended → посты компании (Post.AuthorCompanyId) исключаются из публичных лент (фильтр выдачи); контент не удаляетсяquery-слой публичных лентнет — фильтр выдачи
INV-REP-004 UpsertIdempotentUpsert по Id идемпотентен (business key = UserId/CompanyId); повтор доставки не меняет состояниеconsumer (идемпотентность по MessageId + INV-REP-001)нет

Переходы — по монотонному guard-у INV-REP-001 (событие с меньшей Version игнорируется).

Доменные и интеграционные события

Реплики — конечная точка данных read-модели: собственных доменных и интеграционных событий не публикуют (ADR-0056 — не плодить события без потребителей). Read-модель наполняется консюмерами из топиков identity:

ТопикСобытиеConsumer newsЦелевая реплика
pin.identity.users.v1UserUpsertedKafkaEvent / UserBlockedKafkaEvent (ContractVersion = 1)identity-users.mdAuthorReplica
pin.identity.companies.v1CompanyUpsertedKafkaEvent / CompanySuspendedKafkaEvent (ContractVersion = 1)identity-companies.mdCompanyReplica

Мутация реплики и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации.

Согласованность и что видит пользователь

  • Eventual, монотонно. Реплика отражает последнее применённое событие identity (лаг консюмера < 1 мин SLO). Монотонный guard INV-REP-001 (event.Version > SourceVersion) исключает откат к устаревшему снапшоту при переупорядочении/replay: применяется только более свежая версия, остальные — no-op.
  • Порядок user/company не гарантирован. Связь AuthorReplica.CompanyId → CompanyReplica.Id — логическая, без FK: событие пользователя может прийти раньше события его компании. Рендер подписи устойчив к отсутствию CompanyReplica (компанийная часть подписи подтягивается, когда реплика компании появится) — это ожидаемое промежуточное состояние, не ошибка (INV-REP-004).
  • Эффекты статусов — на выдаче, не в консюмере. Blocked (INV-REP-002) и Suspended (INV-REP-003) меняют только поле Status реплики; фактическое скрытие/пометка постов — решение query-слоя ленты в момент выборки. Поэтому в окне лага между блокировкой в identity и обработкой события лента ещё может показывать посты автора обычной подписью.
  • first-seen. Появление автора/компании в ленте до синка реплики теоретически возможно (пост опубликован, реплика ещё не пришла) — рендер деградирует к нейтральной подписи по id, не падает.

Acceptance Criteria

  • Первый синк. Дано реплики автора нет; когда обработан UserUpsertedKafkaEvent; тогда появляется AuthorReplica с DisplayName = FirstName + ' ' + LastName, Type, Status = Active (для Invited/Active), SourceVersion = event.Version.
  • Монотонность. Дано SourceVersion = 5; когда приходит событие с Version = 4; тогда реплика не меняется + news_replica_stale_total (INV-REP-001).
  • Блокировка автора. Дано AuthorReplica.Status = Active; когда обработан UserBlockedKafkaEvent; тогда Status = Blocked; выдача ленты по INV-REP-002 помечает/скрывает посты автора (правило query-слоя).
  • Приостановка компании. Дано CompanyReplica; когда источник переходит в Suspended/Archived; тогда Status = Suspended и посты компании (Post.AuthorCompanyId) исключаются из публичных лент (INV-REP-003), контент не удаляется.
  • Unknown enum. Дано неизвестное/несоответствующее UserType; когда синк; тогда Type = ближайшее значение + warning + метрика; UserStatus/CompanyStatus unknown → Blocked/Suspended (fail-closed).
  • Удаление источника без каскада. Дано учётка/компания удалены в identity; когда синк; тогда Status = Archived/Suspended, строка реплики физически сохраняется (ссылочная целостность подписей постов/комментариев/подписок по id).
  • PII не реплицируется. Дано событие несёт PhoneNumber/Email; когда синк; тогда эти поля не попадают в реплику и в логи (минимизация PII).

Индексы, хранение, безопасность

АспектЗначение
ИндексыAuthorReplica: PK pk_author_replicas (id); btree ix_author_replicas_company (company_id), ix_author_replicas_status (status). CompanyReplica: PK pk_company_replicas (id); btree ix_company_replicas_status (status)
Партиционирование / retentionnone — десятки–сотни тысяч строк максимум (simplicity-first, ADR-0056); удаление источника → Status = Archived/Suspended, строки физически остаются (ссылочная целостность подписей по id)
Concurrencyоптимистическая блокировка — параллельные консюмеры user/company пишут разные строки; SourceVersion — монотонный бизнес-guard (INV-REP-001)
Permissions / PIIDisplayName/Name/Type/AvatarFileId/LogoFileId — публичные подписи (identity уже публикует их доверенным consumer-ам через ACL топика — user-upserted.md/company-upserted.md). PhoneNumber/Email НЕ реплицируются — для подписи не нужны (минимизация PII). Чтение — через API ленты/поста news
AuditОтдельной audit-сущности нет (read-проекция); происхождение значений трассируется SourceVersion + SyncedAt

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

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

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