ADR-0060: Продвижение — отдельный bounded context promotion (полиморфный таргет); catalog и news — консюмеры буста
Статус
Accepted
Дата
2026-07-14
Назначение
Зафиксировать выделение продвижения (промо-пакеты и их покупки) в самостоятельный bounded context
promotion с полиморфным таргетом и вынос его из catalog. Контексты catalog и news перестают
владеть продвижением и становятся консюмерами события буста.
Контекст
- Сейчас продвижение живёт внутри
catalog: агрегатыPromotionPackage/PromotionPurchase/PromotionPurchaseLot, API промо (developers + admin), событиеpromotion-purchase-changedи денормализацияPromotionBoostв карточки. Дом —promotion-package.md. - Продвижение — сквозная бизнес-возможность: буст нужен и карточкам каталога (проект/объект), и
ленте новостей (пост). По ADR-0018 (Service Boundary Decision Matrix) владелец возможности не должен
совпадать с одним из её потребителей — иначе
newsвынужден зависеть отcatalogради промо-постов. - Требуется расширить таргет: продвигать не только проект и его объекты, но и посты (news).
Модель
catalog(TargetProjectId+PromotionPurchaseLot) жёстко привязана к каталогу и не описывает пост как цель. - Покупатель промо — не только застройщик: агентство/брокер тоже продвигают контент.
TenantIdпокупки нужно обобщить сDeveloperIdдоCompanyId(ADR-0052). - Монетизация: структуры цены проектируются, оплата отложена (technical-assignment.md, решение №10; simplicity-first — ADR-0056). Это остаётся в силе.
Решение
- Новый bounded context
promotion— source of truth продвижения. NamespacePin.Promotion.*, схема БДpromotion, топикpin.promotion.*. Контекстcatalogтеряет владение продвижением. - Модель контекста:
PromotionPackage— платформенный каталог пакетов (управляет admin-система, m2m ADR-0053); не tenanted. Вид пакета определяет допустимыйTargetKind, вес буста, длительность, будущую цену (снапшот в покупку). Переносится изcatalogбез смысловых изменений.PromotionPurchase— покупка пакета компанией.AggregateRoot<Guid>,ITenantedEntity.TenantId = CompanyId(ADR-0052; обобщениеDeveloperId → CompanyId). Хранит снапшотыBoostWeight/Price/PackageKind(INV снапшота — правки пакета не трогают покупку),Status(Pending/Active/Expired/Cancelled),PaymentStatus,StartsAt/EndsAt.
- Полиморфный таргет (замена
TargetProjectId+PromotionPurchaseLot):- VO
PromotionTarget(owned) ={ TargetKind, TargetId }, гдеPromotionTargetKind ∈ { Project | PropertyObject | Post }(.HasConversion<string>()),TargetId— логическая ссылка (без FK: цель в чужом контексте). - child-entity
PromotionPurchaseTarget— коллекция агрегатаPromotionPurchase(1..N целей одной покупки), несётPromotionTarget. ЗаменяетPromotionPurchaseLot. Набор допустимыхTargetKindв покупке ограниченPackageKind.
- VO
- Событие буста
pin.promotion.purchase-changed.v1(PromotionPurchaseChangedKafkaEvent) — переносится/переименовывается изpin.catalog.promotions.v1. Публикуется через outbox на каждый переход статуса; payload содержитtargets[] { TargetKind, TargetId },BoostWeight,EndsAt,Status,PaymentStatus, снапшотPrice,Version. Ключ =PurchaseId. catalogиnews— консюмеры буста (PromotionBoostденормализуется у потребителя, а не в промо-контексте):catalog: дляTargetKind ∈ { Project, PropertyObject }пересчитываетPromotionBoostкарточек проекта/объекта (catalog-project-card / catalog-property-object-card) — первый ключ сортировки «Рекомендованные».news: дляTargetKind = Postнаполняет новое денормализованное полеPost.PromotionBoost(post.md) — вклад в post-ranking-model.- Консюмер идемпотентен (
MessageId+Version-guard), пересчёт — суммаBoostWeightактивных покупок, затрагивающих цель; истечение/отмена снимают вклад.
- Оплата отложена —
PaymentStatus = NotRequired, поля цены снапшотятся для будущего эквайринга (billing-консюмерpin.promotion.purchase-changed.v1). Без изменения решения №10 / ADR-0056.
Область действия
| Область | Входит | Не входит |
|---|---|---|
| Сервисы | новый promotion (source of truth); catalog, news — консюмеры | сам расчёт ранжирования (остаётся у catalog/news) |
| Контракты | pin.promotion.purchase-changed.v1; API промо (developers + admin) — в promotion | остальные события catalog/news |
| Данные | promotion.promotion_packages / promotion_purchases / promotion_purchase_targets; Post.PromotionBoost; PromotionBoost карточек catalog (наполняется консюмером) | прочие проекции catalog/news |
| Эксплуатация | dashboard/outbox promotion; job expire-promotions (перенос) | — |
Последствия
| Тип | Последствие | Компенсация |
|---|---|---|
| Архитектура | Контекстов становится 18 (было 17, service-boundaries.md). catalog перестаёт владеть продвижением → консюмер | Чёткая граница по ADR-0018; news больше не зависит от catalog ради промо |
| Данные | Таблицы catalog.promotion_* мигрируют в схему promotion; PromotionPurchaseLot → полиморфный PromotionPurchaseTarget; TargetProjectId → PromotionTarget{Project} | Проект на этапе ТЗ (кода ещё нет) — миграция чисто документная/DDL, без данных |
| Контракты | Топик pin.catalog.promotions.v1 → pin.promotion.purchase-changed.v1; payload расширен targets[] + TargetKind | Событие в статусе draft (нет прод-потребителей) — переезд без dual-publish |
| Данные | news.posts получает PromotionBoost (новое поле) + консюмер | Денормализация как у catalog; пересчёт идемпотентен |
| Эксплуатация | expire-promotions (polling, ADR-0056 п.3) переезжает в promotion | Механика без изменений |
Затронутые контракты и совместимость
| Контракт | Версия | Совместимость | Миграция |
|---|---|---|---|
pin.catalog.promotions.v1 → pin.promotion.purchase-changed.v1 | v1 | breaking (топик + targets[]/TargetKind) | статус draft, потребителей в проде нет — переезд без периода dual-publish |
API промо (developers purchase/cancel/list, admin CRUD пакетов) | — | путь/area переезжают в promotion | клиенты генерируются из swagger (кода ещё нет) |
Post.PromotionBoost | — | additive (новое поле) | заполняется консюмером, default 0 |
Изменения в документации
- Перенести в
docs/06-services/promotion/:catalog/domain/promotion-package.md,catalog/events/promotion-purchase-changed.md,catalog/api/{developers,admin}промо-методы,catalog/background/expire-promotions.md; обновитьPromotionKind/таргет на полиморфную модель иTenantId = CompanyId. catalog:promotion-purchase-changedпереоформить как consumer; вcatalog-project-card.md/catalog-property-object-card.mdуточнить, чтоPromotionBoostнаполняется консюмеромpromotion.news: вpost.mdдобавить полеPromotionBoost+ консюмер; учесть вклад вpost-ranking-model.md.service-boundaries.md: 17 → 18 контекстов, добавитьpromotion(владелец продвижения; catalog/news — консюмеры).- ADR index
README.md: строка ADR-0060.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (5): Entity / Aggregate: NotificationEntity, Entity / Aggregate: Post, Entity / Aggregate: PostReaction + проекция PostRankingSignals (Design), Entity / Aggregate: PromotionPackage, Entity / Aggregate: PromotionPurchase
- События (1): Event Contract: pin.promotion.purchase-changed (PromotionPurchaseChangedKafkaEvent)
- Консюмеры (3): Consumer: Promotion Boost (pin-catalog-promotion-boost), Consumer: Promotion Boost (pin-news-promotion-boost), Consumer: Promotion Events → Notifications (notifications-promotion-events)
- Фоновые задачи (1): Background Job: ExpirePromotionsJob
- scenarios (1): UC-OVR-009. Продвижение поста / объекта недвижимости
- index.md (3): News Service (Pin.News), Notifications Service, Promotion Service (Pin.Promotion)
Связанные документы
- Текущий дом (до переноса):
promotion-package.md,promotion-purchase-changed.md. - Потребители буста:
catalog-project-card.md,catalog-property-object-card.md,post.md,post-ranking-model.md. - ADR: ADR-0018 (границы возможностей),
ADR-0052 (
TenantId = CompanyId), ADR-0053 (admin m2m), ADR-0056 (simplicity-first, polling, оплата отложена). - ТЗ: technical-assignment.md, решение №10 (монетизация — модели есть, оплата отложена); ТЗ.md:195.