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

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). Это остаётся в силе.

Решение

  1. Новый bounded context promotion — source of truth продвижения. Namespace Pin.Promotion.*, схема БД promotion, топик pin.promotion.*. Контекст catalog теряет владение продвижением.
  2. Модель контекста:
    • 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.
  3. Полиморфный таргет (замена 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.
  4. Событие буста pin.promotion.purchase-changed.v1 (PromotionPurchaseChangedKafkaEvent) — переносится/переименовывается из pin.catalog.promotions.v1. Публикуется через outbox на каждый переход статуса; payload содержит targets[] { TargetKind, TargetId }, BoostWeight, EndsAt, Status, PaymentStatus, снапшот Price, Version. Ключ = PurchaseId.
  5. 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 активных покупок, затрагивающих цель; истечение/отмена снимают вклад.
  6. Оплата отложена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; TargetProjectIdPromotionTarget{Project}Проект на этапе ТЗ (кода ещё нет) — миграция чисто документная/DDL, без данных
КонтрактыТопик pin.catalog.promotions.v1pin.promotion.purchase-changed.v1; payload расширен targets[] + TargetKindСобытие в статусе draft (нет прод-потребителей) — переезд без dual-publish
Данныеnews.posts получает PromotionBoost (новое поле) + консюмерДенормализация как у catalog; пересчёт идемпотентен
Эксплуатацияexpire-promotions (polling, ADR-0056 п.3) переезжает в promotionМеханика без изменений

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

КонтрактВерсияСовместимостьМиграция
pin.catalog.promotions.v1pin.promotion.purchase-changed.v1v1breaking (топик + targets[]/TargetKind)статус draft, потребителей в проде нет — переезд без периода dual-publish
API промо (developers purchase/cancel/list, admin CRUD пакетов)путь/area переезжают в promotionклиенты генерируются из swagger (кода ещё нет)
Post.PromotionBoostadditive (новое поле)заполняется консюмером, 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.

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

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

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