Пошук уроків, статей та іншого контенту
Моделюватимете події предметної області, їхні дані та правила публікації в межах доменної моделі.
Доменна подія — це незмінний факт, який уже відбувся в предметній області.
Назва події зазвичай формулюється в минулому часі:
OrderPlaced — замовлення створено;
OrderConfirmed — замовлення підтверджено;
PaymentReceived — оплату отримано;
UserRegistered — користувача зареєстровано.
Подія описує не команду і не майбутню дію:
команда: «підтвердити замовлення»;
подія: «замовлення підтверджено».
Ця відмінність важлива, тому що подія не повинна змінюватися після публікації. Вона є частиною історії предметної області.
Доменна модель не повинна знати про NestJS, EventBus, контролери або базу даних.
Зазвичай відповідальності розподіляються так:
Агрегат перевіряє бізнес-правила та фіксує події.
Репозиторій зберігає змінений агрегат.
Application service отримує незапубліковані події.
Механізм публікації передає їх обробникам.
Обробники подій виконують реакції на факт, який відбувся.
Отже, агрегат не повинен робити таке:
this.eventBus.publish(new OrderConfirmed(...));Замість цього він лише реєструє подію:
this.record(new OrderConfirmed(...));Публікація відбувається за межами доменної моделі.
Для більшості подій корисно мати спільні технічні поля:
eventId — унікальний ідентифікатор події;
aggregateId — ідентифікатор агрегату;
occurredAt — час виникнення події;
aggregateVersion — версія агрегату на момент події;
бізнес-дані, необхідні споживачам події.
Приклад:
export class OrderConfirmed {
constructor(
public readonly eventId: string,
public readonly aggregateId: string,
public readonly occurredAt: string,
public readonly aggregateVersion: number,
public readonly customerId: string,
public readonly totalCents: number,
) {}
}Подія повинна містити достатньо даних для обробки, але не повинна передавати весь внутрішній стан агрегату без потреби.
Використовуйте:
readonly для властивостей;
унікальний ідентифікатор події;
серіалізований формат часу, наприклад ISO-рядок;
прості значення або окремі об'єкти-значення.
Не змінюйте опубліковану подію:
event.totalCents = 5000; // помилка проєктуванняЯкщо структура події більше не підходить споживачам, краще створити нову версію або нову подію, а не змінювати стару семантику.
Агрегат відповідає за дві речі:
перевіряє інваріанти;
створює події, якщо зміна дозволена.
У прикладі замовлення:
замовлення можна підтвердити лише один раз;
порожнє замовлення не можна створити;
після підтвердження його стан змінюється.
import { randomUUID } from 'node:crypto';
export interface DomainEvent {
readonly eventId: string;
readonly aggregateId: string;
readonly occurredAt: string;
readonly aggregateVersion: number;
}
export class OrderPlaced implements DomainEvent {
readonly eventType = 'OrderPlaced';
constructor(
public readonly eventId: string,
public readonly aggregateId: string,
public readonly occurredAt: string,
public readonly aggregateVersion: number,
public readonly customerId: string,
public readonly totalCents: number,
) {}
}
export class OrderConfirmed implements DomainEvent {
readonly eventType = 'OrderConfirmed';
constructor(
public readonly eventId: string,
public readonly aggregateId: string,
public readonly occurredAt: string,
public readonly aggregateVersion: number,
public readonly customerId: string,
public readonly totalCents: number,
) {}
}
type OrderStatus = 'placed' | 'confirmed';
export class Order {
private status: OrderStatus;
private version: number;
private readonly pendingEvents: DomainEvent[] = [];
private constructor(
private readonly id: string,
private readonly customerId: string,
private readonly totalCents: number,
status: OrderStatus,
version: number,
) {
this.status = status;
this.version = version;
}
static place(params: {
id: string;
customerId: string;
totalCents: number;
}): Order {
if (params.totalCents <= 0) {
throw new Error('Сума замовлення має бути більшою за нуль');
}
const order = new Order(
params.id,
params.customerId,
params.totalCents,
'placed',
0,
);
order.record(
new OrderPlaced(
randomUUID(),
order.id,
new Date().toISOString(),
order.version + 1,
order.customerId,
order.totalCents,
),
);
return order;
}
confirm(): void {
if (this.status === 'confirmed') {
throw new Error('Замовлення вже підтверджено');
}
this.record(
new OrderConfirmed(
randomUUID(),
this.id,
new Date().toISOString(),
this.version + 1,
this.customerId,
this.totalCents,
),
);
}
getId(): string {
return this.id;
}
getStatus(): OrderStatus {
return this.status;
}
getVersion(): number {
return this.version;
}
getUncommittedEvents(): readonly DomainEvent[] {
return [...this.pendingEvents];
}
clearUncommittedEvents(): void {
this.pendingEvents.length = 0;
}
private record(event: DomainEvent): void {
this.apply(event);
this.pendingEvents.push(event);
}
private apply(event: DomainEvent): void {
if (event instanceof OrderPlaced) {
this.status = 'placed';
}
if (event instanceof OrderConfirmed) {
this.status = 'confirmed';
}
this.version = event.aggregateVersion;
}
}Метод confirm() спочатку перевіряє стан агрегату. Якщо замовлення вже підтверджене, подія не створюється.
Це гарантує, що кожна доменна подія відповідає дійсній зміні в предметній області. Не слід створювати подію, а потім сподіватися, що інший компонент відхилить некоректну зміну.
Агрегат може змінитися протягом одного сценарію кілька разів. Події, які він створив, потрібно тимчасово зберігати:
const events = order.getUncommittedEvents();Це незапубліковані події. Вони ще не є повідомленнями для інших компонентів. Спочатку application service має зберегти агрегат, а потім передати ці події механізму публікації.
Важливо розрізняти:
подію, яку агрегат створив у пам'яті;
подію, яка успішно потрапила до шини подій;
подію, яку фактично обробив конкретний обробник.
Це різні етапи одного процесу.
EventBus у NestJSУ NestJS для внутрішніх подій застосунку можна використовувати @nestjs/cqrs.
Доменні класи при цьому можуть реалізувати IEvent, але не зобов'язані наслідувати NestJS-класи. Це дозволяє залишити доменну модель незалежною від фреймворку.
Нижче наведено повний приклад основного сценарію:
import { randomUUID } from 'node:crypto';
import { Injectable, Inject, Module } from '@nestjs/common';
import {
CqrsModule,
EventBus,
EventsHandler,
IEvent,
IEventHandler,
} from '@nestjs/cqrs';
export interface DomainEvent extends IEvent {
readonly eventId: string;
readonly aggregateId: string;
readonly occurredAt: string;
readonly aggregateVersion: number;
}
export class OrderPlaced implements DomainEvent {
constructor(
public readonly eventId: string,
public readonly aggregateId: string,
public readonly occurredAt: string,
public readonly aggregateVersion: number,
public readonly customerId: string,
public readonly totalCents: number,
) {}
}
export class OrderConfirmed implements DomainEvent {
constructor(
public readonly eventId: string,
public readonly aggregateId: string,
public readonly occurredAt: string,
public readonly aggregateVersion: number,
public readonly customerId: string,
public readonly totalCents: number,
) {}
}
type OrderStatus = 'placed' | 'confirmed';
export class Order {
private status: OrderStatus;
private version: number;
private readonly pendingEvents: DomainEvent[] = [];
private constructor(
private readonly id: string,
private readonly customerId: string,
private readonly totalCents: number,
status: OrderStatus,
version: number,
) {
this.status = status;
this.version = version;
}
static place(params: {
id: string;
customerId: string;
totalCents: number;
}): Order {
if (params.totalCents <= 0) {
throw new Error('Сума замовлення має бути більшою за нуль');
}
const order = new Order(
params.id,
params.customerId,
params.totalCents,
'placed',
0,
);
order.record(
new OrderPlaced(
randomUUID(),
order.id,
new Date().toISOString(),
1,
order.customerId,
order.totalCents,
),
);
return order;
}
confirm(): void {
if (this.status === 'confirmed') {
throw new Error('Замовлення вже підтверджено');
}
this.record(
new OrderConfirmed(
randomUUID(),
this.id,
new Date().toISOString(),
this.version + 1,
this.customerId,
this.totalCents,
),
);
}
getId(): string {
return this.id;
}
getUncommittedEvents(): readonly DomainEvent[] {
return [...this.pendingEvents];
}
clearUncommittedEvents(): void {
this.pendingEvents.length = 0;
}
private record(event: DomainEvent): void {
this.apply(event);
this.pendingEvents.push(event);
}
private apply(event: DomainEvent): void {
if (event instanceof OrderPlaced) {
this.status = 'placed';
} else if (event instanceof OrderConfirmed) {
this.status = 'confirmed';
}
this.version = event.aggregateVersion;
}
}
export interface OrderRepository {
getById(id: string): Promise<Order>;
save(order: Order): Promise<void>;
}
export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY');
@Injectable()
export class InMemoryOrderRepository implements OrderRepository {
private readonly orders = new Map<string, Order>();
async getById(id: string): Promise<Order> {
const order = this.orders.get(id);
if (!order) {
throw new Error('Замовлення не знайдено');
}
return order;
}
async save(order: Order): Promise<void> {
this.orders.set(order.getId(), order);
}
}
@Injectable()
export class OrderApplicationService {
constructor(
@Inject(ORDER_REPOSITORY)
private readonly orders: OrderRepository,
private readonly eventBus: EventBus,
) {}
async placeOrder(
customerId: string,
totalCents: number,
): Promise<string> {
const order = Order.place({
id: randomUUID(),
customerId,
totalCents,
});
await this.saveAndPublish(order);
return order.getId();
}
async confirmOrder(orderId: string): Promise<void> {
const order = await this.orders.getById(orderId);
order.confirm();
await this.saveAndPublish(order);
}
private async saveAndPublish(order: Order): Promise<void> {
const events = order.getUncommittedEvents();
await this.orders.save(order);
if (events.length > 0) {
this.eventBus.publishAll(events);
order.clearUncommittedEvents();
}
}
}
@EventsHandler(OrderPlaced)
export class OrderPlacedHandler
implements IEventHandler<OrderPlaced>
{
handle(event: OrderPlaced): void {
console.log(
`Замовлення ${event.aggregateId} створено для клієнта ${event.customerId}`,
);
}
}
@EventsHandler(OrderConfirmed)
export class OrderConfirmedHandler
implements IEventHandler<OrderConfirmed>
{
handle(event: OrderConfirmed): void {
console.log(
`Замовлення ${event.aggregateId} підтверджено на суму ${event.totalCents} центів`,
);
}
}
@Module({
imports: [CqrsModule],
providers: [
InMemoryOrderRepository,
{
provide: ORDER_REPOSITORY,
useExisting: InMemoryOrderRepository,
},
OrderApplicationService,
OrderPlacedHandler,
OrderConfirmedHandler,
],
exports: [OrderApplicationService],
})
export class OrdersModule {}У цьому прикладі:
Order не імпортує EventBus;
Order створює події лише після перевірки правил;
OrderApplicationService зберігає агрегат;
EventBus публікує всі події сценарію;
обробники реагують на події через @EventsHandler.
Операція складається з двох пов'язаних кроків:
зберегти новий стан агрегату;
опублікувати події, які описують цю зміну.
Між цими кроками може виникнути помилка. Наприклад:
агрегат уже збережено;
процес завершився до публікації події;
інші компоненти не дізналися про зміну.
Для надійної системи події зазвичай спочатку записують у таблицю незапублікованих подій у тій самій транзакції, що й агрегат. Окремий процес або воркер публікує ці записи та позначає їх обробленими.
Такий підхід називають транзакційним журналом подій, або outbox-патерном.
У межах простої моделі можна викликати eventBus.publishAll() після repository.save(), але це не забезпечує надійної доставки після падіння процесу. EventBus у NestJS є внутрішньою шиною процесу, а не довговічним брокером повідомлень.
Якщо один сценарій породжує кілька подій, їхній порядок може мати значення.
Наприклад:
OrderPlaced
OrderConfirmedНе слід публікувати OrderConfirmed раніше за OrderPlaced, якщо споживачі очікують послідовну історію агрегату.
Для контролю порядку можна використовувати:
aggregateVersion;
послідовну публікацію подій;
перевірку версії в обробнику;
упорядкування повідомлень у зовнішній інфраструктурі.
Версія агрегату також допомагає виявляти конфлікти одночасного оновлення. Якщо два процеси намагаються зберегти агрегат із версією 3, репозиторій може дозволити лише одну операцію.
Публікація або доставка події може повторитися. Тому обробники повинні бути ідемпотентними: повторна обробка тієї самої події не повинна створювати неправильний результат.
Потенційно небезпечний обробник:
@EventsHandler(OrderConfirmed)
export class SendConfirmationHandler
implements IEventHandler<OrderConfirmed>
{
async handle(event: OrderConfirmed): Promise<void> {
await this.emailService.sendConfirmation(event.customerId);
}
}Якщо подія буде доставлена двічі, клієнт може отримати два листи.
Практичні способи захисту:
зберігати eventId уже оброблених подій;
використовувати унікальне обмеження в базі даних;
робити операцію оновлення стану повторно безпечною;
передавати подію в сервіс, який підтримує ключ ідемпотентності.
Ідемпотентність особливо важлива для побічних ефектів:
надсилання листів;
списання коштів;
створення документів;
оновлення зовнішніх систем.
Не кожна доменна подія повинна безпосередньо ставати повідомленням для зовнішнього сервісу.
Доменна подія:
описує факт у межах предметної області;
моделюється мовою домену;
використовується всередині bounded context;
може містити типи та правила, характерні для домену.
Інтеграційна подія:
є контрактом між окремими модулями або сервісами;
повинна мати стабільну схему;
часто потребує версіювання;
не повинна розкривати внутрішні деталі агрегату.
Наприклад, доменна подія:
OrderConfirmedможе бути перетворена на інтеграційне повідомлення:
{
eventType: 'sales.order-confirmed.v1',
eventId: '...',
occurredAt: '...',
data: {
orderId: '...',
customerId: '...',
totalCents: 12500
}
}Таке перетворення краще виконувати на межі модуля, а не змінювати доменну модель під вимоги зовнішнього API.
class Order {
constructor(private readonly eventBus: EventBus) {}
confirm(): void {
// Невдале рішення: домен залежить від NestJS.
this.eventBus.publish(new OrderConfirmed(/* ... */));
}
}Агрегат повинен фіксувати факт, а не знати, як його доставляти.
Назви на кшталт ConfirmOrder описують команду, а не подію. Для факту використовуйте OrderConfirmed.
Не передавайте в події посилання на агрегат або об'єкт, який може змінитися після публікації:
new OrderConfirmed(order); // небезпечноПодія повинна містити власну незмінну копію необхідних даних.
Якщо подія створюється до бізнес-перевірки, можна отримати подію про зміну, яка фактично не відбулася.
Спочатку перевірте правило, потім змініть стан і зафіксуйте подію.
Обробник події не повинен вимагати, щоб інший компонент обов'язково виконав дію. Подія повідомляє про вже завершену операцію.
Якщо потрібно попросити інший компонент щось зробити, це команда або виклик application service.
Виклик EventBus.publishAll() не означає, що подія буде відновлена після падіння процесу. Для гарантованої доставки потрібне довговічне зберігання подій та стратегія повторних спроб.
Доменна подія описує незмінний факт, який уже відбувся.
Назви подій формулюють у минулому часі.
Агрегат перевіряє інваріанти та лише фіксує незапубліковані події.
Публікація належить application layer, а не доменній моделі.
EventBus у NestJS зручний для внутрішніх подій процесу.
Для надійної доставки між збереженням агрегату та публікацією потрібен outbox-підхід.
Обробники подій мають бути ідемпотентними.
Доменні події та інтеграційні повідомлення слід розглядати як різні контракти.