Опирается на правила: R-EVT-1R-EVT-5 и R-EVT-X1R-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-X1final class + final поля, новый event при изменении
Ссылка на агрегат внутри события (Order order)R-EVT-X2Только ID и плоские VO/примитивы
Публикация события из контроллера или сервисаR-EVT-X3registerEvent в корне → publishAll в репозитории
AFTER_COMMIT для критичных side-effects (money, склад)R-EVT-X4Outbox pattern + Kafka relay
Имя события — императив (PayOrder, CreateCustomer)R-EVT-2Past participle: OrderPaid, CustomerCreated

Куда дальше