---
title: "Event design — past tense, eventId UUID v7 и forward-compatible schema (NestJS/TypeScript)"
nav_title: "Event design"
excerpt: "Дизайн Kafka-событий в NestJS: имя — глагол в прошедшем времени, payload содержит eventId UUID v7 и версионированный eventType, schema эволюционирует forward-compatible."
keywords: "Kafka event design NestJS, TypeScript event naming past tense OrderConfirmed, eventId UUID v7, eventType version, zod schema, R-KFK-EVT, forward-compatible schema kafkajs"
focus_keyword: "Kafka event design NestJS TypeScript"
tags: ["kafka", "nestjs", "typescript", "event-design", "ddd", "kafkajs"]
---

# Event design — past tense, eventId UUID v7 и forward-compatible schema (NestJS/TypeScript)

> **Опирается на правила:** `R-KFK-EVT-1` … `R-KFK-EVT-4` и `R-KFK-EVT-X1` … `R-KFK-EVT-X4` из Kafka Style Guide → [раздел 6. Event design](/standards/backend/kafka/#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`: глагол в прошедшем времени.

| Корректно | Неверно | Почему |
|---|---|---|
| `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`: метаданные + бизнес-данные.

```ts
// 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`).

```ts
// 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-е:

```ts
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`:

```ts
// 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:

```ts
// 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`:

```ts
// ПЛОХО — внутренняя структура Order ломает forward-compat
interface OrderConfirmedEvent {
  eventId: string;
  order: Order;  // целиком entity
}
```

Что ломается:
- Любое изменение `Order` (новое внутреннее поле, рефакторинг `OrderItem`) — breaking для всех consumer-ов.
- Entity может содержать sensitive поля (`customer` с email, phone).
- Circular references → сериализация упадёт или даст неожиданный результат.

Корректно — **snapshot**:

```ts
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.

```ts
// ПЛОХО — 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](node/security.md).

### Breaking change без версии

`R-KFK-EVT-X4`: переименовали поле, не сменили `eventType.v1` → `eventType.v2`.

```ts
// БЫЛО
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](/standards/backend/kafka/#6-event-design) — нормативные формулировки.
- [Producer](node/producer.md) — как event попадает в Kafka через kafkajs.
- [Outbox publishing](node/outbox-publishing.md) — `event_type` и `payload` в outbox-таблице с TypeORM.
- [Idempotent consumer](node/idempotent-consumer.md) — dedup по `eventId` через `processed_event`.
- [Consumer](node/consumer.md) — ручной commit offset и `autoCommit: false`.
- [Retry topic + DLQ](node/retry-and-dlq.md) — poison-pill в DLQ без retry.
- [Security](node/security.md) — PII в restricted topics, ACL per-сервис.
- [Конфигурация](node/configuration.md) — статический реестр zod-схем, fail-fast на старте.
- [Observability](node/observability.md) — `traceparent` в headers, consumer lag alert.
