Эта спека — контрпример к Order Service. Order — Уровень 3: домен, агрегаты, события, процессы. Notification — Уровень 1: слоёная архитектура без UseCase Pattern и DDD. И это правильно: задача сервиса — взять входящее событие, отрендерить шаблон, отправить во внешний канал, записать результат. Бизнес-инвариантов, которые надо защищать агрегатом, тут нет.

Цель — показать, как выглядит формат, когда агрегатов ноль: вся спека — один файл (папки aggregates/ нет), контекст- и «модульные» секции идут единой сквозной нумерацией, а §Доменные события (публикация) и §Процессы помечены «не применимо». Машинно-читаемая копия — в docs/spec/notification-service-spec.md репозитория, из неё скиллы методологии генерируют код.


1. Bounded Context

Контекст: Notification. На Уровне 1 это не отдельная предметная область, а модуль — техническая прокладка между Kafka и провайдерами доставки; термин «Bounded Context» здесь условен. Субдомен: Generic (доставка уведомлений — не конкурентное преимущество). Владелец: команда «Платформа». Агрегатов нет (anemic, CRUD-сервис) — спека в одном файле.

Внутри границы: подписка на доменные события Order; выбор каналов по типу события; рендер шаблона и отправка через SMTP + FCM; журнал попыток; ретраи при временных ошибках; приём webhook'ов о доставке; админ-журнал и ручной retry.

Вне границы: бизнес-логика маркетплейса (правила «когда слать» — в источнике события); настройки подписок и опт-ауты (расширение Уровня 2); кампании/массовые рассылки (отдельный сервис — Notification только транзакционные); токены устройств и сертификаты (берёт у Customer BFF); тело письма после отправки (только метаданные).


2. Интеграции (Context Map)

diagram
РеброНаправлениеКаналСвязьПередаётся
Order Serviceinboundasyncconformistсобытия marketplace.orders.v1 (создать уведомления)
Customer BFFoutboundsyncconformistGET /users/{id}/contact — email, push-токены, locale
SMTP (Mailgun)bidirectionalsync + webhook— (внешний провайдер)отправка письма; webhook delivered/bounced
FCM (Firebase)outboundsync— (внешний провайдер)push (fire-and-forget, без webhook)
Admin UIinboundsynccustomer-supplierжурнал, ручной retry

Notification — Conformist к контракту Order и Customer BFF: подстраивается под чужой контракт, не диктует. Anti-corruption layer на Уровне 1 избыточен — закладываемся на стабильность контракта (semver).

Контракты

КонтрактФорматФайлВладелец
События Order (consume)AsyncAPIконтракт контекста Order (marketplace.orders.v1)Order
Customer BFF API (consume)OpenAPIконтракт контекста Customer BFFCustomer BFF
Notification REST (admin + webhook)OpenAPIcontracts/notification-api.openapi.yamlNotification
Mailgun / FCMвнешниедокументация провайдероввнешние

3. Ubiquitous Language

ТерминВ кодеОпределение
УведомлениеNotificationЗапись журнала: одно событие, одна попытка в один канал одному адресату. Письмо + push на одно событие = две записи.
КаналChannelEMAIL, PUSH (на запуске); SMS — расширение (добавится провайдер).
АдресатRecipientuserId + материализованный контакт на момент отправки (адрес мог поменяться — в журнале правда).
ШаблонTemplateСообщение в БД: ключ (order.confirmed) + язык + субъект и тело с плейсхолдерами ${var}.
Исходное событиеSourceEventВходящее Kafka-событие, по которому создано уведомление (JSON хранится для дебага/retry).
Статус доставкиDeliveryStatusQUEUED, SENT, DELIVERED, BOUNCED, FAILED (см. §5).

Намеренно нет агрегатов, value objects, доменных событий — Notification это строка в таблице, а не агрегат.


4. Доменная модель

Уровень 1 — модель сводится к таблицам (агрегатов и VO нет; это проектное решение, не упущение). Контроллер/сервис принимают JSON-DTO напрямую, без UseCase-входов.

ТаблицаРоль
notificationsглавная: одна строка = попытка доставки в один канал; хранит копию контакта и шаблонных переменных на момент отправки
templatesшаблоны (<event>.<channel> × locale): субъект + тело с ${var}
delivery_attemptsлог каждой попытки (изолирован от notifications — запросы по статусу идут по индексу, не по jsonb)
processed_eventsидемпотентность консьюмера (PK по event_id)

Схема (ER, типы, индексы) — в §Техническая реализация.


5. Жизненный цикл уведомления

СтатусОписание
QUEUEDсоздано из события, ждёт отправки
SENTушло в провайдер
DELIVEREDwebhook delivered (только email)
BOUNCEDwebhook bounced
FAILEDретраи исчерпаны / permanent error
diagram

Терминальные: DELIVERED, BOUNCED, FAILED (после ручной отметки). PUSH не имеет webhook → остаётся SENT (DELIVERED/BOUNCED недостижимы, ограничение FCM). Ручной retry — только из FAILED (из BOUNCED бесполезен).


6. Роли и доступ

РольКто
support-operatorоператор поддержки (Keycloak)
systemSMTP-провайдер (webhook, фиксированный IP + HMAC), Customer BFF (s2s)
Операцияsupport-operatorsystem
GET /notifications, /{id}
POST /{id}/retry, /{id}/abandon
POST /webhooks/email-events✅ (HMAC)

ABAC отсутствует — оператор видит все уведомления (фильтрация «по своему направлению» — уровень BFF, не Notification). Покупатели прямого доступа не имеют (inbox — через Customer BFF, s2s).

PII: email и push-токен — шифрование хранения; в логах маскируются (u***@example.com); TTL 90 дней (BR-N8).


7. Бизнес-правила

  • BR-N1инвариант. Идемпотентность по event_id: повторная доставка из Kafka не создаёт дубль (processed_events, INSERT … ON CONFLICT DO NOTHING).
  • BR-N2политика. Каналы выбираются жёстко по типу события (таблица в коде): OrderConfirmed/OrderPaid/OrderShipped/OrderCancelled/OrderRefunded/DisputeResolved → email+push; OrderDelivered → email; DisputeOpened → push продавцу + email оператору.
  • BR-N3инвариант. Шаблон обязателен: нет шаблона для (event, channel, locale) → уведомление не создаётся + метрика notification_template_missing_total. Лучше пропустить, чем отправить пустое.
  • BR-N4инвариант. Контакт материализуется на момент отправки (в журнале остаётся реальный адрес, даже если пользователь его поменял).
  • BR-N5политика. Retry: 3 попытки (30s/5min/30min) при TRANSIENT_ERROR (5xx, timeout); при PERMANENT_ERROR (4xx) — сразу FAILED.
  • BR-N6инвариант. Дедупликация webhook по (notification_id, webhook_event_id).
  • BR-N7инвариант. PUSH без подтверждения: HTTP 200 от FCM → SENT, в DELIVERED не переходит.
  • BR-N8политика. TTL журнала 90 дней (152-ФЗ/GDPR: ограничить хранение логов с PII).
  • BR-N9политика. Не более 100 писем/сек на провайдера (token bucket).

8. Команды (операции)

Уровень 1 — операции сервиса/шедулеров, не UseCase-классы.

ProcessOrderEvent

  • Триггер: Kafka marketplace.orders.v1 · Предусловия: BR-N1, BR-N3
  • Логика: проверить event_id; запросить контакт у Customer BFF; по таблице каналов (BR-N2) создать записи QUEUED.
  • Ошибки: CONTACT_LOOKUP_FAILED (→ FAILED после ретраев), TEMPLATE_MISSING (уведомление не создаётся)

DispatchPending

  • Триггер: шедулер (1с) · Логика: взять QUEUED (≤100), отрендерить шаблон, отправить через провайдера, обновить статус; при ошибке — delivery_attempts + retry (BR-N5).

ProcessEmailWebhook

  • Триггер: webhook Mailgun · Предусловия: HMAC (BR-N6) · Логика: по external_id найти уведомление → DELIVERED/BOUNCED.
  • Ошибки: WEBHOOK_SIGNATURE_INVALID

RetryNotification

  • Актор: support-operator · Переход: FAILEDQUEUED
  • Логика: сбросить last_error, новая запись в delivery_attempts.
  • Ошибки: NOTIFICATION_NOT_FOUND, INVALID_STATUS_FOR_RETRY

AbandonNotification

  • Актор: support-operator · Переход: FAILEDFAILED (финальный, без retry)

PurgeOldRecords

  • Триггер: шедулер (ежедневно) · Логика: удалить записи старше 90 дней (BR-N8).

9. Доменные события

Не применимо на Уровне 1. Notification событий не публикует — только потребляет события Order (см. §Интеграции).


10. Запросы

SearchNotifications

  • Актор: support-operator · Параметры: userId?, status?, eventType?, channel?, период, пагинация
  • Возвращает: страницу уведомлений · Логика: SELECT поверх notifications (отдельной Read Model на Уровне 1 нет).

GetNotification

  • Актор: support-operator · Параметры: id · Возвращает: полную запись + delivery_attempts.

GetUserInbox

  • Актор: Customer BFF (s2s) · Параметры: userId, пагинация · Возвращает: уведомления пользователя (EMAIL+PUSH) по дате.

11. Use Cases

UC-N1 Подтверждение заказа → email + push. OrderConfirmed → проверка event_id → контакт у BFF → по BR-N2 две записи QUEUEDDispatchPending отправляет → email SENT, через ~30с webhook → DELIVERED; push SENT.

UC-N2 Провайдер отвечает 503 → retry. 3 попытки (BR-N5) → FAILED + алёрт → оператор RetryNotificationQUEUED → успех.

UC-N3 Невалидный адрес → bounced. email SENT → webhook bounced (permanent) → BOUNCED; оператор видит причину.

UC-N4 Разбор оператора. Жалоба «не пришёл код» → фильтр по userId → запись BOUNCED → детали → ответ покупателю.


12. Процессы

Не применимо на Уровне 1. Межагрегатных/межсервисных процессов (Saga) нет — сервис обрабатывает каждое событие независимо.


13. UI-спецификация

Один интерфейс — админ-журнал для support-operator (пользователи inbox видят через Customer BFF).

  • Журнал (/admin/notifications): фильтры (userId/status/eventType/channel/даты), таблица (дата, получатель, канал, тип, статус-бейдж, действия), пагинация, счётчики «✓/⏳/✕».
  • Детали (/admin/notifications/{id}): поля записи + source_event_payload (JSON) + delivery_attempts; кнопки Retry/Abandon (если FAILED).
Код ошибкиТекст оператору
INVALID_STATUS_FOR_RETRYМожно ретраить только FAILED-уведомления.
NOTIFICATION_NOT_FOUNDУведомление не найдено.

Abandon — confirm-диалог: «Финальное действие, уведомление больше не отправится. Уверены?»


14. Критерии приёмки (Given / When / Then)

  • Given событие Order из таблицы каналов · When консьюмер его получил · Then созданы записи в правильных каналах (BR-N2).
  • Given уже обработанное событие · When Kafka доставляет дубль · Then второе уведомление не создаётся (BR-N1).
  • Given отправленное письмо · When Mailgun шлёт webhook delivered/bounced · Then SENTDELIVERED/BOUNCED.
  • Given webhook без валидной HMAC · When приём · Then WEBHOOK_SIGNATURE_INVALID (401).
  • Given провайдер отвечает 5xx · When отправка · Then 3 ретрая → FAILED; 4xx → сразу FAILED.
  • Given нет шаблона для (event, channel, locale) · When обработка · Then уведомление не создаётся, метрика notification_template_missing_total++.
  • Given уведомление в FAILED · When POST /retry · Then QUEUED; на не-FAILED → 409.
  • Given Customer BFF недоступен · When запрос контакта · Then FAILED; retry оператора срабатывает после восстановления BFF.

15. Нефункциональные требования

АспектТребование
Производительностьдо 500 событий/с → ~1000 уведомлений; p95 «событие → ушло в Mailgun» ≤ 5с; p99 ≤ 30с (с 1 retry)
Надёжностьat-least-once (лучше дубль, чем потеря подтверждения); идемпотентность консьюмера (BR-N1); потерянные QUEUED подберёт следующий запуск
БезопасностьHMAC всех webhook (timestamp от replay); PII шифруется + маскируется в логах; TTL 90 дней (BR-N8); s2s к BFF через service-account JWT
Наблюдаемостьметрики created/status/provider_latency/template_missing/retry; алёрты на failed > 5/min, template_missing > 0, provider_latency p99 > 10с; трейс по event_id/notification_id
Эксплуатацияшаблоны меняются без рестарта (кэш TTL ≤ 60с); новый провайдер = adapter; locale ru/en (32 шаблона)

16. Техническая реализация

java-21 · spring-boot-3 · spring-kafka (консьюмер) · spring-web (webhook + admin) · spring-security (OAuth2 + HMAC-фильтр) · postgresql-16 + jooq + flyway · Mailgun/FCM (REST) · resilience4j (CB/retry для BFF) · Micrometer/Prometheus · OpenTelemetry · JUnit5 + WireMock.

Persistence — только jOOQ, только сгенерированные классы (командное правило BS-17, на любом уровне зрелости). Postgres ENUM-типы (notification_channel/notification_status/delivery_attempt_result) → jOOQ генерит Java-enum. Уровень 1 — про отсутствие DDD/UseCase Pattern, не про упрощение persistence.

Схема БД

diagram

API-контракт ошибок — RFC 9457 ProblemDetails: NOTIFICATION_NOT_FOUND (404), INVALID_STATUS_FOR_RETRY (409), WEBHOOK_SIGNATURE_INVALID (401). Внутренние (TEMPLATE_MISSING, CONTACT_LOOKUP_FAILED, PROVIDER_UNAVAILABLE) наружу не светят — пишутся в last_error.

Расширения (Уровень 2/3, не в первой версии): подписки/опт-ауты (UpdatePreferences), A/B-тексты, WebPush, кампании (отдельный сервис), динамический выбор каналов через админку.


Машинно-читаемая копия — docs/spec/notification-service-spec.md рядом с кодом (github.com/remodov/notification-service): один файл (агрегатов нет), из которого скиллы методологии генерируют контроллеры, jOOQ-репозитории, шедулеры и тесты.