Каждый раз, когда покупатель оформляет заказ на маркетплейсе, ему приходит email «Заказ подтверждён» и пуш в приложение. Кажется просто — взял событие, отправил письмо. На деле за этим стоит отдельный сервис с ретраями, дедупликацией, журналом попыток и обработкой webhook'ов. Разберём, как он устроен.
Зачем отдельный сервис
Самый очевидный вариант — отправлять письмо прямо из сервиса заказов. Проблема: если Mailgun недоступен, транзакция заказа откатывается? Или письмо теряется? Платёж провёлся, заказ создан, а письмо пропало — пользователь не знает что происходит.
Второй вариант — отправлять в той же транзакции через очередь. Но тогда сервис заказов знает про email и push-каналы, провайдеров, шаблоны. Это лишняя ответственность не на своём месте.
Решение — отдельный Notification Service, который подписан на события заказа в Kafka и занимается только доставкой: берёт событие, выбирает каналы, рендерит шаблон, отправляет через провайдера, записывает результат.
Задача сервиса — не принимать решения о бизнес-логике (когда слать — решает сервис заказов, публикуя событие), а надёжно доставить уведомление.
Откуда берутся уведомления
Notification Service слушает топик marketplace.orders.v1 в Kafka. Туда Order Service публикует события жизненного цикла заказа: OrderConfirmed, OrderPaid, OrderShipped, OrderCancelled, OrderRefunded, OrderDelivered, DisputeOpened, DisputeResolved.
Каждое событие содержит event_id, тип события, userId покупателя и детали заказа. По userId сервис идёт в Customer BFF — запрашивает email и push-токены устройств пользователя, а также его язык (locale).
Идемпотентность. Kafka гарантирует доставку at-least-once — одно событие может прийти дважды. Чтобы не создавать дубликаты, каждый event_id записывается в таблицу processed_events. При повторной обработке — INSERT … ON CONFLICT DO NOTHING, дубль игнорируется.
Какие каналы и когда
Каждый тип события → конкретные каналы (жёстко задано в коде):
OrderConfirmed,OrderPaid,OrderShipped,OrderCancelled,OrderRefunded,DisputeResolved→ email + push покупателюOrderDelivered→ только emailDisputeOpened→ push продавцу + email оператору поддержки
Одно событие может породить две записи в журнале — по одной на каждый канал. В журнале это две независимые строки: письмо и пуш отслеживаются отдельно.
Контакт (адрес, токен) материализуется в момент отправки и сохраняется в журнале. Если пользователь потом поменяет email — история доставки покажет, на какой адрес реально ушло письмо.
Шаблоны и рендеринг
Текст письма и пуш-уведомления хранится в таблице templates. Ключ шаблона — это комбинация типа события и канала, например order.confirmed.email или order.confirmed.push. Каждый шаблон хранится в двух языках: ru и en.
В шаблоне есть плейсхолдеры вида ${orderNumber}, ${amount} — они подставляются из данных события перед отправкой.
Если шаблона для нужной комбинации (событие, канал, locale) нет — уведомление не создаётся. Пустое письмо хуже чем никакого, поэтому при отсутствии шаблона срабатывает метрика notification_template_missing_total и алёрт. Шаблоны хранятся в БД и обновляются без перезапуска сервиса (кэш TTL 60 секунд).
Статусы доставки
Каждая запись уведомления проходит через несколько статусов:
| Статус | Смысл |
|---|---|
QUEUED | создано из события, ждёт отправки |
SENT | ушло в провайдер |
DELIVERED | webhook от Mailgun подтвердил доставку (только email) |
BOUNCED | webhook сообщил о неверном адресе |
FAILED | ретраи исчерпаны или постоянная ошибка |
Push-уведомления не имеют механизма подтверждения на уровне FCM — HTTP 200 от Firebase означает «принято», но не «доставлено». Поэтому push остаётся в статусе SENT навсегда. Это ограничение FCM, а не баг.
Как работают ретраи
Отправкой занимается шедулер DispatchPending, который каждую секунду берёт пачку записей в статусе QUEUED (до 100 штук) и отправляет их через провайдера.
Если провайдер ответил временной ошибкой (5xx, таймаут) — запись получает статус QUEUED снова с задержкой: 30 секунд, потом 5 минут, потом 30 минут. После трёх неудачных попыток — FAILED. Если ошибка постоянная (4xx, невалидный адрес) — сразу FAILED без ретраев.
Каждая попытка записывается в таблицу delivery_attempts: дата, номер попытки, результат, фрагмент ответа провайдера. Это нужно для разбора инцидентов.
Чтобы не перегружать провайдеров, действует ограничение скорости — не более 100 писем в секунду (механизм token bucket).
Webhook от Mailgun
Когда Mailgun доставил письмо или получил отказ — он шлёт webhook на POST /webhooks/email-events. По external_id (идентификатор, который Mailgun вернул при отправке) сервис находит запись и переводит её в DELIVERED или BOUNCED.
Webhook проверяется по HMAC-подписи — без валидной подписи запрос отклоняется (401). Дедупликация по (notification_id, webhook_event_id) защищает от повторных доставок одного события.
Что видит оператор поддержки
Когда покупатель пишет «мне не пришло подтверждение» — оператор открывает журнал и фильтрует по userId. Он видит все записи: когда создано, в какой канал, на какой адрес, статус, сколько попыток и почему последняя провалилась.
Если уведомление в статусе FAILED — оператор может нажать «Retry» и оно снова встанет в QUEUED. Если адрес невалидный и retry бессмысленен — «Abandon», финальный статус.
Из соображений безопасности email и push-токены шифруются при хранении и маскируются в логах (u***@example.com). Данные журнала удаляются через 90 дней (требование по хранению персональных данных).
Схема базы данных
Четыре таблицы:
notifications— главная: одна строка на попытку доставки в один канал. Хранит материализованный контакт, ключ шаблона, статус,external_idдля матчинга webhook.templates— шаблоны (ключ события × канал × locale).delivery_attempts— лог каждой попытки с результатом и ответом провайдера.processed_events— таблица идемпотентности поevent_id.
Технический стек
Java 21, Spring Boot 3, Spring Kafka — потребитель событий. PostgreSQL + jOOQ + Flyway — хранение. Spring Web — REST-эндпоинты (webhook + админ-журнал). Spring Security + OAuth2 + HMAC-фильтр — авторизация. Resilience4j — Circuit Breaker и retry при обращении к Customer BFF. Micrometer/Prometheus, OpenTelemetry — метрики и трассировка.
Рабочий пример — github.com/remodov/notification-service.
Коротко
- Notification Service подписан на события заказа в Kafka и занимается только доставкой — бизнес-решения «когда слать» остаются в Order Service.
- Идемпотентность по
event_idзащищает от дублей при повторной доставке из Kafka. - Каналы (email/push) выбираются по типу события: одно событие может породить две записи в журнале.
- Контакт материализуется в момент отправки — история точна даже после смены адреса.
- Шаблоны в БД обновляются без перезапуска (кэш 60 секунд); нет шаблона — нет уведомления, срабатывает алёрт.
- Ретраи: три попытки при временных ошибках (30с/5мин/30мин); при постоянной ошибке — сразу FAILED.
- Статус DELIVERED доступен только для email (через webhook Mailgun); push остаётся в SENT — ограничение FCM.
- Журнал с фильтрацией и ручным retry — основной инструмент оператора при разборе жалоб.
Что почитать дальше
- Order Service — архитектура маркетплейса — сервис, который публикует события для Notification.
- Kafka: основы брокера сообщений — основы для понимания потребителей и at-least-once.