Почти в любом развивающемся веб-продукте рано или поздно появляются уведомления.
Сначала требования выглядят просто:
После регистрации отправить письмо.
Потом добавляется:
Когда клиент написал сообщение, уведомить администратора.
Затем:
Если пользователь не находится на сайте, показать push.
Ещё через некоторое время:
Давайте продублируем важные события в Telegram.
А потом появляется внутренний центр:
В личном кабинете должна храниться история всех уведомлений.
Если каждую новую функцию добавлять отдельно, архитектура быстро превращается примерно в это:
createProject()
├── sendEmail()
├── sendTelegram()
└── createNotification()
addMessage()
├── sendEmail()
├── sendPush()
├── sendTelegram()
└── createNotification()
changeStatus()
├── sendEmail()
├── sendPush()
└── createNotification()Через год уже никто точно не знает, почему одно событие отправляет четыре уведомления, другое — два, третье вообще ничего не отправляет, а пользователь получает три одинаковых сообщения подряд.
Правильнее проектировать систему иначе:
Бизнес-событие
↓
Notification policy
↓
Внутреннее уведомление
↓
Выбор каналов доставки
↓
Email / Push / Telegram
↓
История и статусы доставкиТо есть сначала приложение отвечает на вопрос:
что произошло и кого нужно уведомить?
И только потом:
через какие каналы это уведомление доставить?
Именно такое разделение превращает уведомления из набора побочных функций в полноценную подсистему веб-продукта.
Уведомление и способ доставки — не одно и то же
Представим клиент написал администратору:
Проект #1842
Новое сообщение:
«Прикрепил уточнённое техническое задание»Само бизнес-событие:
PROJECT_MESSAGE_CREATEDсуществует независимо от email, Telegram или push.
Сегодня продукт может использовать:
внутренний центр + emailЧерез полгода:
внутренний центр + Web Push + TelegramНо событие остаётся тем же.
Поэтому лучше моделировать систему так:
PROJECT_MESSAGE_CREATED
↓
NotificationService
↓
recipient = admin
↓
notification =
"Новое сообщение по проекту #1842"
↓
channels:
IN_APP
EMAIL
TELEGRAMЕсли завтра Telegram отключат, бизнес-событие не меняется.
Если пользователь запретит push, событие тоже не меняется.
Меняется только способ доставки.
Почему внутренний центр уведомлений лучше сделать основой
На первый взгляд можно решить:
Зачем хранить уведомление внутри приложения? Отправим email и всё.
Проблема в том, что email — внешний транспорт.
Письмо может попасть:
в спам;
в задержку;
в переполненный почтовый ящик;
под фильтр;
в недоступный почтовый сервер.Push может быть запрещён пользователем.
Telegram пользователь может вообще не подключить.
А внутренний центр находится под контролем самого продукта.
Поэтому хорошая архитектура часто использует внутреннее уведомление как долговечную запись:
notification_id
user_id
type
title
body
created_at
read_at
entity_type
entity_idНапример:
{
"id": "notif_7281",
"type": "PROJECT_MESSAGE_CREATED",
"title": "Новое сообщение",
"body": "Клиент написал по проекту #1842",
"entityType": "project",
"entityId": 1842,
"createdAt": "2026-10-09T18:30:00Z",
"readAt": null
}Теперь пользователь может открыть раздел:
Уведомлениячерез час, завтра или через неделю и всё равно увидеть событие.
Email, push и Telegram становятся ускорителями доставки внимания.
Не единственными носителями информации.
Хорошая модель: уведомление сначала существует, потом доставляется
Полезно разделить две сущности.
Первая:
Notificationотвечает:
Что пользователь должен узнать?
Вторая:
NotificationDeliveryотвечает:
Каким каналом мы пытались ему это доставить и что произошло?
Например:
Notification
────────────
id = 7281
user = 52
type = PROJECT_MESSAGE_CREATED
project = 1842
created = 18:30
read = falseА доставки:
NotificationDelivery
────────────────────
notification 7281
channel EMAIL
status SENT
notification 7281
channel PUSH
status ACCEPTED
notification 7281
channel TELEGRAM
status FAILEDЭто значительно лучше одного поля:
notification_sent = trueПотому что возникает естественный вопрос:
Куда именно sent?
Одно уведомление может иметь несколько каналов
Например для обычного сообщения:
IN_APP
+
PUSHДля важного изменения договора:
IN_APP
+
EMAILДля критического события администратора:
IN_APP
+
EMAIL
+
TELEGRAMНо это всё ещё одно логическое уведомление:
notification_id = 7281а не три независимых сообщения, случайно созданных разными участками кода.
Откуда вообще возникают уведомления
Лучше не заставлять каждую бизнес-функцию знать детали каналов.
Плохой вариант:
async function changeProjectStatus(projectId, status) {
await updateProject(projectId, status);
await sendEmail(...);
await sendTelegram(...);
await sendPush(...);
}Теперь функция изменения проекта знает слишком много:
про SMTP;
Telegram;
push;
предпочтения пользователя;
шаблоны сообщений.Гораздо чище:
ProjectStatusChangedПосле изменения доменного состояния публикуется событие:
{
"type": "PROJECT_STATUS_CHANGED",
"projectId": 1842,
"oldStatus": "in_progress",
"newStatus": "review"
}Notification subsystem уже решает:
кого уведомить;
каким текстом;
какими каналами;
с какой срочностью.Это особенно важно, когда событий становится много
Через некоторое время в продукте могут существовать:
USER_REGISTERED
PROJECT_CREATED
PROJECT_STATUS_CHANGED
PROJECT_MESSAGE_CREATED
PROJECT_FILE_UPLOADED
CHANGE_REQUEST_CREATED
CHANGE_REQUEST_ACCEPTED
PAYMENT_RECEIVED
PROJECT_COMPLETED
SECURITY_LOGIN_DETECTED
PASSWORD_CHANGEDЕсли каждый handler самостоятельно реализует email/push/Telegram, логика начинает дублироваться.
При централизованной модели каждое событие проходит через одну систему правил.
Notification policy — мозг системы
Например событие:
PROJECT_MESSAGE_CREATEDПравило может быть таким:
| Получатель | In-app | Push | Telegram | |
|---|---|---|---|---|
| Клиент | ✓ | при настройке | ✓ | при подключении |
| Администратор | ✓ | ✓ | ✓ | ✓ |
Для:
PASSWORD_CHANGEDполитика будет другой:
| Получатель | In-app | Push | Telegram | |
|---|---|---|---|---|
| Пользователь | ✓ | обязательно | возможно | нет |
А для обычного:
REPORT_READYможет быть достаточно:
| Получатель | In-app | Push | Telegram | |
|---|---|---|---|---|
| Пользователь | ✓ | нет | ✓ | нет |
Таким образом правила каналов существуют централизованно и могут изменяться без переписывания бизнес-кода.
Пользовательские настройки не должны управлять самим фактом существования события
Допустим пользователь отключил:
Email для новых сообщенийЭто не означает:
Вообще не создавать уведомление.
Правильнее:
Notification
→ создать
Email delivery
→ skip by user preferenceВнутренний центр всё равно сохраняет событие.
Пользователь просто выбрал, каким внешним способом привлекать его внимание.
Не все уведомления можно отключать
Это важное продуктовое правило.
Можно дать пользователю отключить:
новости;
маркетинговые рассылки;
необязательные напоминания;
часть обновлений проектов.Но события вроде:
смена пароля;
изменение email;
подозрительный вход;
критическое изменение безопасности;
юридически обязательное сообщениемогут иметь совсем другую политику.
То есть preferences полезно делить на:
OPTIONALи:
MANDATORY.Иначе пользователь случайно выключит именно то уведомление, которое должно предупредить его о захвате аккаунта.
Email: хороший канал для информации, которую нужно сохранить
Email остаётся полезным каналом именно потому, что пользователь привык возвращаться к письмам.
Он хорошо подходит для:
подтверждения регистрации;
изменения пароля;
договорных событий;
чеков;
счётов;
итогов операции;
длинных уведомлений;
сообщений, которые могут понадобиться через несколько дней.Но email значительно хуже как канал настоящего real time.
Письмо может задержаться.
Почтовый клиент может проверить почту позднее.
Фильтр может убрать письмо во вкладку уведомлений.
Поэтому архитектура не должна предполагать:
sendEmail()
=
пользователь немедленно узнал.Email лучше отправлять асинхронно
Плохая регистрация:
POST /register
↓
INSERT user
↓
SMTP
↓
ждём 8 секунд
↓
SMTP timeout
↓
500 Internal Server ErrorПользователь зарегистрирован.
Но интерфейс сообщает:
Регистрация не удалась.
Это плохая связанность.
Лучше
POST /register
↓
DB transaction
↓
UserRegistered event
↓
COMMIT
↓
200Отдельный worker:
UserRegistered
↓
Email delivery job
↓
SMTP/providerЕсли email-провайдер временно недоступен:
retry.Регистрация от этого не отменяется.
Исключение — некоторые verification flow
Например продукт действительно не разрешает пользоваться системой до подтверждения email.
Но даже здесь сам пользовательский account и delivery verification email лучше считать разными состояниями:
account = created
email_verified = falseЕсли отправка письма не удалась, система может повторить её.
Не создавать пользователя заново.
Email delivery тоже требует состояний
Например:
QUEUED
↓
SENDING
↓
SENTили:
SENDING
↓
RETRY
↓
FAILEDДля внешнего email-provider можно дополнительно хранить:
provider_message_id.Но слово:
SENTнужно использовать аккуратно.
Чаще оно означает:
Провайдер принял сообщение на отправку.
Не:
Получатель прочитал письмо.
Не пытайтесь создать универсальный статус READ для всех каналов
Внутренний центр уведомлений действительно может знать:
read_at.Потому что пользователь открыл уведомление внутри вашего приложения.
С email всё сложнее.
С push тоже.
Telegram API может подтвердить принятие операции, но это не значит, что человек прочитал сообщение.
Поэтому единая шкала:
SENT
DELIVERED
READдля всех каналов часто является ложной абстракцией.
Лучше иметь channel-specific capabilities.
Например:
IN_APP:
CREATED
SEEN
READEMAIL:
QUEUED
ACCEPTED
BOUNCED
FAILEDPUSH:
QUEUED
ACCEPTED_BY_PUSH_SERVICE
INVALID_SUBSCRIPTION
FAILEDTELEGRAM:
QUEUED
ACCEPTED
FAILEDИ не придумывать данные, которых канал на самом деле не предоставляет.
Web Push: быстрый способ вернуть внимание пользователя
Web Push полезен, когда пользователь:
не смотрит текущую вкладку;
перешёл в другое приложение;
закрыл страницу,но разрешил уведомления.
В современной веб-архитектуре push-подписка связана с service worker. Push API позволяет сервису доставлять событие приложению асинхронно, а service worker уже может показать системное уведомление.
Но здесь есть принципиальное отличие от внутреннего центра:
push существует только после согласия пользователя.
Нельзя проектировать бизнес-процесс так:
Пользователь обязательно увидит push.
Он может нажать:
Запретить.Браузер может изменить subscription.
Она может стать недействительной.
У пользователя может быть другое устройство.
Поэтому push — дополнительный канал.
Push subscription — отдельная сущность
Например:
push_subscriptions
id
user_id
endpoint
p256dh
auth
created_at
last_success_at
disabled_atУ одного пользователя может существовать несколько подписок:
Desktop Chrome
Android PWA
LaptopСледовательно, связь:
user
→ one push subscriptionобычно слишком упрощённая.
Правильнее:
user
→ many devices/subscriptions.Не храните push subscription как обычную публичную строку
Endpoint подписки фактически является чувствительной capability-информацией.
Его не нужно:
логировать во всех логах;
показывать в админке целиком;
отправлять на frontend другого пользователя.Подписки нужно хранить и обрабатывать как технические данные доставки.
Что делать с умершей push-подпиской
Push provider может сообщить, что subscription больше не существует.
Например пользователь:
очистил данные браузера;
отозвал разрешение;
сменил профиль;
удалил PWA.Система не должна retry такой endpoint бесконечно.
Нужно:
mark invalidили удалить subscription в соответствии с retention policy.
Push не должен содержать слишком много чувствительной информации
На заблокированном телефоне уведомление может быть видно окружающим.
Поэтому вместо:
Ваш долг составляет 184 000 ₽, договор №...
часто безопаснее:
В вашем аккаунте появилось важное обновление.
А подробности пользователь увидит после открытия приложения и нормальной авторизации.
Deep link из push тоже не отменяет authorization
Уведомление может открыть:
/projects/1842Но backend всё равно должен проверить:
имеет ли текущий пользователь
право видеть проект 1842?Нельзя считать possession push notification разрешением на доступ.
Push permission нужно запрашивать в подходящий момент
Плохой UX:
пользователь впервые открыл сайт
↓
через 300 мс
↓
«Разрешить уведомления?»Человек ещё не понял, зачем они ему.
Гораздо понятнее контекстное предложение:
Разрешить уведомления о новых сообщениях по вашему проекту?
Тогда пользователь знает, какую пользу получает.
Telegram — удобный внешний канал, но не универсальный
Telegram особенно удобен для:
администратора;
менеджеров;
операционных уведомлений;
владельца сервиса;
пользователей, которые сами подключили бота.Например администратор получает:
Новая быстрая заявка
Проект:
CRM для отдела продаж
Клиент:
Иван
Открыть в админ-панелиЭто действительно удобно: администратору не нужно постоянно держать dashboard открытым.
Но Telegram нельзя считать заменой внутренней системе
Пользователь может:
заблокировать бота;
удалить чат;
сменить Telegram account;
отозвать подключение.Кроме того, бот должен иметь возможность отправлять сообщение конкретному получателю. В обычном private-chat сценарии пользователь сначала должен взаимодействовать с ботом, после чего приложение может сохранить связанный идентификатор чата.
Поэтому схема:
мы знаем email пользователя
↓
как-нибудь найдём его Telegramне работает.
Нужно явное подключение.
Хороший сценарий подключения Telegram
В настройках пользователя:
Telegram
Не подключён
[Подключить]После перехода к боту пользователь подтверждает связь.
Backend получает связку:
application_user_id
↔
telegram_chat_idТеперь канал можно использовать.
Но связь нужно уметь отозвать
Например:
Telegram
Подключён: @username
[Отключить]После отключения приложение:
не отправляет новые сообщения;
не использует старую связь.Telegram token никогда не должен попадать во frontend
Bot token — серверный credential.
Правильная схема:
Notification worker
↓
Telegram adapter
↓
Telegram Bot APIНе:
Browser
↓
Telegram Bot API + secret token.Telegram особенно хорош для операционных событий
Например:
новая заявка;
критическая ошибка;
оплата;
новый клиент;
вопрос клиента;
неуспешный backup;
проблема с интеграцией.Но даже администратора не нужно заваливать всем подряд.
Если система каждую минуту отправляет:
Пользователь открыл страницу
Пользователь обновил страницу
Пользователь закрыл страницучерез несколько дней Telegram-уведомления начнут игнорировать целиком.
Это называется alert fatigue.
Чем больше уведомлений, тем меньше ценность каждого
Это универсальная проблема всех каналов.
Если приложение показывает:
1 новое уведомлениепользователь обращает внимание.
Если:
487 непрочитанныхцентр уведомлений перестаёт быть полезным инструментом.
Поэтому хороший notification design занимается не только доставкой.
Но и подавлением информационного шума.
Не каждое событие должно превращаться в уведомление
Например проект получил:
status = in_progress.Возможно клиенту действительно важно знать это.
А техническое:
background_job_retry = 2ему совершенно не нужно.
Система должна различать event и notification
Event:
что-то произошло в системе.Notification:
это событие имеет смысл показать конкретному человеку.Не все events становятся notifications.
И одно событие может создать разные уведомления
Например:
PAYMENT_FAILEDдля пользователя:
Оплата не завершена. Попробуйте другой способ оплаты.
Для администратора:
Платёж заказа #1842 завершился ошибкой PROVIDER_TIMEOUT.
Это одно business event.
Но аудитории и сообщения разные.
Не отправляйте технические ошибки пользователю
Плохой email:
PaymentIntegrationException:
ECONNRESET provider gatewayПравильнее:
Не удалось подтвердить оплату. Мы продолжаем проверку статуса.
А техническая информация остаётся:
в логах;
monitoring;
админском уведомлении.Приоритет уведомлений
Полезно разделять уведомления хотя бы концептуально:
LOW
NORMAL
HIGH
CRITICALНапример:
LOW:
новая статья в экспертном центре.NORMAL:
изменился статус проекта.HIGH:
клиент написал сообщение.CRITICAL:
обнаружен подозрительный вход.Приоритет может влиять на routing.
Например
LOW
→ только внутренний центрNORMAL
→ внутренний центр + pushHIGH
→ внутренний центр + push + emailCRITICAL
→ обязательный email
+ внутреннее уведомление
+ дополнительные разрешённые каналыЭто намного полезнее подхода:
Всё отправляем везде.
Но priority не должен полностью заменять конкретные правила
Например:
PASSWORD_CHANGEDи:
NEW_PROJECT_MESSAGEмогут оба быть HIGH.
Но каналы у них будут разными.
Поэтому notification policy обычно зависит от:
event type
+
recipient
+
user preferences
+
priority
+
context.Quiet hours
Пользователь может не хотеть:
обычный push в 03:40.Полезная настройка:
Не беспокоить:
23:00–08:00Но возникает важный вопрос:
Что делать с критическими уведомлениями?
Можно определить:
обычные
→ отложитькритические security
→ доставлять независимо.Это должно быть частью product policy.
Не случайным if в push worker.
Digest вместо двадцати одинаковых писем
Представим за 15 минут в проекте появилось:
12 новых сообщений.Плохая система отправляет:
12 email
12 push
12 Telegram messages.Через день пользователь всё отключает.
Можно агрегировать
Например:
У вас 12 новых сообщений
по проекту «CRM для отдела продаж»Внутренний центр по-прежнему может хранить точные события.
А внешний канал получает digest.
Это особенно полезно для email
Например вместо:
5 новых комментариев
→ 5 emailотправить через небольшой aggregation window:
5 новых комментариев в проекте.Но нельзя агрегировать всё подряд
Например:
Ваш пароль изменённе должен ждать двухчасового digest.
Routing снова зависит от типа события.
Escalation: канал может зависеть от реакции пользователя
Интересная модель:
сначала in-app + push.Если пользователь открыл уведомление:
готово.Если важное сообщение через условный период всё ещё не замечено:
дополнительно email.Это позволяет уменьшить шум.
Но такую систему нужно строить осторожно, потому что разные каналы не всегда умеют достоверно сообщать:
пользователь прочитал.Надёжнее ориентироваться на собственное:
notification.read_atв приложении, если пользователь действительно должен перейти туда.
Почему внутренний центр становится центральной точкой
Потому что только он позволяет точно сказать:
уведомление создано;
пользователь увидел список;
пользователь открыл конкретное уведомление;
связанная сущность была открыта.Внешние каналы такой полной картины не дают.
Непрочитанное и неувиденное — разные состояния
Можно моделировать:
CREATEDSEENREADНапример пользователь открыл dropdown уведомлений:
SEEN.Но конкретное:
«Изменился договор»ещё не открывал:
READ = false.Так интерфейс становится точнее.
Но иногда достаточно read_at
Не нужно усложнять модель, если продукту не требуется разница.
Для большинства систем вполне достаточно:
read_at IS NULLили:
read_at = timestamp.Главное, чтобы состояние имело ясную семантику.
«Отметить все прочитанными»
Кажется простой функцией.
Но важно решить:
только текущую страницу?
все уведомления пользователя?
только видимый тип?Обычно понятнее:
Mark all notifications as read
WHERE user_id = current_user
AND read_at IS NULL.И операция должна быть серверной.
Не просто визуально убрать badge во frontend.
Счётчик непрочитанных тоже лучше получать с backend
Плохая логика:
frontend:
notifications.lengthПотому что на экране может быть только:
последние 20,а непрочитанных:
57.Правильнее backend возвращает:
{
"unreadCount": 57
}Как обновлять центр уведомлений без перезагрузки
Здесь возвращается выбор между polling и WebSocket.
Для обычного бизнес-продукта вполне возможно:
GET /notifications/unread-count
каждые 15–30 секунд.Если уведомления должны появляться практически мгновенно:
WebSocketили SSE могут передавать:
{
"type": "notification.created",
"notificationId": 7281
}Frontend добавляет новую запись или делает небольшой resync.
Но WebSocket не должен быть единственным хранилищем уведомления
Правильная последовательность:
Business event
↓
INSERT notification
↓
COMMIT
↓
Realtime event
↓
BrowserНе:
WebSocket
↓
показываем toast
↓
и нигде больше уведомления нет.Если пользователь был offline, сообщение будет потеряно.
In-app notification + WebSocket — сильная комбинация
Database:
надёжно хранит.WebSocket:
ускоряет появление.После reconnect frontend делает:
GET /notificationsи восстанавливает всё пропущенное.
Toast — не центр уведомлений
Toast:
Проект успешно созданна несколько секунд — это UX-feedback.
Не обязательно сохранять его навсегда.
Но:
Клиент отправил изменение ТЗможет потребовать persistent notification.
Поэтому полезно отличать
EPHEMERAL UI MESSAGEот:
PERSISTENT NOTIFICATION.Не нужно сохранять в центре:
Настройки успешно сохраненыпосле каждого клика.
Иначе история превращается в журнал UI.
Событие должно иметь deduplication key
Представим background worker дважды обработал:
PROJECT_STATUS_CHANGEDи оба раза создал уведомление.
Пользователь получает:
Статус проекта изменён
Статус проекта изменёнПоэтому для некоторых типов полезен ключ
Например:
PROJECT_STATUS_CHANGED:
project=1842
new_status=review
revision=17
recipient=52Из этого можно получить dedupe key.
В базе:
UNIQUE(notification_type, recipient, dedupe_key)или эквивалентную модель.
Но dedupe window зависит от события
Два сообщения клиента:
«Здравствуйте»и ещё одно:
«Здравствуйте»могут быть реальными отдельными событиями.
Нельзя дедуплицировать их просто по одинаковому тексту.
Нужен event_id
Если notification создаётся из business event:
event_id = evt_123.Можно гарантировать:
event evt_123
+
recipient 52
+
notification typeсоздают одну notification row.
Если handler выполняется повторно, дубль не появляется.
Это особенно важно с background queues
У большинства надёжных очередей обработка фактически строится вокруг модели:
at least once.Job может выполниться повторно.
Следовательно notification creation должна быть идемпотентной.
Transactional outbox помогает не терять уведомления
Представим клиент создал новый проект.
Плохой код:
INSERT project
COMMIT
↓
publish NotificationEventМежду:
COMMITи отправкой event process падает.
Проект существует.
Уведомления нет.
Лучше в одной транзакции
INSERT project
INSERT outbox_event
COMMITWorker позже читает:
PROJECT_CREATEDи запускает notification pipeline.
Теперь либо существуют:
и проект,
и durable event,либо:
не существует ни одного.Notification pipeline тоже лучше выполнять фоново
Например:
Outbox event
↓
Notification processor
↓
create notification
↓
create delivery jobsДальше независимые workers:
Email worker
Push worker
Telegram workerПочему отдельные workers полезны
Представим Telegram API временно недоступен.
Если всё выполняется одной job:
Email успешно
Push успешно
Telegram fail
↓
retry всей job
↓
Email второй раз
Push второй разПолучаем дубли.
Лучше каждый delivery живёт отдельно
notification 7281
EMAIL
→ SENT
PUSH
→ SENT
TELEGRAM
→ RETRYПовторяется только Telegram.
База данных может выглядеть так
notifications
────────────────────────
id
recipient_user_id
type
title
body
entity_type
entity_id
priority
created_at
read_atи:
notification_deliveries
────────────────────────
id
notification_id
channel
status
attempts
next_attempt_at
provider_message_id
last_error
created_at
sent_atДополнительно:
notification_preferencesи:
push_subscriptions
telegram_connectionsЭто уже полноценная notification platform внутри продукта
Даже если она работает в обычном монолите.
Для этого не обязательно создавать:
Notification Microservice™.Один хорошо разделённый модуль внутри приложения часто более чем достаточен.
Монолит вполне может иметь чистые границы
Например:
notifications/
domain/
policies/
templates/
channels/
email/
push/
telegram/
workers/Это гораздо полезнее, чем преждевременно выносить всё в отдельный сервис.
Когда отдельный notification service действительно появляется
Например одна notification platform обслуживает:
несколько продуктов;
миллионы пользователей;
большие кампании;
множество команд.Тогда выделение становится оправданным.
Но обычному SaaS или клиентскому кабинету часто достаточно модульного монолита + очереди задач.
Шаблоны уведомлений тоже требуют архитектуры
Плохой вариант:
sendEmail(
user.email,
"Ваш проект " + project.name + " изменил статус..."
);такие строки быстро размножаются по backend.
Лучше template layer
Например:
template:
PROJECT_STATUS_CHANGEDданные:
{
"projectName": "CRM для отдела продаж",
"statusLabel": "Проверка"
}Email renderer создаёт длинную версию.
Push:
Проект «CRM для отдела продаж»:
новый статус — «Проверка»Telegram может добавить кнопку или ссылку.
In-app notification — короткий текст и entity reference.
То есть сообщение одно по смыслу, но не обязательно одинаковое по тексту
Это важный принцип.
Плохая попытка унификации:
одна строка
→ отправить во все каналы.Email позволяет длинный текст.
Push требует краткости.
Внутренний центр может показать дополнительные метаданные.
Telegram поддерживает собственное форматирование и кнопки.
Поэтому notification model должна хранить семантику, а channel adapter — формировать подходящее представление.
Notification intent:
type:
PROJECT_FILE_UPLOADED
project:
1842
actor:
client 52
file_count:
3Email:
Михаил загрузил 3 файла в проект №1842. Откройте проект, чтобы проверить новые материалы.
Push:
Проект №1842: загружено 3 новых файла.
In-app:
3 новых файла
Проект №1842 · 2 минуты назадTelegram:
📎 В проект №1842 клиент загрузил 3 файла.
Смысл одинаков.
Формат канала — разный.
Версионирование шаблонов
Шаблон сегодня:
PROJECT_COMPLETED v1через год изменился.
Для простых уведомлений хранить полный immutable rendered текст часто удобнее, чем пытаться каждый раз заново рендерить старую историю современным шаблоном.
Иначе пользователь откроет уведомление двухлетней давности, а его текст неожиданно будет построен по сегодняшним правилам.
Поэтому внутреннее уведомление может хранить snapshot
Например:
title
bodyготовыми строками на момент создания.
Плюс structured metadata:
entity_type
entity_id.Localization
Если продукт мультиязычный, язык notification желательно определять в момент создания delivery.
Например пользователь:
locale = ru.Email отправляется по русскому template.
Но что делать, если через месяц пользователь переключил язык на английский и открыл старую историю?
Есть два подхода.
Первый:
хранить готовый русский snapshot.Исторически точно.
Второй:
хранить semantic event
и рендерить текущим locale.Гибче, но история меняется.
Для большинства бизнес-уведомлений snapshot проще и предсказуемее.
Ссылки в уведомлениях должны быть стабильными
Например:
/project/1842лучше строить серверным canonical routing helper.
Не вручную в каждом email.
Но нельзя доверять старой ссылке как источнику authorization
Проект мог:
сменить владельца;
уйти в архив;
быть удалён;
потерять доступ пользователя.Поэтому открытие deep link всегда проходит обычную авторизацию.
Не отправляйте секреты в URL уведомления
Например:
https://example.ru/project/1842?adminToken=SECRETможет попасть:
в email;
Telegram;
browser history;
proxy logs.Если нужен одноразовый verification flow, используйте специально спроектированные короткоживущие tokens с ограниченными правами.
Но обычное уведомление не должно превращаться в authentication credential.
Email, push и Telegram могут содержать персональные данные
Поэтому notification system участвует и в privacy architecture.
Например сообщение:
Пользователь Иван Иванов прикрепил медицинский документ...
может оказаться:
в email inbox;
на заблокированном экране;
в Telegram;
в корпоративной истории чата.Не вся информация, которую можно показать внутри защищённого кабинета, должна копироваться во внешний канал.
Полезно иметь уровень чувствительности
Например:
PUBLIC
INTERNAL
SENSITIVEДля SENSITIVE push может показывать:
В вашем аккаунте новое сообщение.
Без подробностей.
Email unsubscribe и transactional notifications
Нельзя смешивать:
маркетинговую рассылкус:
операционным уведомлением о безопасности.Пользователь может отказаться от рекламных писем, но это не обязательно означает отказ от:
подтверждения email;
сброса пароля;
изменения безопасности аккаунта.Поэтому тип notification должен определять и юридическую/продуктовую категорию доставки.
Notification preferences лучше хранить по типам или группам
Плохая настройка:
Email:
ON/OFFПользователь хочет:
не получать рекламу,но получать:
сообщения по проекту.Гораздо полезнее:
| Категория | Push | Telegram | |
|---|---|---|---|
| Сообщения по проектам | ✓ | ✓ | ✓ |
| Изменения статусов | ✓ | ✓ | — |
| Файлы | — | ✓ | ✓ |
| Новости продукта | — | — | — |
| Безопасность | обязательно | ✓ | — |
Так пользователь управляет вниманием, а не выключает целый канал вслепую.
Настройки по умолчанию имеют большое значение
Если по умолчанию включить:
email + push + Telegramдля каждого движения проекта, пользователи быстро отключат всё.
Лучше начинать с консервативной политики.
Например:
in-app
→ почти всегда
push
→ события, требующие внимания
email
→ важные и долговечные события
Telegram
→ opt-inКаналы можно выбирать с учётом активности пользователя
Представим пользователь прямо сейчас находится в проекте.
Ему приходит новое сообщение.
Внутри приложения WebSocket сразу добавляет его в чат.
Нужно ли одновременно:
push на тот же ноутбук
+
email
+
Telegram?Возможно, нет.
Presence-aware suppression
Если система знает:
user currently activeможно не отправлять некоторые внешние каналы.
Например:
user online
+
project open
→ in-app only.Если:
user offline
→ push.Если уведомление важное и долго не прочитано:
→ email.Это делает систему значительно менее раздражающей.
Но presence не должен быть источником истины
WebSocket может оборваться.
Tab может быть background.
Поэтому правило:
online = true
→ никогда emailможет оказаться слишком агрессивным.
Presence лучше использовать как оптимизацию, а не как безусловную гарантию внимания.
Rate limiting нужен и для уведомлений
Допустим в проект импортировали:
1000 файлов.Событие на каждый файл создаёт:
1000 notifications;
1000 push;
1000 Telegram.Технически система работает.
Продуктово — нет.
Нужны aggregation rules
Например:
1000 FILE_UPLOADED events
↓
одна notification:
«В проект загружено 1000 файлов»Или лимит канала
Например:
не более 5 non-critical push
одному пользователю за 10 минут.Остальные:
aggregate
или in-app only.Но не нужно rate-limit security warning вместе с маркетингом
Правила опять должны учитывать тип события.
Retry notification deliveries
Не каждая ошибка требует одинакового поведения.
Например email provider:
500 / timeout
→ retry.Постоянный invalid recipient:
→ failed,
retry бессмыслен.Push subscription expired:
→ disable subscription.Telegram:
bot blocked
→ отключить канал для этой связи
или пометить connection invalid.Поэтому channel adapters должны нормализовать ошибки
Внутренняя система не должна зависеть от десятков provider-specific сообщений.
Например:
TRANSIENT_FAILURE
PERMANENT_FAILURE
INVALID_DESTINATION
RATE_LIMITED
ACCEPTEDКаждый adapter переводит ответ своего провайдера в общую модель.
Retry with backoff
Например временная ошибка:
attempt 1
↓
1 min
attempt 2
↓
5 min
attempt 3
↓
15 min
attempt 4
↓
1 hНе нужно каждую секунду атаковать provider.
А критическое уведомление?
Можно выбрать более агрессивную retry policy.
Но даже критическое сообщение не должно создавать бесконечный tight loop при outage внешнего сервиса.
Failed state должен быть видим
Плохая реализация:
send email
↓
error
↓
console.log(error)Через месяц никто не знает, почему важные письма не доставлялись.
Лучше админская статистика
Например:
Notification deliveries
Queued: 4
Retry: 2
Failed: 1
Email provider:
Healthy
Push:
Healthy
Telegram:
3 invalid connectionsДля критичных operational notifications полезен alert
Парадокс:
Система уведомлений перестала отправлять уведомления.
Если единственный alert об этом тоже идёт через сломанный notification worker, никто ничего не узнает.
Поэтому инфраструктурный monitoring желательно держать частично отдельно от пользовательского notification pipeline.
Notification queue не должна бесконечно расти
Полезные метрики:
queued deliveries
oldest queued age
delivery latency
failure rate by channel
retry count
notifications created per minuteНапример:
Email queue:
27 jobsможет быть нормально.
Но:
oldest = 6 hoursявно указывает на проблему.
Delivery latency
Для каждого канала полезно знать:
accepted_at - created_atНапример:
Push p95 = 2 sec
Email p95 = 18 sec
Telegram p95 = 3 secТеперь «уведомления приходят быстро» становится измеримой характеристикой.
Но end-to-end latency ещё важнее
Например:
business event:
18:30:00
notification created:
18:30:00.200
push accepted:
18:30:01.100Получаем:
~1.1 sec.Так можно увидеть, где возникает задержка:
event processing;
notification queue;
provider.Внутренний центр тоже требует пагинации
Если продукт работает несколько лет, у пользователя может накопиться:
10 000 уведомлений.Нельзя постоянно загружать всё.
Нужны:
pagination/cursor;
filter unread;
filter type;
archive/retention.Не обязательно хранить все уведомления вечность
Например:
обычные уведомления:
12 месяцева:
security audit eventsмогут иметь другой lifecycle.
Retention нужно определять по продуктовым и юридическим требованиям.
Удаление notification не должно удалять бизнес-данные
Если пользователь удалил:
уведомление о проекте,это не означает:
удалить проект.Notification — представление события.
Не владелец исходной сущности.
Ссылочная модель обычно безопаснее копирования всего объекта
Например:
entity_type = project
entity_id = 1842Но если проект впоследствии удалён, уведомление должно корректно обрабатывать:
связанная сущность больше недоступна.Не падать с 500.
Можно хранить небольшой snapshot
Например:
Проект «CRM отдела продаж»сохраняется как часть notification body.
Даже если проект позже переименуют, историческое сообщение остаётся понятным.
Центр уведомлений не должен раскрывать удалённые права
Представим пользователь раньше участвовал в проекте и получил уведомление.
Позже доступ отозвали.
Уведомление всё ещё существует.
Нельзя использовать его entity reference, чтобы снова открыть проект.
Backend при переходе проверяет актуальные permissions.
Telegram-кнопки тоже требуют безопасных ссылок
Если Telegram message содержит:
[Открыть проект]ссылка может вести на:
https://example.ru/projects/1842Но после открытия пользователь должен нормально войти и пройти authorization.
Не использовать постоянную admin-ссылку с секретным токеном.
Один пользователь — несколько получателей
У проекта может быть:
клиент;
администратор;
менеджер;
исполнитель.Событие:
PROJECT_FILE_UPLOADEDне обязательно уведомляет всех.
Notification policy определяет аудиторию.
Хорошо отделить audience resolution
Например:
Event
↓
AudienceResolver
↓
recipient IDs
↓
Notification creation.Это полезно, когда правила становятся сложнее:
только владелец проекта;
все администраторы;
назначенный менеджер;
пользователи с ролью REVIEWER.Не отправляйте уведомление самому инициатору без причины
Если пользователь написал сообщение:
он и так знает, что написал его.Частый баг:
message participants
→ notify allвключая автора.
Получается:
Вы отправили сообщение.
через email, push и Telegram.
Event должен содержать actor
Например:
{
"type": "PROJECT_MESSAGE_CREATED",
"actorUserId": 52,
"projectId": 1842,
"messageId": 9181
}Audience resolver может исключить:
actorUserId.Системное событие actor может не иметь
Например:
PAYMENT_CONFIRMEDинициировано provider.
Тогда:
actor = SYSTEM.Notification preferences нужно применять после определения получателя
Сначала:
кого касается событие?Потом:
какими каналами этот конкретный пользователь хочет его получить?Не наоборот.
Пример полного pipeline
Клиент загрузил файл в проект.
1. File uploaded
2. DB transaction committed
3. PROJECT_FILE_UPLOADED event
4. Notification policy
5. Audience → admin
6. Notification row created
7. Preferences loaded
8. Delivery jobs:
PUSH
TELEGRAM
9. Workers deliver independently
10. Browser receives realtime updateПри этом:
Emailможет быть отключён политикой.
Если Telegram упал
Notification:
существует.
Push:
доставлен.
Telegram:
retry.Ни одно из этих состояний не ломает остальные.
Если пользователь запрещает push
Notification:
существует.
Push:
SKIPPED / NO_SUBSCRIPTION.Это нормальное состояние.
Не ошибка всей системы.
SKIPPED тоже полезный статус
Причины могут быть:
USER_PREFERENCE
NO_DESTINATION
QUIET_HOURS
SUPPRESSED_WHILE_ACTIVE
CHANNEL_DISABLEDТак через полгода можно понять:
Почему уведомление не было отправлено по email?
Не гадать по отсутствующей строке.
Не создавайте delivery, если канал вообще неприменим
Есть два допустимых подхода.
Первый:
создавать delivery со статусом SKIPPED.Даёт хороший аудит.
Второй:
создавать только реальные attempts.Меньше данных.
Для бизнес-критичных продуктов первый вариант часто удобнее.
Для простого приложения второй дешевле.
Notification center должен быть понятным пользователю
Хорошая карточка отвечает на три вопроса:
Что произошло?
Где произошло?
Когда?Например:
Новое сообщение от клиента
Проект №1842 «CRM отдела продаж»
2 минуты назадА не:
Notification #7281
PROJECT_MESSAGE_CREATEDВнутренние коды остаются backend-деталью.
Хорошо группировать визуально
Например:
Сегодня
Вчера
Ранееили по категориям:
Проекты
Оплата
Безопасность
СистемаНо количество категорий не должно превращать центр в сложный почтовый клиент, если продукту это не нужно.
Первое уведомление можно показывать сразу, остальные — по истории
Для некоторых кабинетов удобна компактная модель:
Последнее важное уведомлениевидно непосредственно на dashboard.
Кнопка:
Все уведомленияоткрывает полную историю.
Это позволяет сохранять внимание к свежему событию и не перегружать основной интерфейс.
Badge должен иметь смысл
Например:
🔔 4означает:
4 непрочитанных уведомления.Не:
4 всех уведомления за всё время.Иначе пользователь перестаёт понимать интерфейс.
Когда сбрасывать badge
Можно:
при открытии центра
→ SEENа отдельные записи:
при открытии
→ READ.Либо использовать только READ.
Важно одно:
правило должно быть одинаковым на desktop и mobile.
Email, push и Telegram не должны изменять read_at автоматически
Отправили push:
notification.read_atпо-прежнему:
NULL.Даже если push provider принял сообщение.
Мы не знаем, читал ли его человек.
Если пользователь кликнул push и открыл notification
Вот тогда приложение уже может:
mark read.То есть read state принадлежит бизнес-приложению.
Не транспортному каналу.
Deep links делают уведомление полезнее
Плохой push:
У вас новое сообщение.
Клик открывает:
главную.Пользователь должен искать, где это сообщение.
Лучше:
/project/1842/messages/9181или соответствующий стабильный маршрут.
Но deep link должен уметь деградировать
Если message удалено:
открыть проект.Если проект архивирован:
показать архивную карточку,
если доступ разрешён.Если доступа нет:
корректная страница отказа.Не белый экран.
Отдельное внимание мобильному приложению и PWA
В PWA web push может приходить через service worker даже когда веб-приложение не находится в активной вкладке, если пользователь дал разрешение и платформа поддерживает нужную функциональность.
Но поддержка браузеров и платформ имеет нюансы, поэтому notification system не должна считать:
Web Push = гарантированный канал для каждого устройства.На уровне продукта лучше иметь capability model:
user/device supports push?
subscription active?
permission granted?Только после этого создавать доставку.
Notification permission — состояние устройства, а не просто пользователя
У пользователя:
Desktop:
push allowed.
Phone:
push denied.Поэтому настройка:
Push = ONна account уровне не означает, что каждый device реально способен получать сообщения.
Нужно различать:
user preferenceи:
device capability/subscription.Telegram также является capability
Preference:
Telegram notifications = ONне имеет смысла без:
telegram_connection.UI может показать:
Чтобы получать уведомления в Telegram, сначала подключите бота.
Email destination тоже может меняться
После изменения email нужно решить:
старые queued notifications
отправлять куда?Обычно delivery job должен получить актуальный destination в момент отправки либо зафиксировать адрес при создании — выбор зависит от semantics.
Для security notification об изменении email часто полезно отправить сообщения как на старый, так и на новый адрес по специально продуманному сценарию.
Notification delivery и business transactions не нужно смешивать
Например:
Оплата подтверждена.Даже если:
email не отправился,payment всё равно:
PAID.Нельзя откатывать успешную оплату из-за проблемы notification provider.
Notification — side effect
Критический бизнес-переход:
Payment
PENDING → PAIDсохраняется отдельно.
Notification subsystem сообщает о нём.
Если subsystem сломан:
бизнес-истина не меняется.Это очень важная граница.
Но event нельзя терять
Если оплата стала PAID, а process упал до постановки notification job, пользователь может не получить важное сообщение.
Поэтому снова полезен:
transactional outbox.Exactly once здесь обычно не требуется
Notification worker может получить event повторно.
Главное:
один business event
не должен создавать
два одинаковых notification.То есть нужна идемпотентность.
dedupe_key =
PAYMENT_CONFIRMED:payment_982:user_52Unique constraint предотвращает повтор.
А что если действительно нужны два напоминания
Например:
Платёж ожидаетсясегодня и через три дня.
Это уже два разных notification intents:
PAYMENT_REMINDER_1
PAYMENT_REMINDER_2или разные schedule instances.
Не нужно обходить dedupe случайными ID.
Плановые уведомления
Система может создавать сообщения не только от моментальных событий.
Например:
через 24 часа
напомнить заполнить профиль.или:
за сутки до встречи.Это scheduled notification jobs.
Архитектура доставки остаётся той же:
scheduled trigger
↓
notification intent
↓
policy
↓
channels.Но scheduler не должен напрямую отправлять email
Иначе снова появляются две notification systems:
event-drivenи:
scheduled.Лучше обе приводить к общей pipeline.
Notification id полезно передавать в channel metadata
Если email provider или Telegram adapter возвращает ошибку, можно связать её:
notification_id
delivery_id
request_id
provider_message_id.Теперь production incident расследуется значительно легче.
Correlation ID
Например бизнес-событие:
event_id=evt_123создало:
notification_id=notif_7281delivery:
delivery_id=del_991В логах можно пройти:
evt_123
↓
notif_7281
↓
del_991
↓
provider response.Без correlation всё выглядит как случайные ошибки
Почему клиент не получил письмо?
Система должна уметь ответить конкретно:
Notification created:
yes
Email delivery:
failed
Attempts:
3
Last error:
recipient rejectedЦентр уведомлений полезен и службе поддержки
Пользователь говорит:
Мне ничего не сообщили.
Оператор может увидеть:
notification created at 14:02;
push skipped — permission denied;
email accepted at 14:03;
in-app unread.Это намного полезнее:
В логах вроде что-то было.
Но администратору нельзя показывать лишние секреты
Не нужно выводить:
полный push endpoint;
Telegram bot token;
SMTP credentials;
raw provider authorization headers.Админский audit и секреты — разные вещи.
Notification system должна иметь health
Можно показывать:
Email:
OK
Push:
OK
Telegram:
DEGRADED
Queue:
3 pending
Oldest:
18 secЭто сразу даёт представление о работоспособности подсистемы.
Что делать, если канал отключён глобально
Например Telegram integration временно выключена:
TELEGRAM_ENABLED=false.Notification processor не должен падать.
Delivery может:
SKIPPED_CHANNEL_DISABLED.Остальные каналы продолжают работать.
Feature flags удобны для rollout
Новая push-система сначала включается:
для администраторов;затем:
10% пользователей;затем:
всем.При проблеме её можно выключить, не отключая внутренний notification center.
Как мигрировать со старой системы
Допустим старый backend уже содержит:
sendTelegramNewBrief()
sendEmailNewBrief()
sendEmailProjectStatus()
sendTelegramProjectStatus()Не обязательно переписывать всё одновременно.
Можно начать с одного события:
PROJECT_CREATEDи пропустить его через новую pipeline.
Потом:
PROJECT_MESSAGE_CREATED.Постепенно старые прямые send-функции исчезают.
Самая опасная фаза — двойная отправка
Например новый notification handler уже отправляет email.
А старый код ещё вызывает:
sendProjectEmail().Пользователь получает два одинаковых письма.
На этапе миграции полезны integration tests и временная telemetry:
notification source:
legacy
new_pipeline.Как тестировать систему уведомлений
Happy path:
event
→ notification
→ emailнедостаточен.
Нужно проверять и сбои.
Например business event выполняется дважды:
ожидаем одну notification.Email provider отвечает timeout:
ожидаем retry,
а не потерю notification.Push subscription expired:
ожидаем disable subscription.Telegram connection invalid:
остальные каналы продолжают работать.Пользователь выключил email:
in-app остаётся.Пользователь выключил всё optional:
mandatory security event всё равно использует предусмотренный обязательный канал.Нужно тестировать массовые события
Например:
10 000 notificationsсоздаются за короткое время.
Проверяем:
queue latency;
database indexes;
worker throughput;
provider rate limits;
aggregation.Тест после worker crash
Сценарий:
provider accepted email
↓
worker crashed
до status update.После retry система может отправить письмо второй раз.
Полностью исключить такие edge cases бывает непросто.
Поэтому полезно использовать:
provider idempotency,если сервис поддерживает, либо проектировать пользовательские уведомления так, чтобы редкий duplicate был безопаснее потерянного критического сообщения.
Внутреннее создание notification при этом можно сделать строго идемпотентным
База данных контролируется нами.
UNIQUE(event_id, recipient_id, notification_type)даёт намного более сильную гарантию.
Не все уведомления требуют гарантии одинакового уровня
Например потеря:
«Ваш еженедельный отчёт готов»неприятна.
Потеря:
«Ваш пароль был изменён»намного серьёзнее.
Поэтому notification types могут иметь разный delivery SLA.
| Тип | SLA |
|---|---|
| Маркетинговое | best effort |
| Обычное проектное | несколько минут |
| Сообщение клиента | секунды |
| Security | высокий приоритет + контролируемый retry |
Архитектура становится экономнее, чем если всё обрабатывать как аварийный alert.
Telegram для администратора и Telegram для клиента — разные продукты
Операционный Telegram-канал для одного администратора можно настроить относительно просто.
Пользовательская Telegram-интеграция требует:
account linking;
preferences;
disconnect flow;
multi-user mapping;
privacy;
support.Поэтому требование:
Добавьте Telegram уведомления.
может иметь совершенно разную стоимость в зависимости от аудитории.
Email тоже может быть простым или сложным
Отправлять:
5 transactional emails в деньи:
500 000 уведомлений— разные архитектуры.
При большом объёме появляются:
provider reputation;
bounce processing;
suppression lists;
rate limits;
domain authentication;
delivery analytics.Не нужно строить массовую email-platform для небольшого B2B-продукта заранее.
Система должна масштабироваться постепенно
На первом этапе:
PostgreSQL
+
background jobs
+
email adapter
+
in-app notifications.Потом:
Web Push.Затем:
Telegram.При росте:
несколько worker pools;
batching;
rate limiting.Не обязательно начинать с отдельного Kafka-кластера.
Что должен видеть пользователь в настройках
Хороший интерфейс может быть очень простым:
Уведомления
Сообщения по проектам
Email [✓]
Push [✓]
Telegram [✓]
Изменения статуса
Email [✓]
Push [✓]
Telegram [ ]
Файлы
Email [ ]
Push [✓]
Telegram [ ]
Новости продукта
Email [ ]
Push [ ]
Безопасность
Email ОбязательноЧеловеку понятно:
что;
куда;
можно ли отключить.Не заставляйте пользователя понимать технические каналы
Например:
FCM endpointили:
VAPIDне должны появляться в пользовательском интерфейсе.
Пользователь выбирает:
Push-уведомления.Технические детали принадлежат backend.
Production-ready схема
В итоге notification subsystem может выглядеть так:
Business action
│
▼
Domain event
│
▼
Transactional outbox
│
▼
Notification processor
│
┌────────┴────────┐
▼ ▼
Audience resolver Notification policy
│ │
└────────┬────────┘
▼
Notification
stored in DB
│
┌──────────┼──────────┬─────────┐
▼ ▼ ▼ ▼
In-app Email Push Telegram
│ │ │ │
│ worker worker worker
│ │ │ │
└──────────┴──────────┴─────────┘
│
▼
Delivery statusА для online-пользователя дополнительно:
Notification committed
↓
Realtime event
↓
WebSocket/SSE
↓
Browser updates badgeЧто происходит, если всё ломается сразу
Допустим:
Telegram unavailable;
email provider timeout;
push subscription invalid.Само notification:
всё ещё находится во внутреннем центре.Это и показывает ценность durable notification core.
Внешние каналы могут деградировать независимо.
Основная информация не исчезает.
И обратная ситуация
Внутренний realtime channel временно не работает.
Email и Telegram всё равно могут дойти.
То есть многоканальность может повышать устойчивость — если используется осознанно, а не просто создаёт четыре одинаковых сообщения.
Главное правило многоканальности
Не:
Чем больше каналов, тем лучше.
А:
каждый канал должен иметь понятную причину существования.
Например:
In-app
→ история и источник истины уведомлений.
Push
→ быстро вернуть внимание.
Email
→ важное долговечное сообщение.
Telegram
→ удобный opt-in внешний канал.
WebSocket
→ мгновенно показать новое уведомление online-пользователю.Когда роли понятны, каналы дополняют друг друга.
Когда нет — начинают дублировать.
Практический production checklist
Перед запуском системы стоит проверить:
- Бизнес-код создаёт события, а не напрямую знает обо всех каналах.
- Внутренние уведомления сохраняются durable в базе.
- Notification и delivery разделены.
- Email, push и Telegram имеют независимые workers и статусы.
- Повторная обработка одного event не создаёт дубли уведомлений.
- Пользовательские preferences применяются централизованно.
- Mandatory security notifications нельзя случайно отключить обычной настройкой.
- Push subscription хранится отдельно для каждого устройства и умеет инвалидироваться.
- Telegram подключается явно и может быть отключён.
- Секреты Telegram, email-provider и push infrastructure не попадают во frontend и обычные логи.
- Email/push/Telegram failure не откатывает основную бизнес-операцию.
- Temporary failures получают retry с backoff.
- Permanent failures не retry бесконечно.
- Есть aggregation/rate limiting для шумных событий.
- Sensitive данные не выводятся во внешние каналы без необходимости.
- Deep links всегда проходят обычную authentication/authorization.
- Badge непрочитанных считается на backend.
- Realtime delivery не является единственным хранилищем уведомления.
- Есть мониторинг очередей, failures и oldest pending age.
- Пользователь может понять, какие уведомления и через какие каналы он получает.
Как выбрать канал для конкретного события
Хороший вопрос звучит не:
Можем ли мы отправить push?
А:
Зачем пользователю знать это событие и насколько быстро?
Если информация нужна прямо сейчас:
push / realtime.Если её важно сохранить:
email + in-app.Если речь об операционной работе администратора:
Telegram может быть очень удобен.Если событие не требует внешнего внимания:
in-app достаточно.Не каждое уведомление должно отвлекать
Это, возможно, главный продуктовый вывод.
Notification center может содержать:
50 полезных событий.Но превращать каждое из них в:
push;
email;
Telegramне нужно.
Хорошая система управляет не количеством сообщений.
Она управляет вниманием пользователя.
Например клиентский проект
Событие:
Проект принят в работу.Можно:
in-app + email.Событие:
Администратор написал сообщение.Можно:
in-app + push.Событие:
В проект загружен новый служебный файл.Возможно:
in-app only.Событие:
Запрос изменений оценён и ждёт решения клиента.Уже:
in-app + email + push.Потому что от пользователя требуется действие.
Action-required — полезная категория
Некоторые уведомления просто сообщают:
INFO.Другие требуют:
ACTION.Например:
«Проект завершён»и:
«Подтвердите оценку изменений».не должны выглядеть одинаково.
Внутренний центр может показывать действие
Например:
Оценка изменения готова
+3 дня · +35 000 ₽
[Принять]
[Отклонить]Но сами кнопки должны выполнять обычные безопасные backend-команды.
Notification не хранит бизнес-истину.
После выполнения действия уведомление можно изменить
Например:
ACTION_REQUIRED
↓
ACTION_COMPLETEDили оставить исторически:
Вы приняли оценку 9 октября.
Это уже продуктовый выбор.
Уведомления — часть интерфейса бизнес-процесса
Хорошая notification system не просто сообщает:
Что-то произошло.
Она помогает понять:
что произошло;
почему это важно;
нужно ли действие;
куда перейти.Поэтому хороший текст уведомления часто важнее красивой анимации колокольчика.
Плохой текст
Статус изменён.
Какой статус?
Чего?
Что делать?
Хороший
Проект №1842 «CRM отдела продаж» перешёл на этап «Проверка». Посмотрите текущую версию и оставьте комментарий, если нужны изменения.
Теперь уведомление выполняет задачу.
Система уведомлений — часть продукта, а не декоративная функция
Когда она спроектирована плохо, пользователь получает:
дубли;
спам;
пропущенные важные события;
непонятные badge;
письма не по теме.Когда хорошо:
важное появляется быстро;
история сохраняется;
каналы можно настроить;
сообщения не дублируются;
из уведомления понятно следующее действие.Вместо вывода
Email, Web Push, Telegram и внутренний центр уведомлений не нужно проектировать как четыре независимые функции.
Правильнее начать с общей модели:
Что произошло?
↓
Кого это касается?
↓
Нужно ли вообще уведомление?
↓
Насколько оно важно?
↓
Нужно ли от пользователя действие?
↓
Какими каналами его доставить?А уже затем:
In-app
Email
Push
Telegramстановятся адаптерами доставки.
В такой системе внутренний центр уведомлений играет роль надёжной истории и контролируемого источника пользовательских уведомлений.
Email подходит для важных сообщений, которые удобно сохранить и к которым пользователь может вернуться позже.
Web Push помогает быстро вернуть внимание пользователя, даже когда активная вкладка приложения не находится перед ним, но зависит от разрешения и действующей push-подписки.
Telegram является удобным opt-in каналом для пользователей и особенно для оперативной работы администраторов, но требует явного подключения и не должен заменять историю внутри продукта.
А WebSocket или SSE могут мгновенно показать уже созданное внутреннее уведомление пользователю, который находится online.
В итоге production-архитектура выглядит так:
Business event
↓
Notification
↓
Persistent history
↓
Delivery policy
↓
┌─────────┬─────────┬─────────┐
│ Email │ Push │Telegram │
└─────────┴─────────┴─────────┘
↓
Delivery statusИменно такое разделение позволяет системе расти.
Сначала у продукта может быть только:
in-app + email.Позже добавляется:
push.Потом:
Telegram.Но бизнес-код не приходится переписывать каждый раз, потому что он по-прежнему говорит только:
Произошло событие, о котором нужно уведомить пользователя.
А notification subsystem сама решает, как именно доставить это внимание человеку — надёжно, без дублей и без лишнего шума.