Опирается на правила:
R-EVT-1…R-EVT-5иR-EVT-X1…R-EVT-X4из DDD Tactical Style Guide → раздел 4. Domain Event.
Важно знать
- Domain Event = факт, который уже произошёл в домене. Имя — глагол в прошедшем времени:
OrderPaid,CustomerRegistered, неPayOrderи неOrderPaymentEvent.- Наследуем
DomainEventизddd-building-blocks, в конструкторе вызываемsuper(aggregateType, aggregateId).- Immutable:
final class, все поляfinal. Никаких сеттеров. Событие — описание прошлого; прошлое не редактируется.- В полях — примитивы и Value Objects, не агрегаты.
OrderId,Money,Instant— да.Order order— нет.- Публикация — через
DomainEventPublisher.publishAll(...)в репозитории после save. Сразу после публикации —clearDomainEvents()на корне.@TransactionalEventListener(AFTER_COMMIT)для критичных эффектов (списать деньги, отгрузить со склада) запрещён — нужен Outbox pattern.- Подписчик обрабатывает событие в отдельной транзакции. Согласованность между агрегатами — eventual.
Domain Event — это механизм, через который агрегат рассказывает миру «у меня изменилось состояние», не зная заранее, кто будет слушать. Подписчики (другой агрегат, integration в внешнюю систему, проекция в read-model) подключаются независимо. Без событий все взаимодействия пришлось бы зашивать прямыми вызовами — get a tightly coupled monolith inside one service. Раскрытие раздела 4 гайда.
Наследование DomainEvent
R-EVT-1: событие наследует DomainEvent и в конструкторе вызывает super(aggregateType, aggregateId).
public final class OrderPaid extends DomainEvent {
private final OrderId orderId;
private final Money amount;
private final Instant paidAt;
public OrderPaid(OrderId orderId, Money amount, Instant paidAt) {
super("Order", orderId.value());
this.orderId = Objects.requireNonNull(orderId);
this.amount = Objects.requireNonNull(amount);
this.paidAt = Objects.requireNonNull(paidAt);
}
public OrderId orderId() { return orderId; }
public Money amount() { return amount; }
public Instant paidAt() { return paidAt; }
}
Что даёт базовый класс:
eventId— UUID, сгенерированный при создании. Используется подписчиками для дедупликации (см. Kafka Style Guide → processed_event).createdAt— момент создания события (не путать с моментом события в домене; для последнего — собственное полеpaidAt).aggregateType/aggregateId— позволяет outbox-relay и подписчикам понять, к какому агрегату относится событие, без открытия payload.
Имя — глагол в прошедшем времени
R-EVT-2: OrderPaid, CustomerRegistered, RefundIssued. Не PayOrder (это команда), не OrderPaymentEvent (тавтология).
Почему именно так:
- Событие — факт в прошлом. Императивное имя (
PayOrder) — это команда («сделай платёж»), а команда и событие — разные концепции в CQRS. - Читается как лог происходящего.
OrderCreated → OrderConfirmed → OrderPaid → OrderShipped— описывает жизнь заказа.CreateOrder → ConfirmOrder— это план действий, не история. - Подписчик не путается. Когда видит
OrderPaid, понимает: это уже произошло, нужно отреагировать. СPayOrderEventнеясно — «событие, что просят оплатить» vs «событие, что оплачено».
Суффикс Event не добавляем. Имя класса говорит само за себя; OrderPaidEvent — это OrderPaid плюс «спасибо, я знаю, что это event».
Immutable
R-EVT-3, R-EVT-X1: класс final, поля final, никаких сеттеров. После создания событие не меняется.
// ХОРОШО — final class, final fields
public final class CustomerRegistered extends DomainEvent {
private final CustomerId customerId;
private final Email email;
private final Instant registeredAt;
// ...
}
// ПЛОХО
public class CustomerRegistered extends DomainEvent {
private CustomerId customerId; // ← не final
public void setCustomerId(CustomerId id) { this.customerId = id; } // ← setter
}
Почему категорически:
- Прошлое не редактируется. Событие — это факт. Изменить факт можно только новым фактом (
CustomerEmailChanged). - Подписчик доверяет. Если событие могло измениться по дороге, подписчик должен бы каждый раз перепроверять. Immutability снимает этот вопрос.
- Безопасно делить между потоками. Подписка может быть асинхронной, событие может быть закэшировано в outbox-таблице — везде нужно константное состояние.
Java record тоже подходит, если базовый класс позволяет:
public record OrderShipped(OrderId orderId, Instant shippedAt, String carrier)
implements ValueObject /* если архитектура позволяет record вместо extends */ { ... }
В подходе с extends DomainEvent record не получится (record не может наследоваться от класса). Альтернатива — поднимать DomainEvent до интерфейса, см. библиотечную документацию.
В полях — примитивы и VO, не агрегаты
R-EVT-4, R-EVT-X2: событие несёт бизнес-контекст, а не указатель на изменяемое состояние агрегата.
// ПЛОХО
public final class OrderPaid extends DomainEvent {
private final Order order; // ← ссылка на агрегат
}
// ХОРОШО
public final class OrderPaid extends DomainEvent {
private final OrderId orderId;
private final Money amount;
private final Instant paidAt;
}
Что не так со ссылкой на агрегат:
- Подписчик «доберётся» до изменяемого состояния. К моменту обработки события
Orderуже отличается — кто-то его изменил. Это нарушает «факт в прошлом» — мы передаём ссылку на «настоящее» агрегата. - Сериализация в outbox / Kafka. Сериализовать
Orderцеликом — десятки полей, mutable internal state, циклы ссылок. СериализоватьOrderId + Money + Instant— три поля, понятная схема. - Версионирование. Когда
Orderизменит свою структуру, payload события для старых подписчиков тоже изменится. С плоскими полями событие — стабильный контракт.
Что включаем в событие:
- ID агрегата (
OrderId). - Ключевые значения на момент события —
amountдляOrderPaid,emailдляCustomerRegistered,quantityдляStockReserved. - Технические метаданные —
paidAt,correlationId, source-system.
Что не включаем: всё остальное состояние агрегата. Если подписчику нужна полная картина — он сам поднимет агрегат по ID из репозитория. Но в 95% случаев данных в событии достаточно.
Публикация: после save + clearDomainEvents
R-EVT-5: события собираются в корне через registerEvent(...), публикуются репозиторием после успешного сохранения, после публикации — clearDomainEvents().
@Repository
@RequiredArgsConstructor
class JooqOrderRepository implements OrderRepository {
private final DSLContext dsl;
private final OrderDomainRecordMapper mapper;
private final DomainEventPublisher publisher;
@Override
public void save(Order order) {
dsl.transaction(cfg -> {
// 1. upsert агрегата
mapper.upsert(cfg.dsl(), order);
// 2. публикация собранных событий — внутри той же транзакции,
// если publisher пишет в outbox-таблицу
publisher.publishAll(order.getDomainEvents());
});
// 3. чистим список событий на агрегате
order.clearDomainEvents();
}
}
Почему именно так:
publishAll— внутри транзакции. ЕслиDomainEventPublisher— это outbox-writer, события и состояние агрегата фиксируются атомарно. Не «состояние сохранили, события потеряли» и не наоборот.clearDomainEvents— после успешногоcommit. Если транзакция падает, события остаются в агрегате, и повторныйsaveснова попытается их опубликовать.- Сам агрегат не публикует. Корень только регистрирует (
registerEvent); кто и как доставит — забота инфраструктуры.
R-EVT-X3: публикация не из контроллера и не из сервиса. Только репозиторий (или явный publisher-вызов в Domain Service для очень специфических случаев).
AFTER_COMMIT для критичных эффектов запрещён
R-EVT-X4: @TransactionalEventListener(phase = AFTER_COMMIT) — соблазнительный механизм, но опасный.
// ПЛОХО — критичный эффект через AFTER_COMMIT
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderPaid event) {
inventoryClient.deduct(event.orderId(), event.items()); // ← списать со склада
}
Что может пойти не так:
- Падение между commit и AFTER_COMMIT. JVM умер, под перезагрузился, listener не отработал. Деньги списали (через
OrderPaidсохранён), склад не уменьшен. Восстановить — невозможно автоматически. - Listener в том же процессе. Несколько подписчиков — один сбой блокирует следующих.
- Нет ретраев. AFTER_COMMIT не персистентный — повторно не сработает.
Что взамен:
- Outbox pattern. Событие пишется в
outbox-таблицу в той же транзакции, что и агрегат. Отдельный relay-процесс читает outbox, публикует в Kafka, гарантирует at-least-once. Подробно — в Kafka Style Guide и PG runtime style guide. - Синхронный
@EventListenerв той же транзакции — если эффект внутри сервиса и нужен немедленный rollback при ошибке. Но это уже про in-process Domain Event Handler, не cross-service.
AFTER_COMMIT остаётся пригодным для некритичных эффектов: «отправить notification, окей если потеряем», «обновить кэш». Но money и склад — только Outbox.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Mutable event (setter, не final поле) | R-EVT-X1 | final class + final поля, новый event при изменении |
Ссылка на агрегат внутри события (Order order) | R-EVT-X2 | Только ID и плоские VO/примитивы |
| Публикация события из контроллера или сервиса | R-EVT-X3 | registerEvent в корне → publishAll в репозитории |
AFTER_COMMIT для критичных side-effects (money, склад) | R-EVT-X4 | Outbox pattern + Kafka relay |
Имя события — императив (PayOrder, CreateCustomer) | R-EVT-2 | Past participle: OrderPaid, CustomerCreated |
Куда дальше
- DDD Tactical → раздел 4. Domain Event — нормативные формулировки
R-EVT-*. - Aggregate Root — где
registerEventвызывается и почему только в корне. - Repository — где
publishAll+clearDomainEvents. - Kafka Style Guide — outbox publishing вместо AFTER_COMMIT, idempotent consumer.
- Distributed Patterns Style Guide — saga через события, eventual consistency.
- CQRS Style Guide — read-model, которая обновляется по событиям.