Опирается на правила: 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 — когда произошло событие, не когда опубликовано.
  • PII не в payload для широковещательных топиков — отдельный restricted topic.
  • Forward-compatible schema: добавление non-breaking; удаление/переименование — breaking, требует eventType.v2.
  • Domain event — Java record в core/<bc>/domain/event/.
  • Внутренние объекты (Aggregate, Entity целиком) в payload — ломают forward-compat.
  • Breaking change без версии — старые consumer'ы перестанут работать.

Дизайн события — это контракт между producer и всеми consumer-ами (текущими и будущими). Любое изменение влияет на десятки систем. UCP формулирует правила так, чтобы события были читаемые (для людей в логах), стабильные (для downstream-сервисов) и версионированные (для эволюции без поломок).

Имя события — past tense

R-KFK-EVT-1: глагол в прошедшем времени.

КорректноНеверноПочему
OrderConfirmedConfirmOrderКоманда — намерение; событие — факт
PaymentFailedPaymentFailure / FailPaymentnoun не описывает «что произошло»
UserRegisteredUserRegistrationфакт регистрации, не процесс
ItemReservedReserveItemкоманда vs событие
OrderCancelledCancelOrderкоманда vs событие

DDD различает три концепта:

  • Command — намерение что-то сделать (ConfirmOrder). Адресован конкретному сервису, может быть отклонён.
  • Event — факт того, что произошло (OrderConfirmed). Прошлое, его нельзя отменить, только compensate. Адресован всем заинтересованным.
  • Query — запрос данных (GetOrderById).

Kafka topics несут именно events. Имя в прошедшем времени — сигнал «это факт, реагируйте если нужно».

Payload — обязательные поля

R-KFK-EVT-2: метаданные + бизнес-данные.

public record OrderConfirmedEvent(
    UUID eventId,
    String eventType,
    OffsetDateTime occurredAt,
    String aggregateType,
    Long aggregateId,
    Long orderId,
    Long customerId,
    Money totalAmount,
    List<OrderItemSnapshot> items
) {
    public static OrderConfirmedEvent from(Order order) {
        return new OrderConfirmedEvent(
            UuidV7.generate(),
            "order.confirmed.v1",
            order.getConfirmedAt(),
            "Order",
            order.getId(),
            order.getId(),
            order.getCustomerId(),
            order.getTotalAmount(),
            order.getItems().stream().map(OrderItemSnapshot::from).toList()
        );
    }
}
ПолеНазначение
eventIdUUID v7, уникальный, для dedup на consumer-side
eventType<aggregate>.<event>.v<N> строка, для routing и schema-evolution
occurredAtКогда событие произошло (commit в БД), не когда опубликовано в Kafka
aggregateTypeТип агрегата (Order), для маршрутизации
aggregateIdID агрегата, для dedup + routing
Бизнес-поляorderId, customerId, totalAmount, items — что нужно consumer-у

Разница occurredAt vs Kafka-timestamp:

  • Kafka-timestamp — когда broker записал сообщение. Может быть через секунды после реального события (если outbox-relay лагал).
  • occurredAt — когда бизнес-факт случился. order.getConfirmedAt() — момент commit в БД.

Для distributed tracing и аналитики occurredAt критичен: он отражает реальное время бизнес-события.

Forward-compatible schema

R-KFK-EVT-3: какие изменения safe.

ИзменениеBreaking?Что делать
Добавить новое полеНетПросто добавить, consumers игнорируют
Сделать nullable поле requiredДаНовый eventType.v2
Удалить полеДаНовый eventType.v2
Переименовать полеДаНовый eventType.v2
Изменить тип поляДаНовый eventType.v2
Изменить семантику без переименованияОпасноНовый field name + новый eventType

Default Jackson behavior: unknown fields ignored, missing fields → null/default. Это позволяет добавлять поля без breaking consumers.

При breaking change:

public record OrderConfirmedEventV1(
    UUID eventId,
    String eventType,
    OffsetDateTime occurredAt,
    Long orderId,
    Long customerId,
    Money totalAmount
) {}

public record OrderConfirmedEventV2(
    UUID eventId,
    String eventType,
    OffsetDateTime occurredAt,
    Long orderId,
    Long customerId,
    Money grossAmount,
    Money netAmount,
    Money taxAmount
) {}

Producer публикует обе версии в один topic в течение transition period:

outboxRepository.append(buildOutbox("order.confirmed.v1", OrderConfirmedEventV1.from(order)));
outboxRepository.append(buildOutbox("order.confirmed.v2", OrderConfirmedEventV2.from(order)));

Consumer-ы постепенно переключаются с v1 на v2; после миграции всех — producer перестаёт публиковать v1.

Domain event как Java record

R-KFK-EVT-4: размещение в core.

core/
  order/
    domain/
      Order.java
      OrderItem.java
      event/
        OrderCreatedEvent.java
        OrderConfirmedEvent.java
        OrderCancelledEvent.java

Размещение в core/<bc>/domain/event/:

  • Core — без зависимостей на Spring, Kafka, JOOQ. Чистый Java + DDD building blocks.
  • Persistence-слой и outbox-relay импортируют record для serialization.
  • Subscribers (в этом или в других сервисах) импортируют для deserialization.

Java records — идеальны для events: immutable, equals/hashCode/toString авто, идеально сериализуется JSON-ом.

Что запрещено

Имя — команда

R-KFK-EVT-X1: ConfirmOrder опубликован в topic orders.confirmed создаёт путаницу.

Consumer видит имя ConfirmOrder — думает «это команда, кто-то просит подтвердить заказ». На самом деле это событие «заказ уже подтверждён». Реакция consumer-а будет неверной.

Aggregate целиком в payload

R-KFK-EVT-X2:

// ПЛОХО — внутренняя структура Order ломает forward-compat
public record OrderConfirmedEvent(
    UUID eventId,
    Order order  // целиком aggregate
) {}

Что ломается:

  • Любое изменение Order (добавили внутреннее поле, рефакторили OrderItem) — breaking change для consumer-ов.
  • Aggregate может содержать sensitive поля (Customer customer с email, phone).
  • Aggregate содержит lazy-collections — сериализация может тащить лишние данные.

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

public record OrderConfirmedEvent(
    UUID eventId,
    Long orderId,
    Long customerId,
    Money totalAmount,
    List<OrderItemSnapshot> items
) {}

public record OrderItemSnapshot(
    Long productId,
    int quantity,
    Money price
) {}

Event payload — это проекция aggregate, специально для consumer-а. Что нужно — то и кладём.

PII в широковещательных топиках

R-KFK-EVT-X3: orders.confirmed слушают billing, notification, analytics, fraud-detection. Если в payload customerEmail, customerPhone — все 4 consumer'а видят PII.

// ПЛОХО — email в широком topic
public record OrderConfirmedEvent(
    Long orderId,
    String customerEmail,
    String customerPhone,
    ...
) {}

// ХОРОШО — только customerId, PII подгружается через customer-service по необходимости
public record OrderConfirmedEvent(
    Long orderId,
    Long customerId,
    Money totalAmount
) {}

Notification-service, которому нужен email для отправки письма, делает HTTP-вызов в customer-service: GET /customers/{id}/email. Это даёт точечный доступ + audit log.

Альтернатива — отдельный restricted topic customer.pii с ACL только для notification-service. См. Security.

Breaking change без версии

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

// БЫЛО
public record OrderConfirmedEvent(Long orderId, Money totalAmount) {}

// СТАЛО — breaking, но eventType остался v1
public record OrderConfirmedEvent(Long orderId, Money grossAmount) {}

Старые consumer'ы делают totalAmount — поле missing → null → NPE в их коде. Не сменили eventType — нет сигнала «обновитесь до v2».

Любой breaking change = новый eventType (order.confirmed.v2), параллельная публикация v1 и v2, миграция consumer'ов, удаление v1.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Имя — команда (ConfirmOrder как event)R-KFK-EVT-X1past tense (OrderConfirmed)
Aggregate целиком в payloadR-KFK-EVT-X2snapshot record с явными полями
PII (email, phone) в широких topicsR-KFK-EVT-X3только internal ID, PII по запросу
Breaking change без .v2R-KFK-EVT-X4новый eventType + parallel publish
occurredAt = публикация в KafkaR-KFK-EVT-2commit в БД (business time)
Event без eventIdR-KFK-EVT-2UUID v7 обязательно
Event как POJO с setter'амиR-KFK-EVT-4Java record (immutable)
Event в bootstrap/, не в core/R-KFK-EVT-4core/<bc>/domain/event/

Куда дальше

  • Kafka → раздел 6. Event design — нормативные формулировки.
  • Producer — как event попадает в Kafka.
  • Outbox publishing — event_type и payload в outbox-таблице.
  • Idempotent consumer — dedup по eventId.
  • Security — PII в restricted topics.
  • DDD → domain events — теория Domain Event.
  • REST API → versioning — forward-compat principles общие.