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 ленты.
Классификация
| Поле | AuthorReplica | CompanyReplica |
|---|---|---|
| Контекст | news | news |
| Kind | Replica (read-модель, мутируется консюмерами; доменных инвариантов нет, только правила проекции) | Replica |
| Source of truth | identity (UserEntity) | identity (CompanyEntity) |
| Таблица | news.author_replicas | news.company_replicas |
| Tenant isolation | не tenanted — платформенные подписи (ADR-0052 п.3) | не tenanted |
| Concurrency | оптимистическая блокировка — параллельные консюмеры пишут разные строки; свежесть источника — поле SourceVersion (монотонный guard, INV-REP-001) | то же |
Поля AuthorReplica
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | identity (UserUpsertedKafkaEvent.UserId) | = identity UserId; прямая адресация без lookup. |
DisplayName | string | Да | varchar(384) | identity (Name: FirstName + LastName) | Публичная подпись автора; MiddleName в подпись не входит. |
Type | PostAuthorType | Да | enum (хранится как text), varchar(16) | identity (UserType, маппинг) | Классификация автора; enum — реплика UserType, дом значений post.md; маппинг ниже. |
CompanyId | Guid? | Нет | index ix_author_replicas_company; логическая ссылка на news.company_replicas.id (без FK — порядок событий user/company не гарантирован; INV-REP-004) | identity (CompanyId) | Родительская компания менеджера/агента; null — инвестор (ADR-0052). |
AvatarFileId | Guid? | Нет | без FK (внешний storage/CDN) | identity (если появится; иначе null) | Аватар автора карточки поста. |
Status | AuthorReplicaStatus | Да | enum (хранится как text), varchar(32), index ix_author_replicas_status | identity (UserStatus, маппинг) | Active/Blocked/Archived; см. INV-REP-002. |
SourceVersion | long | Да | >= 1; монотонный guard | identity (payload.Version) | Доменная версия события identity (INV-REP-001). |
UpdatedAt | DateTimeOffset | Да | — | app | Последняя запись строки. |
SyncedAt | DateTimeOffset | Да | — | consumer (время события) | Время исходного события последнего применённого upsert (лаг синка). |
Поля CompanyReplica
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | identity (CompanyUpsertedKafkaEvent.CompanyId) | = identity CompanyId; прямая адресация без lookup. |
Name | string | Да | varchar(256) | identity (Name) | Название компании-автора. |
Type | CompanyType | Да | enum (хранится как text), varchar(32) | identity (Type) | Реплика 1:1 (additive); дом значений — identity/domain/company.md. |
LogoFileId | Guid? | Нет | без FK (внешний storage/CDN) | identity (LogoFileId) | Лого для карточки поста. |
Status | CompanyReplicaStatus | Да | enum (хранится как text), varchar(32), index ix_company_replicas_status | identity (CompanyStatus, маппинг) | Active/Suspended; см. INV-REP-003. |
SourceVersion | long | Да | >= 1; монотонный guard | identity (payload.Version) | Доменная версия события identity (INV-REP-001). |
UpdatedAt | DateTimeOffset | Да | — | app | Последняя запись строки. |
SyncedAt | DateTimeOffset | Да | — | 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-источник | Эффект |
|---|---|---|
Active | Invited, Active | Подпись обычная. |
Blocked | Blocked | INV-REP-002: лента помечает/скрывает посты автора. |
Archived | Archived | Учётка архивирована (soft-delete источника). |
Unknown UserStatus → Blocked (fail-closed — правило источника user-upserted.md).
CompanyReplicaStatus (news) — маппинг из CompanyStatus
| Значение | CompanyStatus-источник | Эффект |
|---|---|---|
Active | Pending, Active | Посты компании в публичных лентах. |
Suspended | Suspended, Archived | INV-REP-003: посты компании исключаются из публичных лент. |
Unknown CompanyStatus → Suspended (fail-closed — правило источника company-upserted.md).
Value objects
Value objects не используются: DisplayName — плоская колонка FirstName + ' ' + LastName (обрезка до
384 + warning), вычисляется консюмером из FullNameSnapshot; парных/структурных VO у реплик нет.
Модели JSONB
JSONB-модели не используются.
Связи
Все связи — логические, без FK (чужой контекст источника + порядок событий не гарантирован);
загрузка подписей — батч-загрузка батчем при рендере, без джойна в контекст identity.
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
Post → AuthorReplica | referencing (обратная) | post.author_user_id → author_replicas.id | батч-загрузка на рендере ленты/карточки | нет каскада: посты по id переживают удаление реплики |
Comment → AuthorReplica | referencing | comment.author_user_id → author_replicas.id | батч-загрузка | то же |
AuthorSubscription → AuthorReplica | referencing | author_subscription.target_user_id → author_replicas.id | батч-загрузка | то же |
Post → CompanyReplica | referencing | post.author_company_id → company_replicas.id | батч-загрузка | то же |
AuthorSubscription → CompanyReplica | referencing | author_subscription.target_company_id → company_replicas.id | батч-загрузка | то же |
AuthorReplica → CompanyReplica | reference (0..1) | author_replicas.company_id → company_replicas.id | батч-загрузка | нет каскада |
identity UserEntity | replica-источник | Id | consumer identity-users.md | Archived/Deleted → Status = Archived |
identity CompanyEntity | replica-источник | Id | consumer identity-companies.md | Suspended/Archived → Status = Suspended |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-REP-001 Monotonic | Событие применяется только при event.Version > SourceVersion; иначе skip + метрика news_replica_stale_total | consumer, до мутации | нет — событие идемпотентно пропускается |
| INV-REP-002 BlockedEffect | AuthorReplica.Status = Blocked → лента может пометить/скрыть посты автора (решение выдачи API; по умолчанию посты остаются, подпись помечается) | query-слой выдачи ленты/поста | нет — правило выдачи |
| INV-REP-003 SuspendedEffect | CompanyReplica.Status = Suspended → посты компании (Post.AuthorCompanyId) исключаются из публичных лент (фильтр выдачи); контент не удаляется | query-слой публичных лент | нет — фильтр выдачи |
| INV-REP-004 UpsertIdempotent | Upsert по 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.v1 | UserUpsertedKafkaEvent / UserBlockedKafkaEvent (ContractVersion = 1) | identity-users.md | AuthorReplica |
pin.identity.companies.v1 | CompanyUpsertedKafkaEvent / CompanySuspendedKafkaEvent (ContractVersion = 1) | identity-companies.md | CompanyReplica |
Мутация реплики и отметка идемпотентности фиксируются в одном согласованном изменении; 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/CompanyStatusunknown →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) |
| Партиционирование / retention | none — десятки–сотни тысяч строк максимум (simplicity-first, ADR-0056); удаление источника → Status = Archived/Suspended, строки физически остаются (ссылочная целостность подписей по id) |
| Concurrency | оптимистическая блокировка — параллельные консюмеры user/company пишут разные строки; SourceVersion — монотонный бизнес-guard (INV-REP-001) |
| Permissions / PII | DisplayName/Name/Type/AvatarFileId/LogoFileId — публичные подписи (identity уже публикует их доверенным consumer-ам через ACL топика — user-upserted.md/company-upserted.md). PhoneNumber/Email НЕ реплицируются — для подписи не нужны (минимизация PII). Чтение — через API ленты/поста news |
| Audit | Отдельной audit-сущности нет (read-проекция); происхождение значений трассируется SourceVersion + SyncedAt |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: Post, Entity: AuthorSubscription (лента subscription)
- API (10): DELETE /api/v1/subscriptions/{id} — Отписаться от источника, GET /api/v1/feed/subscriptions — Лента по подпискам, GET /api/v1/posts — Глобальная лента + поиск, GET /api/v1/posts/{id}/comments — Дерево комментариев поста, GET /api/v1/posts/{id}/versions — История версий поста (snapshot), GET /api/v1/posts/{id}/versions/{versionNumber} — Конкретный snapshot версии поста, GET /api/v1/posts/{idOrSuffix} — Чтение опубликованного поста, GET /api/v1/subscriptions — Мои подписки, POST /api/v1/posts — Создать черновик поста, POST /api/v1/subscriptions — Подписаться на автора/компанию
- Use Cases (4): UC-NWS-003. Вложенные переключаемые комментарии, UC-NWS-004. Подписки и лента subscription, UC-NWS-005. Поиск постов (Elasticsearch), UC-NWS-006. История версий поста (полный snapshot)
- События (2): События сервиса news: карта топиков и отклонённые события/консюмеры, Event Contract: pin.news.post-published (PostPublishedKafkaEvent)
- Консюмеры (2): Consumer: identity.companies.v1 → CompanyReplica, Consumer: identity.users.v1 → AuthorReplica (news-identity-users)
- Поиск (1): PostSearchDocument
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- Контракты источника:
identity/events/user-upserted.md,identity/events/company-upserted.md; дома enum источника —identity/domain/user.md(UserType/UserStatus),identity/domain/company.md(CompanyType/CompanyStatus). - Консюмеры, наполняющие реплики: consumers/identity-users.md, consumers/identity-companies.md.
- Потребитель полей: post.md (
AuthorUserId/AuthorCompanyId,PostAuthorType). - Паттерн-аналог денормализации identity/company: catalog-property-object-card.md.
- ADR-0052 (платформенные данные не tenanted), ADR-0056 (simplicity-first).