Опирается на правила:
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— когда произошло событие, не когда опубликовано.- 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: глагол в прошедшем времени.
| Корректно | Неверно | Почему |
|---|---|---|
OrderConfirmed | ConfirmOrder | Команда — намерение; событие — факт |
PaymentFailed | PaymentFailure / FailPayment | noun не описывает «что произошло» |
UserRegistered | UserRegistration | факт регистрации, не процесс |
ItemReserved | ReserveItem | команда vs событие |
OrderCancelled | CancelOrder | команда 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()
);
}
}
| Поле | Назначение |
|---|---|
eventId | UUID v7, уникальный, для dedup на consumer-side |
eventType | <aggregate>.<event>.v<N> строка, для routing и schema-evolution |
occurredAt | Когда событие произошло (commit в БД), не когда опубликовано в Kafka |
aggregateType | Тип агрегата (Order), для маршрутизации |
aggregateId | ID агрегата, для 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.v1 → eventType.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-X1 | past tense (OrderConfirmed) |
| Aggregate целиком в payload | R-KFK-EVT-X2 | snapshot record с явными полями |
| PII (email, phone) в широких topics | R-KFK-EVT-X3 | только internal ID, PII по запросу |
Breaking change без .v2 | R-KFK-EVT-X4 | новый eventType + parallel publish |
occurredAt = публикация в Kafka | R-KFK-EVT-2 | commit в БД (business time) |
Event без eventId | R-KFK-EVT-2 | UUID v7 обязательно |
| Event как POJO с setter'ами | R-KFK-EVT-4 | Java record (immutable) |
Event в bootstrap/, не в core/ | R-KFK-EVT-4 | core/<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 общие.