Опирается на правила: R-KFK-EVT-1R-KFK-EVT-4 и R-KFK-EVT-X1R-KFK-EVT-X4 из Kafka Style Guide → раздел 6. Event design.

Важно знать

  • Имя события — глагол в прошедшем времени: OrderConfirmed, PaymentFailed, UserRegistered. Не ConfirmOrder (команда), не OrderConfirmation (noun).
  • Payload: eventId UUID 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: глагол в прошедшем времени.

КорректноНеверноПочему
OrderConfirmedConfirmOrderКоманда — намерение; событие — свершившийся факт
PaymentFailedPaymentFailure / FailPaymentnoun не описывает «что произошло»
UserRegisteredUserRegistrationфакт регистрации, не процесс
ProductPriceUpdatedUpdateProductPriceкоманда vs событие
OrderCancelledCancelOrderкоманда 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,
    })),
  };
}
ПолеНазначение
eventIdUUID v7, уникальный, для dedup на consumer-side (R-KFK-IDEM-1)
eventType<aggregate>.<event>.v<N>, для routing и schema-evolution
occurredAtКогда событие произошло (commit в БД), не когда опубликовано
aggregateTypeТип агрегата (Order), для маршрутизации
aggregateIdID агрегата, для 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.v1eventType.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-X1past tense (OrderConfirmed)
Entity/Aggregate целиком в payloadR-KFK-EVT-X2snapshot-интерфейс с явными полями
PII (email, phone) в широких топикахR-KFK-EVT-X3только customerId, PII по запросу
Breaking change без .v2 в eventTypeR-KFK-EVT-X4новый eventType + parallel publish
occurredAt = время публикации в KafkaR-KFK-EVT-2commit в БД (business time)
Event без eventIdR-KFK-EVT-2UUID v7 обязателен
Mutable объект как event (без readonly)R-KFK-EVT-4readonly-интерфейс / as const
Без zod-валидации на границе consumerR-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.