Domain Events
debt(d7/e7/b5/t7)
Closest to 'only careful code review or runtime testing' (d7). The detection_hints indicate automated detection is 'no' and the code pattern (service directly calling side effects instead of raising domain events) requires a reviewer to understand the intended architecture. PHPStan (the only listed tool) cannot flag the absence of domain event patterns — it would only catch type errors. Missing domain events are silent in normal operation and only discovered during architecture review or when a new side effect requirement reveals the coupling.
Closest to 'cross-cutting refactor across the codebase' (e7). The quick_fix describes moving event raising into aggregate roots and dispatching after transaction commits — this is not a single-line fix. It requires identifying all state-changing services, refactoring them to raise events from aggregate roots, introducing an event dispatcher, wiring up handlers, and ensuring transactional safety (events dispatched only after commit). This touches multiple layers (domain, application, infrastructure) and multiple files across the codebase.
Closest to 'persistent productivity tax' (b5). Domain events apply across web, cli, and queue-worker contexts. Once adopted (or not adopted), every new side effect added to the system is shaped by whether domain events exist. Without them, every new side effect (Slack notification, analytics, etc.) requires touching existing service code. The burden is significant but not fully architectural — teams can partially adopt or retrofit incrementally without a full rewrite.
Closest to 'serious trap' (t7). The misconception field identifies a well-documented and consequential confusion: developers treat domain events and integration events as the same thing, leading to domain events being published to external message brokers directly. The common_mistakes reinforce additional traps: raising events before the transaction commits (silent data inconsistency), mutable/entity payloads (serialisation failures), present-tense naming (semantic inversion), and missing timestamps (replay/ordering bugs). These contradict intuitions carried from general event-driven patterns elsewhere, warranting t7.
Also Known As
TL;DR
Explanation
A domain event captures a past fact: OrderPlaced, UserRegistered, PaymentFailed. They are immutable value objects containing all relevant data at the time of occurrence. The aggregate that raises the event does not know who handles it — handlers subscribe independently. This decouples the raising code from side effects (send email, update analytics, reserve inventory). Domain events are distinct from integration events — domain events are internal, integration events cross service boundaries.
Diagram
sequenceDiagram
participant CMD as Command
participant AGG as Aggregate
participant BUS as Event Bus
participant H1 as Email Handler
participant H2 as Analytics Handler
CMD->>AGG: PlaceOrder(items, customer)
AGG->>AGG: Validate and update state
AGG-->>BUS: Publish OrderPlaced event
BUS-->>H1: OrderPlaced - send confirmation email
BUS-->>H2: OrderPlaced - track conversion
Note over AGG,BUS: Aggregate raises event.<br/>Handlers react independently.<br/>No direct coupling.
Common Misconception
Why It Matters
Common Mistakes
- Domain events that contain mutable objects or database entities — they must be serialisable and self-contained.
- Raising events before the transaction commits — if the transaction rolls back, the events have already fired.
- Events named in the present tense (OrderPlacing) instead of past tense (OrderPlaced) — events record facts, not intentions.
- Not recording when the event occurred — timestamp is essential for event ordering and replay.
Code Examples
// Side effects coupled directly to domain logic:
class Order {
public function ship(): void {
$this->status = 'shipped';
// Direct calls — every new side effect requires modifying this class:
app(Mailer::class)->sendShippingNotification($this);
app(Analytics::class)->track('order_shipped', $this->id);
app(SlackNotifier::class)->notify('Order ' . $this->id . ' shipped');
}
}
// Domain event — decoupled side effects:
class OrderShipped {
public function __construct(
public readonly string $orderId,
public readonly string $trackingNumber,
public readonly DateTimeImmutable $occurredAt,
) {}
}
class Order {
private array $events = [];
public function ship(string $trackingNumber): void {
$this->status = 'shipped';
$this->events[] = new OrderShipped($this->id, $trackingNumber, new DateTimeImmutable());
}
public function releaseEvents(): array {
$events = $this->events; $this->events = [];
return $events;
}
}