Эта спека — контрпример к 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)
| Ребро | Направление | Канал | Связь | Передаётся |
|---|---|---|---|---|
| Order Service | inbound | async | conformist | события marketplace.orders.v1 (создать уведомления) |
| Customer BFF | outbound | sync | conformist | GET /users/{id}/contact — email, push-токены, locale |
| SMTP (Mailgun) | bidirectional | sync + webhook | — (внешний провайдер) | отправка письма; webhook delivered/bounced |
| FCM (Firebase) | outbound | sync | — (внешний провайдер) | push (fire-and-forget, без webhook) |
| Admin UI | inbound | sync | customer-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 BFF | Customer BFF |
| Notification REST (admin + webhook) | OpenAPI | contracts/notification-api.openapi.yaml | Notification |
| Mailgun / FCM | внешние | документация провайдеров | внешние |
3. Ubiquitous Language
| Термин | В коде | Определение |
|---|---|---|
| Уведомление | Notification | Запись журнала: одно событие, одна попытка в один канал одному адресату. Письмо + push на одно событие = две записи. |
| Канал | Channel | EMAIL, PUSH (на запуске); SMS — расширение (добавится провайдер). |
| Адресат | Recipient | userId + материализованный контакт на момент отправки (адрес мог поменяться — в журнале правда). |
| Шаблон | Template | Сообщение в БД: ключ (order.confirmed) + язык + субъект и тело с плейсхолдерами ${var}. |
| Исходное событие | SourceEvent | Входящее Kafka-событие, по которому создано уведомление (JSON хранится для дебага/retry). |
| Статус доставки | DeliveryStatus | QUEUED, 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 | ушло в провайдер |
DELIVERED | webhook delivered (только email) |
BOUNCED | webhook bounced |
FAILED | ретраи исчерпаны / permanent error |
Терминальные: DELIVERED, BOUNCED, FAILED (после ручной отметки). PUSH не имеет webhook → остаётся SENT (DELIVERED/BOUNCED недостижимы, ограничение FCM). Ручной retry — только из FAILED (из BOUNCED бесполезен).
6. Роли и доступ
| Роль | Кто |
|---|---|
support-operator | оператор поддержки (Keycloak) |
system | SMTP-провайдер (webhook, фиксированный IP + HMAC), Customer BFF (s2s) |
| Операция | support-operator | system |
|---|---|---|
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 · Переход:
FAILED→QUEUED - Логика: сбросить
last_error, новая запись вdelivery_attempts. - Ошибки:
NOTIFICATION_NOT_FOUND,INVALID_STATUS_FOR_RETRY
AbandonNotification
- Актор: support-operator · Переход:
FAILED→FAILED(финальный, без 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 две записи QUEUED → DispatchPending отправляет → email SENT, через ~30с webhook → DELIVERED; push SENT.
UC-N2 Провайдер отвечает 503 → retry. 3 попытки (BR-N5) → FAILED + алёрт → оператор RetryNotification → QUEUED → успех.
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· ThenSENT→DELIVERED/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· WhenPOST /retry· ThenQUEUED; на не-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.
Схема БД
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-репозитории, шедулеры и тесты.