Events are facts, the bus is plumbing
A domain event describes something that already happened. It is a fact, not an intention. Yet many codebases publish events from inside the entity, at the moment its state changes, which couples the entity to infrastructure and lets you announce a fact before it is even true in the database.
The problem
When the entity publishes its own events, two things go wrong at once.
The first is dependency direction. An entity exists to enforce business rules. The moment it calls a bus, or pushes an event onto an internal list that something else drains, it also knows about the dispatch mechanism. A Book should know whether it can be borrowed. It should not know that a borrowing produces a message somebody else will consume. That is infrastructure leaking into the one place that is supposed to stay pure.
The second is timing. If the event leaves before persistence is confirmed, you have announced something that may never have committed. A listener sends a confirmation email, updates a read model, or notifies another module, and then the transaction rolls back. Now the rest of the system believes in a book that does not exist. The event was published as a fact while it was still only a hope.
Past-tense naming is the tell. BookCreatedEvent claims a book was created. If you raise it from inside the constructor or a state-change method, you are making that claim before the save call has returned. The name promises a fact the code has not yet earned.
The mechanism
In Phexium, the entity stays pure and the handler orchestrates. The event is named in the past tense, instantiated in the handler, and dispatched only after the repository has confirmed the write:
namespace AppDemo\Library\Application\Command;
final readonly class CreateBookHandler
{
public function __invoke(CreateBookCommand $command): void
{
$book = new Book(
$command->id,
Title::fromString($command->title),
Author::fromString($command->author),
ISBN::fromString($command->isbn),
);
$this->bookRepository->save($book);
$this->eventBus->dispatch(
new BookCreatedEvent(
$this->idGenerator->generate(),
$this->clock->now(),
$book,
),
);
}
}
The order is not cosmetic. save() runs first. Only once it returns does dispatch() fire. No listener hears about the book until the book is durably a fact.
The event itself carries no behavior. It records who, when, and what changed:
namespace AppDemo\Library\Domain\Event;
use Phexium\Domain\Event\DomainEventAbstract;
use Phexium\Domain\Event\DomainEventInterface;
final readonly class BookCreatedEvent extends DomainEventAbstract implements DomainEventInterface
{
public function __construct(
IdInterface $eventId,
DateTimeImmutable $occurredOn,
private Book $book,
) {
parent::__construct($eventId, $occurredOn);
}
public function getAggregateId(): IdInterface
{
return $this->book->getId();
}
}
The bus is plumbing, nothing more. Its port is two methods:
namespace Phexium\Plugin\EventBus\Port;
interface EventBusInterface
{
public function dispatch(DomainEventInterface $event): DomainEventInterface;
public function subscribe(string $event, callable $listener): void;
}
There is no business logic in that interface. It transports already-committed facts to whoever subscribed. A listener reacts without ever calling back into the entity:
namespace AppDemo\Library\Application\EventListener;
final readonly class BookEventHandler implements ListenerInterface
{
public function __invoke(BookCreatedEvent|BookUpdatedEvent|BookDeletedEvent $event): void
{
$this->logger->info($event->getEventName().' handled', [
'aggregateId' => $event->getAggregateId()->getValue(),
'occurredOn' => $event->getOccurredOn(),
]);
}
}
Trade-offs
Publishing from the handler is not the same as publishing inside a transaction. The save returns, the row is committed, then the dispatch runs as a separate step. If the process dies between the two, the write survives and the event is lost. For most modules that gap is acceptable. When it is not, you need a transactional outbox: write the event to the same database, in the same transaction as the aggregate, and relay it afterwards. Phexium does not do that for you out of the box, and pretending otherwise would be dishonest about the guarantee.
There is also a school of thought, argued well by Vaughn Vernon, that the aggregate should record its own events into an internal collection that the application layer drains after persistence. That keeps the "what happened" decision next to the rule, while still dispatching late. It is a legitimate alternative. The cost is that the entity holds mutable event state, which is a heavier thing to test and reason about than a handler that simply builds the event after the save returns. Phexium favors the lighter version.
What this enables
You can test entities without a bus in sight. A Book test asserts a state transition and a thrown exception; it never mocks a dispatcher, because the entity never touches one. The publication order becomes a handler-level concern, tested where it actually lives.
Past-tense names then pay off in reading. BookCreatedEvent, LoanReturnedEvent, UserRegisteredEvent read as a log of what the system did, not a queue of instructions it intends to run. When you trace a flow, the events tell you the history, and the handler tells you the order that history was committed in. The fact is true before it is announced, and the bus stays the dumb pipe it should be.