skip to content

Concretely, how does an aggregate raise a domain event without breaking encapsulation - for example, without directly calling out to an event bus from inside its business logic?

level: middleimportance: must knowfreq 65%

answer

  1. internal pending-events list
  2. protected registerEvent helper
  3. pullDomainEvents/clearEvents
  4. publish after save/commit, not inline
  5. avoids phantom events on rollback

basics

~20 s

The aggregate keeps a private list inside itself. When its business method changes state, it also adds an event object to that list. Some outside code (like an application service) reads the list after the method returns and publishes the events - the aggregate itself never talks to any bus.

solid answer

~40 s

The standard pattern is: the aggregate root holds a private, internal collection of pending domain events. Inside a business method, after validating invariants and mutating its own state, the aggregate appends an event object to that collection via a protected helper (often called registerEvent or addDomainEvent) rather than calling any publisher directly. The aggregate exposes a read accessor for pending events and a clearEvents method. The application service or repository layer, which orchestrates the use case and controls the transaction, reads the collected events after the aggregate method returns successfully and dispatches them - typically right before or after the transaction commits. This keeps the aggregate free of any dependency on infrastructure and keeps the decision of when/whether to publish outside the aggregate's own logic.

go deeper

for a junior

Should understand that an aggregate collects events in a list rather than publishing them itself, at a conceptual level.

for a middle

Should be able to implement the register/pull/clear pattern correctly and explain why the aggregate shouldn't depend on a publisher.

for a senior

Should design where in the application layer events get pulled and dispatched, and reason about commit-ordering to avoid phantom or double-published events.

for a principal

Should set the convention across a codebase so publishing is centralized and impossible to forget, and weigh in-process dispatch vs. deferred delivery trade-offs at the seam with infrastructure.

## The design problem The core design problem this pattern solves is: an aggregate needs to announce that something happened, but an aggregate is supposed to be a pure piece of the domain model — **no knowledge of infrastructure, no dependency on a message bus or a framework publisher**, nothing that would make it hard to unit-test in isolation or move between transports. The common answer is to have the aggregate **collect** events rather than publish them. ## Mechanically, what the aggregate holds Mechanically: - the aggregate root holds a mutable, but never externally mutable, list — `private val domainEvents = mutableListOf<DomainEvent>()`; - business methods on the aggregate, after they've validated invariants and applied a state change, call a **protected** method such as `registerEvent(OrderShipped(id, trackingNumber))`, which simply appends to that internal list; - the aggregate also exposes something like `pullDomainEvents(): List<DomainEvent>` that returns and clears the list, or a read-only view plus a separate `clearEvents()`. Crucially, the aggregate's public API never accepts an event publisher, bus, or dispatcher as a dependency — raising an event is just appending to a local collection, trivially testable: a unit test can call `order.ship(...)` and assert on `order.pullDomainEvents()` without any mocking. ## Who publishes, and when Publishing then happens one layer up, in the application service (or in a repository's `save()` method, in some implementations) which already owns the transaction boundary for the use case. After it calls the aggregate's business method and persists the aggregate, it retrieves the pending events and dispatches them to whatever in-process listener mechanism the framework provides. This separation exists because the aggregate should not be deciding **when** to publish — synchronously inline, after commit, batched — since that's an application/infrastructure concern, not a domain concern. It also avoids a subtle bug: if the aggregate published events immediately as raised, and the surrounding transaction later rolled back, listeners would already have reacted to a change that never actually persisted. ## The central trade-off This is the central trade-off: collecting events and deferring publication until after a successful save buys **correctness** (listeners never see phantom events from a rolled-back transaction) at the cost of an extra hop — you can't just fire-and-forget from inside the aggregate, you have to trust the application layer to remember to pull and publish. If a developer writes a new use case and forgets to call `pullDomainEvents()`/dispatch, the event silently never fires; there's no compiler error for that omission unless the framework or a code convention enforces it, e.g. a base application service class that always pulls and publishes after every repository save. ## Two ways it goes wrong 1. **Double publication.** A related failure mode: if both the aggregate's own persistence path and a separate manual call publish the same collected events, listeners fire twice — for example, an email gets sent twice because the event was pulled and dispatched once in a generic `save()` wrapper and again explicitly by a specific use case handler that also wanted to react synchronously. Teams usually fix this by having exactly one designated place responsible for pulling and dispatching, and treating that as an architectural invariant. 2. **Accumulation.** Another common failure mode: if an aggregate instance is kept alive and reused across multiple operations, and `pullDomainEvents()` is never called to clear the list, events from an earlier operation get re-dispatched alongside new ones, or the list grows unboundedly. ## What it looks like in a real codebase A concrete, well-known instance of this pattern is how many DDD-oriented Spring/Kotlin codebases implement it: a base `AggregateRoot` class exposes `protected fun registerEvent(event: DomainEvent)` and `fun pullDomainEvents(): List<DomainEvent>`, and a generic repository wrapper calls `pullDomainEvents()` right after `save()` succeeds inside the same transaction, then hands the events to Spring's `ApplicationEventPublisher` so that `@TransactionalEventListener(phase = AFTER_COMMIT)` handlers only run once the database transaction has actually committed. This gives you the guarantee described above: an event is dispatched **if and only if** the state change it describes actually persisted.

  • Why shouldn't the aggregate just call an event publisher or bus directly from inside its business method?
    Because that would give a pure domain object a dependency on infrastructure, making it harder to unit test and coupling the domain model to a specific transport mechanism. It also risks publishing an event for a change that later fails to persist, since the aggregate has no visibility into whether the surrounding transaction will actually commit.
  • What happens if the application service forgets to call the aggregate's pull-events method after a successful save?
    The events are silently dropped - they sit in the aggregate's in-memory list and are discarded once the aggregate instance goes out of scope, since nothing serialized or dispatched them. This is a common source of 'the side effect never fired' bugs, which is why many teams centralize the pull-and-publish step in a single repository wrapper or interceptor rather than leaving each use case to remember it.
  • How does publishing 'after commit' avoid the phantom-event problem, and what's the cost of doing it that way instead of publishing inline?
    By deferring dispatch until the transaction has actually committed, listeners only ever see events for changes that are durably persisted, so a later rollback in the same use case can't leave a listener reacting to something that never really happened. The cost is a small window of eventual consistency and additional plumbing to make sure events aren't lost between commit and dispatch, e.g. a process crash right after commit.

It's like a courtroom stenographer who writes down 'defendant found guilty' only after the judge's ruling is final - the stenographer doesn't announce it to the press mid-deliberation, and a court clerk collects the transcript and distributes it only after the verdict is officially entered.

saying these in an interview costs you the question

  • Has the aggregate call an event bus or publisher directly inside a business method
  • Publishes events before the transaction that produced them has committed
  • Can't explain who is responsible for calling pullDomainEvents/clearEvents
  • No mechanism to clear the pending-events list, risking re-dispatch or unbounded growth
  • Publishes the same collected events from two different places

context