Notifications Emitters
Возьмём пример из feeldown-backend (ссылка вне зоны доступа).
В проекте есть система уведомлений, построенная на иерархии эмиттеров. Базовый класс содержит общую логику, а конкретные эмиттеры расширяют его для разных типов уведомлений. Это идеальный пример OCP через наследование — добавление нового типа уведомлений не требует изменения существующего кода.
Код
Базовый класс: BaseNotificationsEmitter
import type { Notification, NotificationCreateType } from "@1/types";
import { Injectable } from "@nestjs/common";
import { NOTIFICATIONS_ERRORS } from "@1/errors";
import { PrismaService } from "@/database";
@Injectable()
export abstract class BaseNotificationsEmitter {
public constructor(protected readonly prisma: PrismaService) {}
protected create(data: NotificationCreateType): Promise<Notification> {
if (data.actorId === data.recipientId) {
throw NOTIFICATIONS_ERRORS.CANNOT_NOTIFY_SELF.exception;
}
return this.prisma.notification.create({ data });
}
protected delete(id: string): Promise<Notification> {
return this.prisma.notification.delete({ where: { id } });
}
}Что здесь есть:
- Абстрактный класс (
abstract) — его нельзя инстанциировать напрямую. - Общие методы
createиdelete— вся логика создания/удаления уведомлений в одном месте. - Проверка
actorId !== recipientId— защита от уведомления самого себя. - Зависимость от
PrismaServiceвнедряется через конструктор.
Единственная ответственность: управление созданием и удалением уведомлений в БД.
Конкретный эмиттер: CommentNotificationsEmitter
import type { Comment, Post } from "@1/types";
import { NotificationType, ReferenceType } from "@1/types";
import { BaseNotificationsEmitter } from "./base-notifications.emitter";
import { Injectable } from "@nestjs/common";
export type ActionToType = {
create: Post;
reply: Comment;
};
export type CommentAction = keyof ActionToType;
@Injectable()
export class CommentNotificationsEmitter extends BaseNotificationsEmitter {
public execute<Action extends CommentAction>(
comment: Comment,
parent: ActionToType[Action],
) {
return this.emit<Action>(comment, parent);
}
private emit<Action extends CommentAction>(
comment: Comment,
parent: ActionToType[Action],
) {
return this.create({
...this.getTypes(parent),
actorId: comment.userId,
recipientId: parent.userId,
referenceId: parent.id,
});
}
private getTypes(parent: ActionToType[CommentAction]) {
if ("postname" in parent) {
return {
referenceType: ReferenceType.POST,
type: NotificationType.COMMENT_POST,
};
}
return {
referenceType: ReferenceType.COMMENT,
type: NotificationType.REPLY_COMMENT,
};
}
}Что здесь происходит:
- Наследуется от
BaseNotificationsEmitter. - Добавляет метод
execute, который принимает комментарий и родительскую сущность (пост или другой комментарий). - Метод
getTypesопределяет тип уведомления на основе того, является ли родитель постом или комментарием. - Использует
this.create()из базового класса для сохранения в БД.
Конкретный эмиттер: ReactionNotificationsEmitter
import type { Comment, CommentReaction, Post, PostReaction } from "@1/types";
import { NotificationType, ReferenceType } from "@1/types";
import { BaseNotificationsEmitter } from "./base-notifications.emitter";
import { Injectable } from "@nestjs/common";
export type TargetToType = {
post: {
target: Post;
reaction: PostReaction;
};
comment: {
target: Comment;
reaction: CommentReaction;
};
};
export type ReactionTarget = keyof TargetToType;
export const TARGET_TO_TYPE: Record<ReactionTarget, NotificationType> = {
comment: NotificationType.REACT_COMMENT,
post: NotificationType.REACT_POST,
};
@Injectable()
export class ReactionNotificationsEmitter extends BaseNotificationsEmitter {
public execute<Target extends ReactionTarget>(
data: TargetToType[Target],
react: ReactionTarget,
) {
return this.emit(data, react);
}
private emit<Target extends ReactionTarget>(
{ reaction, target }: TargetToType[Target],
react: ReactionTarget,
) {
return this.create({
actorId: reaction.userId,
recipientId: target.userId,
referenceId: target.id,
referenceType: ReferenceType.COMMENT,
type: TARGET_TO_TYPE[react],
});
}
}Что здесь происходит:
- Наследуется от
BaseNotificationsEmitter. - Добавляет метод
execute, который принимает реакцию и цель (пост или комментарий). - Использует маппинг
TARGET_TO_TYPEдля определения типа уведомления. - Снова использует
this.create()из базового класса.
Конкретный эмиттер: FriendshipNotificationsEmitter
import type { FriendRequest, FriendRequestStatus } from "@1/types";
import { NotificationType, ReferenceType } from "@1/types";
import { Injectable } from "@nestjs/common";
import { BaseNotificationsEmitter } from "./base-notifications.emitter";
import {
FRIENDSHIP_NOTIFICATIONS_ERRORS,
FRIENDSHIP_NOTIFICATIONS_SERVER_ERRORS,
} from "@1/errors";
const STATUS_TO_TYPE: Record<FriendRequestStatus, NotificationType | null> = {
ACCEPTED: NotificationType.FRIEND_ACCEPT,
PENDING: NotificationType.FRIEND_REQUEST,
REJECTED: null,
};
@Injectable()
export class FriendshipNotificationsEmitter extends BaseNotificationsEmitter {
public execute(request: FriendRequest, status: FriendRequestStatus) {
if (request.status !== status) {
throw FRIENDSHIP_NOTIFICATIONS_ERRORS.REQUEST_STATUS_MISMATCH.execute({
requestStatus: request.status,
status,
});
}
const type = STATUS_TO_TYPE[status];
if (!type) {
throw FRIENDSHIP_NOTIFICATIONS_SERVER_ERRORS.BAD_STATUS_TYPE.exception;
}
return this.emit(request, type);
}
private emit(request: FriendRequest, type: NotificationType) {
return this.create({
actorId: request.senderId,
recipientId: request.receiverId,
referenceType: ReferenceType.FRIENDS,
referenceId: request.id,
type,
});
}
}Что здесь происходит:
- Наследуется от
BaseNotificationsEmitter. - Добавляет метод
execute, который принимает запрос в друзья и новый статус. - Проверяет, что статус запроса соответствует переданному статусу.
- Использует маппинг
STATUS_TO_TYPEдля определения типа уведомления. - Валидирует, что для статуса есть уведомление (например, для
REJECTEDуведомление не отправляется). - Снова использует
this.create()из базового класса.
Что здесь хорошо с точки зрения OCP?
1. Базовый класс закрыт для изменений
BaseNotificationsEmitter содержит только общую логику:
- Создание уведомления в БД.
- Удаление уведомления из БД.
- Проверка на уведомление самого себя.
Эти методы не меняются при добавлении новых типов уведомлений.
2. Конкретные эмиттеры открыты для расширения
Каждый новый тип уведомления — это новый класс, наследующий
BaseNotificationsEmitter:
@Injectable()
export class SystemNotificationEmitter extends BaseNotificationsEmitter {
public execute(userId: string, message: string) {
return this.create({
actorId: null, // системное уведомление
recipientId: userId,
referenceType: ReferenceType.SYSTEM,
referenceId: uuid(),
type: NotificationType.SYSTEM_MESSAGE,
});
}
}Что мы не трогаем:
BaseNotificationsEmitter— остаётся без изменений.- Другие эмиттеры (
CommentNotificationsEmitter,ReactionNotificationsEmitterи т.д.) — остаются без изменений. - Существующий код, который использует эмиттеры — остаётся без изменений.
3. Единообразие через общий интерфейс
Все эмиттеры имеют метод execute (или подобный), который принимает данные и
создаёт уведомление. Это упрощает использование:
// В любом месте кода
await commentEmitter.execute(comment, post);
await reactionEmitter.execute({ reaction, target }, "post");
await friendshipEmitter.execute(request, FriendRequestStatus.ACCEPTED);4. Каждый эмиттер отвечает за свою логику
CommentNotificationsEmitter— знает, как различать комментарий к посту и ответ на комментарий.ReactionNotificationsEmitter— знает, как различать реакцию на пост и реакцию на комментарий.FriendshipNotificationsEmitter— знает, какие статусы отправляют уведомления, а какие нет.
Ни один эмиттер не знает о логике других. Это также соблюдает SRP.
5. Лёгкое тестирование
Каждый эмиттер тестируется изолированно:
// Тест для CommentNotificationsEmitter
it('should create notification for comment on post', async () => {
const comment = { userId: 'user1', ... };
const post = { userId: 'user2', ... };
await emitter.execute(comment, post);
expect(prisma.notification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
actorId: 'user1',
recipientId: 'user2',
type: NotificationType.COMMENT_POST,
}),
});
});Что можно было бы улучшить?
Несмотря на то что текущая реализация хорошо соблюдает OCP, есть пара моментов, на которые стоит обратить внимание.
1. getTypes в CommentNotificationsEmitter — проверка через in
private getTypes(parent: ActionToType[CommentAction]) {
if ("postname" in parent) {
return {
referenceType: ReferenceType.POST,
type: NotificationType.COMMENT_POST,
};
}
return {
referenceType: ReferenceType.COMMENT,
type: NotificationType.REPLY_COMMENT,
};
}Сейчас используется проверка "postname" in parent. Это работает, потому что у
модели Post есть поле postname, а у Comment его нет. Но это неявное
знание о структуре моделей.
Почему это не идеально:
- Если в будущем у
Commentпоявится полеpostname(маловероятно, но возможно), проверка сломается. - Код полагается на детали реализации моделей, а не на явное различие.
Альтернатива (если захочется сделать более явным):
type Parent =
| { kind: 'post'; data: Post }
| { kind: 'comment'; data: Comment };
private getTypes(parent: Parent) {
if (parent.kind === 'post') {
return {
referenceType: ReferenceType.POST,
type: NotificationType.COMMENT_POST,
};
}
return {
referenceType: ReferenceType.COMMENT,
type: NotificationType.REPLY_COMMENT,
};
}В текущей предметной области это допустимо, но если модель изменится (например,
у Comment появится поле postname), стоит перейти на дискриминирующий юнион.
Это снимет риск и сделает код более явным
Вывод: проверка через
in— это ok, но если в будущем модельная логика изменится, стоит перейти на дискриминирующий юнион.
2. Маппинг статусов в FriendshipNotificationsEmitter
const STATUS_TO_TYPE: Record<FriendRequestStatus, NotificationType | null> = {
ACCEPTED: NotificationType.FRIEND_ACCEPT,
PENDING: NotificationType.FRIEND_REQUEST,
REJECTED: null,
};Это статический объект-маппинг. Если появится новый статус (например,
BLOCKED), его нужно будет добавить сюда.
Но это не нарушение OCP!
Почему:
- Это конфигурация, а не бизнес-логика.
- Добавление новой записи в объект — это расширение, а не изменение существующего поведения.
- Класс
FriendshipNotificationsEmitterне требует правки — он просто читает изSTATUS_TO_TYPE. - Точно такой же подход используется в
StrategiesServiceсoauth2Services, и мы назвали это хорошим примером OCP.
Когда это стало бы нарушением:
- Если бы внутри метода
executeбыла цепочкаif/elseпо статусам. - Если бы для добавления нового статуса нужно было менять логику метода.
В текущей реализации достаточно просто добавить запись в STATUS_TO_TYPE — и
всё заработает. Это открытость для расширения через конфигурацию.
Вывод: статический маппинг — это нормальный и даже предпочтительный способ, если он не требует изменения логики при добавлении новых значений.
3. (Реальное улучшение) Общий интерфейс для эмиттеров
Сейчас каждый эмиттер имеет свой метод execute с разной сигнатурой:
// CommentNotificationsEmitter
public execute(comment: Comment, parent: Post | Comment): Promise<Notification>
// ReactionNotificationsEmitter
public execute(data: ReactionData, react: ReactionTarget): Promise<Notification>
// FriendshipNotificationsEmitter
public execute(request: FriendRequest, status: FriendRequestStatus): Promise<Notification>Это не нарушает OCP, но усложняет использование в обобщённом коде. Если бы
мы захотели вызвать все эмиттеры из одного места, пришлось бы использовать any
или условные операторы.
Можно сделать (если нужно):
interface NotificationEmitter<T> {
execute(data: T): Promise<Notification>;
}Тогда каждый эмиттер реализует свой тип данных. Но в текущей архитектуре это избыточно — каждый эмиттер используется в своём контексте.
Вывод: общий интерфейс не обязателен, если каждый эмиттер живёт в своём домене. Это вопрос дизайна, а не нарушения OCP.
Основные выводы по улучшениям:
- Проверка через
in— прагматичное решение, которое работает и не требует усложнения. - Статический маппинг — это не нарушение OCP, а пример хорошей конфигурации.
- Общий интерфейс — может быть полезен, но не обязателен для соблюдения OCP.
Итог
Папка notifications/emitters — это пример хорошо спроектированной системы,
где:
- OCP соблюдён — базовый класс закрыт для изменений, конкретные эмиттеры открыты для расширения.
- SRP соблюдён — каждый класс отвечает за свою зону ответственности.
- DRY соблюдён — общая логика вынесена в базовый класс.
- Легко тестировать — каждый эмиттер тестируется изолированно.
- Легко расширять — добавление нового типа уведомления требует только создания нового класса.
Запомните: Когда вы видите иерархию классов, где базовый класс содержит общую логику, а наследники — специфичную, это часто признак хорошего OCP. Главное — чтобы базовый класс не менялся при добавлении новых наследников.
Исходный код доступен в репозитории feeldown-backend.