Пошук уроків, статей та іншого контенту
Спроєктуєте взаємодію компонентів через асинхронні події та визначите межі відповідальності сервісів.
У подієво-керованій архітектурі компоненти взаємодіють через події — повідомлення про факт, який уже відбувся.
Наприклад:
замовлення створено;
платіж підтверджено;
користувача зареєстровано;
товар зарезервовано.
Компонент, який володіє даними та бізнес-правилом, публікує подію. Інші компоненти підписуються на цю подію й виконують власну роботу незалежно від джерела.
OrderService
|
| OrderCreated
v
Event Bus
|
+--> NotificationService
+--> AnalyticsService
+--> BillingServiceСервіс замовлень не повинен напряму знати, як надсилаються листи або як оновлюється аналітика. Його відповідальність — створити замовлення та повідомити про результат.
Важливо розрізняти команду й подію.
Команда описує бажану дію:
CreateOrder
SendWelcomeEmail
CancelPaymentКоманда зазвичай має одного виконавця. Вона відповідає на питання:
Що потрібно зробити?
Подія описує факт, який уже відбувся:
OrderCreated
WelcomeEmailSent
PaymentCancelledПодію можуть обробляти декілька незалежних слухачів. Вона відповідає на питання:
Що вже сталося?
Назви подій варто формулювати в минулому часі. Це допомагає не плутати подію з командою.
OrderCreated // добре: замовлення створено
CreateOrder // це команда, а не подіяПеред реалізацією подій потрібно визначити, який компонент володіє конкретним правилом або даними.
Розглянемо процес створення замовлення:
OrderService створює замовлення.
OrderService публікує OrderCreated.
NotificationService надсилає підтвердження клієнту.
AnalyticsService записує подію для статистики.
Відповідальність розподілена так:
OrderService відповідає за стан і правила замовлення;
NotificationService відповідає за повідомлення;
AnalyticsService відповідає за аналітичні записи.
OrderService не має викликати NotificationService напряму:
await this.notificationService.sendOrderConfirmation(order);Такий код створює жорстку залежність між сервісами. Якщо спосіб надсилання повідомлень зміниться, доведеться змінювати сервіс замовлень.
Краще опублікувати факт:
this.eventEmitter.emit('order.created', event);Тепер сервіс замовлень знає лише про подію, але не знає, хто її обробляє.
Для подій усередині одного NestJS-процесу можна використати пакет @nestjs/event-emitter.
Встановлення:
npm install @nestjs/event-emitterПідключення модуля:
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [EventEmitterModule.forRoot()],
})
export class AppModule {}Подію можна опублікувати через EventEmitter2, а обробник оголосити декоратором @OnEvent.
Нижче наведено спрощований, але працездатний приклад. Сервіс замовлень створює замовлення, після чого публікує подію. Сервіс сповіщень підписаний на цю подію та асинхронно імітує надсилання листа.
import { randomUUID } from 'node:crypto';
import {
Body,
Controller,
Injectable,
Module,
Post,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
import { OnEvent } from '@nestjs/event-emitter';
const ORDER_CREATED = 'order.created';
class CreateOrderDto {
productId!: string;
email!: string;
}
class Order {
constructor(
readonly id: string,
readonly productId: string,
readonly email: string,
readonly createdAt: Date,
) {}
}
class OrderCreatedEvent {
constructor(
readonly eventId: string,
readonly orderId: string,
readonly productId: string,
readonly email: string,
readonly occurredAt: Date,
) {}
}
@Injectable()
class OrdersService {
private readonly orders = new Map<string, Order>();
constructor(private readonly eventEmitter: EventEmitter2) {}
createOrder(input: CreateOrderDto): Order {
const order = new Order(
randomUUID(),
input.productId,
input.email,
new Date(),
);
this.orders.set(order.id, order);
const event = new OrderCreatedEvent(
randomUUID(),
order.id,
order.productId,
order.email,
new Date(),
);
this.eventEmitter.emit(ORDER_CREATED, event);
return order;
}
}
@Injectable()
class NotificationsService {
@OnEvent(ORDER_CREATED, { async: true })
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`Надіслано підтвердження замовлення ${event.orderId} на ${event.email}`,
);
}
}
@Controller('orders')
class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
create(@Body() input: CreateOrderDto): Order {
return this.ordersService.createOrder(input);
}
}
@Module({
imports: [EventEmitterModule.forRoot()],
controllers: [OrdersController],
providers: [OrdersService, NotificationsService],
})
class AppModule {}
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
void bootstrap();Після запуску застосунку можна створити замовлення:
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"productId":"book-42","email":"user@example.com"}'HTTP-відповідь повертається після створення замовлення. Обробник NotificationsService виконується асинхронно окремо від основного сценарію.
Опція { async: true } вказує NestJS виконувати обробник асинхронно:
@OnEvent('order.created', { async: true })
async handle(event: OrderCreatedEvent): Promise<void> {
// асинхронна обробка
}Це корисно, коли обробка:
виконує мережевий запит;
взаємодіє із зовнішнім сервісом;
надсилає електронну пошту;
записує дані в інше сховище;
не повинна затримувати HTTP-відповідь.
Водночас важливо розуміти обмеження: EventEmitter2 у цьому прикладі працює в пам’яті процесу. Це не черга повідомлень і не довговічне сховище.
Якщо процес завершиться після публікації події, але до її обробки, подія може бути втрачена.
Подія має містити лише дані, необхідні споживачам для виконання власної роботи.
Поганий варіант:
class OrderCreatedEvent {
constructor(readonly order: Order) {}
}У цьому випадку споживач отримує весь внутрішній об’єкт замовлення та починає залежати від його структури.
Краще використовувати окремий контракт події:
class OrderCreatedEvent {
constructor(
readonly eventId: string,
readonly orderId: string,
readonly productId: string,
readonly email: string,
readonly occurredAt: Date,
) {}
}Переваги такого підходу:
внутрішня модель замовлення не стає частиною контракту;
склад події контролюється явно;
споживачі отримують лише потрібні дані;
подію простіше версіонувати.
Корисними полями події є:
eventId — унікальний ідентифікатор події;
ідентифікатор сутності, наприклад orderId;
occurredAt — час виникнення події;
значення, необхідні для обробки.
Подія повідомляє про результат, але не вказує іншим сервісам, що саме вони мають робити.
class OrderCreatedEvent {
// добре: факт створення замовлення
}Не варто додавати до події такі поля:
class OrderCreatedEvent {
sendEmail = true;
updateAnalytics = true;
}Це перетворює подію на приховану команду та створює залежність між видавцем і споживачами.
Сервіс сповіщень сам вирішує, що робити після OrderCreated. Аналітичний сервіс також самостійно визначає свою реакцію на цю подію.
Асинхронна обробка може бути повторена. Наприклад, система може повторно доставити подію після тимчасової помилки.
Тому обробники бажано робити ідемпотентними: повторна обробка тієї самої події не повинна створювати неправильний результат.
Проблемний приклад:
@OnEvent('order.created', { async: true })
async handle(event: OrderCreatedEvent): Promise<void> {
await this.emailService.send(event.email);
}Якщо обробник запуститься двічі, користувач може отримати два листи.
Для захисту обробник може зберігати факт обробки за eventId у надійному сховищі:
@OnEvent('order.created', { async: true })
async handle(event: OrderCreatedEvent): Promise<void> {
const alreadyProcessed = await this.processedEvents.exists(event.eventId);
if (alreadyProcessed) {
return;
}
await this.emailService.sendOrderConfirmation(event.email, event.orderId);
await this.processedEvents.save(event.eventId);
}У реальній системі перевірка та запис мають бути узгоджені зі способом виконання бізнес-операції. Простий набір у пам’яті не підходить для кількох екземплярів застосунку або після перезапуску.
Розглянемо послідовність:
await this.ordersRepository.save(order);
this.eventEmitter.emit('order.created', event);Між збереженням замовлення та публікацією події може статися помилка. Наприклад:
замовлення вже записане в базу;
застосунок аварійно завершився;
подія не була опублікована;
сервіс сповіщень не дізнався про замовлення.
Для критичних сценаріїв подію зберігають разом із бізнес-даними в межах однієї транзакції. Окремий процес або компонент згодом публікує збережені події.
Такий підхід називають transactional outbox. Його основна ідея:
змінити бізнес-дані;
записати подію в таблицю outbox у тій самій транзакції;
після успішної транзакції доставити подію споживачам;
позначити подію як опрацьовану.
Для локальних некритичних подій достатньо EventEmitterModule. Для платежів, замовлень та інших важливих процесів потрібно окремо визначити вимоги до доставки, повторів і втрати повідомлень.
Події всередині одного NestJS-застосунку зручні для розділення модулів у межах одного процесу:
OrdersModule -> EventEmitter2 -> NotificationsModuleЦе хороший варіант, коли:
компоненти розгортаються разом;
втрата події допустима або контрольована;
не потрібна довговічна черга;
взаємодія відбувається в одному процесі.
Якщо компоненти працюють у різних процесах або подія має переживати перезапуск застосунку, потрібен зовнішній надійний механізм доставки. У такій ситуації контракт події слід розглядати як публічний контракт між компонентами, а не як внутрішній TypeScript-клас.
await this.ordersService.create();
await this.notificationsService.send();
await this.analyticsService.track();Такий код зв’язує всі компоненти в один сценарій.
Краще:
await this.ordersService.create();А після успішного створення сервіс публікує подію, на яку незалежно реагують інші компоненти.
Погано:
'order'
'process'
'event1'Добре:
'order.created'
'order.cancelled'
'payment.confirmed'Назва має описувати конкретний факт.
this.eventEmitter.emit('order.created', event);
await this.ordersRepository.save(order);Обробник може запуститися раніше, ніж замовлення стане доступним у сховищі.
Спочатку потрібно успішно виконати основну операцію, а потім публікувати подію. Для критичних сценаріїв слід використовувати транзакційне збереження події.
Передавання ORM-сутності або внутрішнього доменного об’єкта прив’язує споживачів до реалізації видавця.
Краще створювати окремий об’єкт події з явним контрактом.
Подія не гарантує, що всі слухачі вже завершили роботу в момент повернення HTTP-відповіді.
Якщо клієнту потрібен результат обробки, це має бути частиною окремого синхронного сценарію або клієнт повинен отримати можливість перевірити стан пізніше.
Повторна доставка — нормальна властивість надійних асинхронних систем. Обробник має коректно поводитися під час повторного отримання тієї самої події.
Подія описує факт, який уже відбувся.
Команда описує дію, яку потрібно виконати.
Видавець події не повинен знати про конкретних споживачів.
Кожен сервіс має володіти власними даними та бізнес-правилами.
У NestJS локальні події можна реалізувати через EventEmitterModule і @OnEvent.
Асинхронний обробник не робить подію довговічною: події в пам’яті можуть бути втрачені.
Події мають містити стабільний і явний контракт, а не внутрішні об’єкти сервісу.
Обробники слід проєктувати ідемпотентними.
Для критичних операцій потрібно узгодити транзакцію з доставкою події, наприклад через transactional outbox.