Опирается на правила:
R-KFK-EVT-1…R-KFK-EVT-4иR-KFK-EVT-X1…R-KFK-EVT-X4из Kafka Style Guide → раздел 6. Event design.
Важно знать
- Имя события — глагол в прошедшем времени:
OrderConfirmed,PaymentFailed,UserRegistered. НеConfirmOrder(команда), неOrderConfirmation(noun).- Payload:
eventIdUUID v7,eventTypeверсионированный (order.confirmed.v1),occurredAt,aggregateType+aggregateId, бизнес-данные.occurredAt— когда произошло событие, не когда опубликовано в Kafka.- PII не в payload широковещательных топиков — только
customerId, PII по запросу.- Forward-compatible schema: добавление полей non-breaking; удаление/переименование — breaking, требует нового
eventType.v2.- Событие — readonly-интерфейс или
as constобъект вcore/<bc>/domain/event/; валидация на границе consumer — zod-схема.- Внутренние объекты (Entity, Aggregate целиком) в payload — ломают forward-compat.
- Breaking change без версии — старые consumer'ы перестанут десериализовать корректно.
Дизайн события — это контракт между producer и всеми consumer-ами, существующими и будущими. В экосистеме Node/NestJS контракт живёт как readonly TypeScript-интерфейс и zod-схема: первый — для статического анализа внутри сервиса, вторая — для runtime-валидации на границе consumer.
Имя события — past tense
R-KFK-EVT-1: глагол в прошедшем времени.
| Корректно | Неверно | Почему |
|---|---|---|
OrderConfirmed | ConfirmOrder | Команда — намерение; событие — свершившийся факт |
PaymentFailed | PaymentFailure / FailPayment | noun не описывает «что произошло» |
UserRegistered | UserRegistration | факт регистрации, не процесс |
ProductPriceUpdated | UpdateProductPrice | команда vs событие |
OrderCancelled | CancelOrder | команда vs событие |
DDD разграничивает три концепта:
- Command — намерение (
ConfirmOrder). Адресован конкретному сервису, может быть отклонён. - Event — факт того, что произошло (
OrderConfirmed). Прошлое, его нельзя отменить, только compensate. - Query — запрос данных.
Kafka-топики несут именно events. Имя в прошедшем времени — сигнал «это факт, реагируйте если нужно».
Payload — обязательные поля
R-KFK-EVT-2: метаданные + бизнес-данные.
// core/order/domain/event/order-confirmed.event.ts
export interface OrderConfirmedEvent {
readonly eventId: string; // UUID v7
readonly eventType: string; // 'order.confirmed.v1'
readonly occurredAt: string; // ISO 8601, момент бизнес-факта
readonly aggregateType: string; // 'Order'
readonly aggregateId: string;
readonly orderId: string;
readonly customerId: string;
readonly totalAmount: Money;
readonly items: readonly OrderItemSnapshot[];
}
export interface OrderItemSnapshot {
readonly productId: string;
readonly quantity: number;
readonly price: Money;
}
export interface Money {
readonly amount: number;
readonly currency: string;
}
export function buildOrderConfirmedEvent(order: Order): OrderConfirmedEvent {
return {
eventId: uuidv7(),
eventType: 'order.confirmed.v1',
occurredAt: order.confirmedAt.toISOString(),
aggregateType: 'Order',
aggregateId: order.id,
orderId: order.id,
customerId: order.customerId,
totalAmount: order.totalAmount,
items: order.items.map((i) => ({
productId: i.productId,
quantity: i.quantity,
price: i.price,
})),
};
}
| Поле | Назначение |
|---|---|
eventId | UUID v7, уникальный, для dedup на consumer-side (R-KFK-IDEM-1) |
eventType | <aggregate>.<event>.v<N>, для routing и schema-evolution |
occurredAt | Когда событие произошло (commit в БД), не когда опубликовано |
aggregateType | Тип агрегата (Order), для маршрутизации |
aggregateId | ID агрегата, для dedup и ordering |
| Бизнес-поля | Что нужно consumer-у — не больше и не меньше |
Разница occurredAt vs Kafka-timestamp:
- Kafka-timestamp — когда broker записал сообщение. Может отставать от события на секунды (outbox-relay лагал).
occurredAt— когда бизнес-факт случился, момент commit в БД. Именно его используют аналитика и distributed tracing.
Zod-схема на границе consumer
R-KFK-EVT-4 в Node-биндинге: domain event — readonly-интерфейс в core/; на границе consumer — zod-схема для runtime-валидации. Статический реестр eventType → схема (R-KFK-CFG-3).
// core/order/domain/event/order-confirmed.schema.ts
import { z } from 'zod';
const moneySchema = z.object({
amount: z.number(),
currency: z.string().length(3),
});
const orderItemSnapshotSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
price: moneySchema,
});
export const orderConfirmedSchema = z.object({
eventId: z.string().uuid(),
eventType: z.literal('order.confirmed.v1'),
occurredAt: z.string().datetime(),
aggregateType: z.literal('Order'),
aggregateId: z.string().uuid(),
orderId: z.string().uuid(),
customerId: z.string().uuid(),
totalAmount: moneySchema,
items: z.array(orderItemSnapshotSchema).min(1),
});
export type OrderConfirmedEvent = z.infer<typeof orderConfirmedSchema>;
В consumer-е:
eachMessage: async ({ message }) => {
const raw = JSON.parse(message.value!.toString());
const event = orderConfirmedSchema.parse(raw); // бросает ZodError если схема нарушена
await handler.handle(event);
},
zod кидает ZodError — poison-pill сообщение (невалидный payload) → DLQ без retry (R-KFK-RTRY-2).
Forward-compatible schema
R-KFK-EVT-3: какие изменения безопасны.
| Изменение | Breaking? | Что делать |
|---|---|---|
| Добавить новое поле | Нет | Добавить в интерфейс; в zod — .optional() |
| Сделать optional поле required | Да | Новый eventType.v2 |
| Удалить поле | Да | Новый eventType.v2 |
| Переименовать поле | Да | Новый eventType.v2 |
| Изменить тип поля | Да | Новый eventType.v2 |
| Изменить семантику без переименования | Опасно | Новый field name + новый eventType |
По умолчанию zod .parse() отбрасывает неизвестные поля (strip mode). Это позволяет producer-у добавлять поля без поломки существующих consumer-ов.
При breaking change — новый eventType:
// v1 → v2: totalAmount разбит на grossAmount / netAmount / taxAmount
// core/order/domain/event/order-confirmed-v2.schema.ts
export const orderConfirmedV2Schema = z.object({
eventId: z.string().uuid(),
eventType: z.literal('order.confirmed.v2'),
occurredAt: z.string().datetime(),
orderId: z.string().uuid(),
customerId: z.string().uuid(),
grossAmount: moneySchema,
netAmount: moneySchema,
taxAmount: moneySchema,
items: z.array(orderItemSnapshotSchema).min(1),
});
Outbox-relay публикует обе версии в течение transition period:
// order.confirmed.v1 — для старых consumer'ов
await manager.save(OutboxEvent, {
eventType: 'order.confirmed.v1',
payload: JSON.stringify(buildOrderConfirmedV1Event(order)),
});
// order.confirmed.v2 — для новых
await manager.save(OutboxEvent, {
eventType: 'order.confirmed.v2',
payload: JSON.stringify(buildOrderConfirmedV2Event(order)),
});
Consumer-ы переключаются с v1 на v2; после миграции всех — producer перестаёт публиковать v1.
Событие в core — без NestJS-зависимостей
R-KFK-EVT-4 применительно к Node: domain event живёт в core/<bc>/domain/event/ без зависимостей на NestJS, kafkajs, TypeORM.
core/
order/
domain/
order.entity.ts
order-item.entity.ts
event/
order-created.event.ts
order-confirmed.event.ts
order-confirmed.schema.ts
order-cancelled.event.ts
order-cancelled.schema.ts
- Интерфейсы — statically typed shape, используются внутри сервиса.
- Zod-схемы — runtime-валидация, используются в consumer-ах на входе.
- Relay/producer импортируют
buildOrderConfirmedEventдля serialization. - Consumer-ы импортируют zod-схему для десериализации и
OrderConfirmedEventкак тип.
Что запрещено
Имя — команда
R-KFK-EVT-X1: ConfirmOrder опубликован в топик orders.confirmed создаёт путаницу на стороне consumer.
Consumer видит имя ConfirmOrder и интерпретирует как «кто-то просит подтвердить заказ». На самом деле это факт — заказ уже подтверждён. Реакция будет неверной.
Aggregate целиком в payload
R-KFK-EVT-X2:
// ПЛОХО — внутренняя структура Order ломает forward-compat
interface OrderConfirmedEvent {
eventId: string;
order: Order; // целиком entity
}
Что ломается:
- Любое изменение
Order(новое внутреннее поле, рефакторингOrderItem) — breaking для всех consumer-ов. - Entity может содержать sensitive поля (
customerс email, phone). - Circular references → сериализация упадёт или даст неожиданный результат.
Корректно — snapshot:
interface OrderConfirmedEvent {
eventId: string;
orderId: string;
customerId: string;
totalAmount: Money;
items: readonly OrderItemSnapshot[];
}
Event payload — это проекция aggregate специально для consumer-а. Кладём ровно то, что нужно.
PII в широковещательных топиках
R-KFK-EVT-X3: топик orders.confirmed слушают billing, notification, analytics, fraud-detection. Если в payload customerEmail, customerPhone — все consumer-ы видят PII.
// ПЛОХО — email в широком топике
interface OrderConfirmedEvent {
orderId: string;
customerEmail: string; // PII — видят все consumer'ы
customerPhone: string;
}
// ХОРОШО — только customerId; email подгружается через customer-service по необходимости
interface OrderConfirmedEvent {
orderId: string;
customerId: string;
totalAmount: Money;
}
Notification-сервис, которому нужен email, делает HTTP-вызов: GET /customers/{id}/contact. Это даёт точечный доступ + audit log. Альтернатива — отдельный restricted топик с ACL только для notification-service. См. Security.
Breaking change без версии
R-KFK-EVT-X4: переименовали поле, не сменили eventType.v1 → eventType.v2.
// БЫЛО
interface OrderConfirmedEvent {
orderId: string;
totalAmount: Money;
}
// СТАЛО — breaking, но eventType остался 'order.confirmed.v1'
interface OrderConfirmedEvent {
orderId: string;
grossAmount: Money; // переименовано — zod consumer с .totalAmount падает
}
Старые consumer-ы: zod-схема ожидает totalAmount — поле отсутствует → ZodError → DLQ. Не сменили eventType — нет сигнала «обновитесь до v2».
Любой breaking change = новый eventType + параллельная публикация v1 и v2 + миграция consumer-ов + удаление v1.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Имя — команда (ConfirmOrder как event) | R-KFK-EVT-X1 | past tense (OrderConfirmed) |
| Entity/Aggregate целиком в payload | R-KFK-EVT-X2 | snapshot-интерфейс с явными полями |
| PII (email, phone) в широких топиках | R-KFK-EVT-X3 | только customerId, PII по запросу |
Breaking change без .v2 в eventType | R-KFK-EVT-X4 | новый eventType + parallel publish |
occurredAt = время публикации в Kafka | R-KFK-EVT-2 | commit в БД (business time) |
Event без eventId | R-KFK-EVT-2 | UUID v7 обязателен |
Mutable объект как event (без readonly) | R-KFK-EVT-4 | readonly-интерфейс / as const |
| Без zod-валидации на границе consumer | R-KFK-EVT-4 | статический реестр eventType → zod-схема |
Куда дальше
- Kafka → раздел 6. Event design — нормативные формулировки.
- Producer — как event попадает в Kafka через kafkajs.
- Outbox publishing —
event_typeиpayloadв outbox-таблице с TypeORM. - Idempotent consumer — dedup по
eventIdчерезprocessed_event. - Consumer — ручной commit offset и
autoCommit: false. - Retry topic + DLQ — poison-pill в DLQ без retry.
- Security — PII в restricted topics, ACL per-сервис.
- Конфигурация — статический реестр zod-схем, fail-fast на старте.
- Observability —
traceparentв headers, consumer lag alert.