Home/Case Studies/Domain event ownership
Architecture

Domain event ownership

Not every event belongs to an aggregate. Recognising the difference kept the domain honest.

Ledger uses a persist-first event-driven architecture. Events are persisted for audit, cross-module communication, and failure replay, but aggregates are reconstituted from database snapshots, not event streams. That distinction created a question: if events are not the source of truth for state, does every event still need to flow through an aggregate? The answer was no, and formalising that boundary prevented a category of modelling mistakes.

The orthodox position

In event-sourced systems, the aggregate is the sole source of events because the event stream is the state. Every fact about the system must originate from an aggregate method. The aggregate decided it happened, so the aggregate records it. Daniel Whittaker's CQRS walkthrough articulates this clearly: the command handler loads the aggregate, the aggregate executes the behavior and raises events, the handler persists them. No exceptions.

Where orthodoxy broke down

Ledger is not event-sourced. Events are persisted to a `domain_events` table via the EventBus, but aggregate state lives in Postgres and is rebuilt via `reconstitute()`, not by replaying events. This means events serve audit and integration, not as the authoritative state record. Forcing every event through an aggregate created two modelling problems that the orthodox model does not account for.

EventProblem with aggregate ownership
LoginFailedEventNo aggregate exists. The user was not found or the password was wrong. There is nothing to call addDomainEvent() on.
AccountDeletedEventThe aggregate is being destroyed. Having a deleted aggregate raise its own death notice is a lifecycle contradiction.

The two-pattern model

The resolution was to formalise two event ownership patterns based on a single question: does this event describe the aggregate's own state change? If yes, the aggregate raises it via `addDomainEvent()`. If no (the event spans aggregates, has no owning aggregate, or the aggregate is being destroyed) the handler dispatches it directly via `eventBus.dispatch()`. Both paths flow through the same EventBus and land in the same `domain_events` table.

EventOwnerPattern
UserRegisteredEventUser.register()Aggregate-raised
UserProfileUpdatedEventUserProfile.updateName() / UserProfile.save()Aggregate-raised
UserLoggedInEventUser.loggedIn()Aggregate-raised
MfaEnabledEventUser.confirmMfa()Aggregate-raised
MfaDisabledEventUser.disableMfa()Aggregate-raised
LoginFailedEventLoginUserHandlerHandler-dispatched
UserLoggedOutEventLogoutUserHandlerHandler-dispatched
AccountDeletedEventDeleteAccountHandlerHandler-dispatched

Why not full event sourcing

Full event sourcing would resolve the ownership question by requiring every event to flow through an aggregate. But it also requires aggregate reconstitution from event replay, a message broker for reliable delivery and projection rebuilds, and snapshot strategies for long-lived aggregates. The infrastructure cost is not justified at this scale. The current architecture (persist-first event dispatch with database-backed aggregate state) provides the audit trail and cross-module decoupling benefits without the operational overhead. The IEventBus interface preserves the upgrade path if the system grows into it.