You ship a new feature. A week later, the product team wants to run a campaign when a user hits a milestone. Two weeks after that, marketing needs to fire a webhook when an order is placed. Then compliance wants an audit log for every payment. Then customer success needs an email sequence triggered by login frequency.
Each request is reasonable. Each one is a Sunday afternoon of adding a Mailchimp::subscribe() call here and a Slack::notify() call there. After six months, your âplace orderâ method is 400 lines long, and nobody knows what side effects it has.
Thereâs a better way.
Event-driven programming decouples the thing that happens from the things that should happen next. Your core business logic fires an event and moves on. Listeners pick up that event and do their work independently. The result is a codebase that bendsânot breaksâunder changing requirements.
What Is Event-Driven Programming?
Event-driven programming is a paradigm where the flow of a program is determined by eventsâsignificant occurrences that your application needs to respond to. Instead of calling code directly, your application emits events and lets interested parties react.
Three core concepts make this work:
Events are messages that represent something that happened. A user registered. An order was placed. A payment failed. Events are immutable records of past occurrences. Their names use the past tense because the thing already happenedâyou canât change it, only react to it.
Listeners (or subscribers) are units of code that respond to specific events. A listener receives the event object and performs an action: send an email, update a cache, log to a file, dispatch a webhook. Multiple listeners can respond to the same event, and no listener knows about the others.
Dispatchers are the central nervous system. They receive events, match them to registered listeners, and invoke each listener in order. The dispatcher is the only piece of the system that knows both sides exist.
Hereâs how they interact:
// The dispatcher orchestrates everything
$dispatcher = new EventDispatcher();
// Register listeners
$dispatcher->addListener(UserRegistered::class, new SendWelcomeEmail());
$dispatcher->addListener(UserRegistered::class, new CreateSlackChannel());
// Fire an event
$dispatcher->dispatch(new UserRegistered(
userId: 42,
email: '[email protected]',
name: 'Jane Doe',
));
// Both listeners run. Neither knows about the other.
// The code that dispatched the event doesn't know what listeners exist.The code that dispatched UserRegistered has no idea that welcome emails or Slack channels exist. It just says âa user registered.â The listeners handle the rest. This is the essence of event-driven programming: the emitter and the reactor donât know each other exist.
Events as First-Class Citizens
An event is a plain PHP object. It carries data relevant to what happened and nothing else. No behavior. No business logic. Just state.
final class UserRegistered
{
public function __construct(
public readonly int $userId,
public readonly string $email,
public readonly string $name,
public readonly \DateTimeImmutable $occurredAt,
) {
}
}
final class OrderPlaced
{
public function __construct(
public readonly string $orderId,
public readonly int $userId,
public readonly array $lineItems,
public readonly Money $total,
public readonly \DateTimeImmutable $occurredAt,
) {
}
}
final class PaymentReceived
{
public function __construct(
public readonly string $transactionId,
public readonly string $orderId,
public readonly Money $amount,
public readonly \DateTimeImmutable $occurredAt,
) {
}
}Every event carries an occurredAt timestamp. This is not optional. Events represent something that happened at a specific point in time. Losing the temporal context means losing the meaning of the event itself.
Events use readonly properties and constructor promotion. They are immutable by design. Once created, an event cannot change. If something else happens, create a new event.
PSR-14: The Event Dispatcher Standard
PHP-FIG recognized the need for interoperability in event-driven systems and released PSR-14 (Psr\EventDispatcher). It defines three interfaces:
EventDispatcherInterfaceâ dispatches an event to all registered listenersListenerProviderInterfaceâ provides listeners for a given eventStoppableEventInterfaceâ allows an event to stop propagation
The standard separates two concerns. The provider knows which listeners exist for which events. The dispatcher takes an event, asks the provider for relevant listeners, and calls each one. This separation lets you swap implementations without changing your application code.
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\StoppableEventInterface;
// Your application code depends only on the interfaces
final class RegisterUserUseCase
{
public function __construct(
private readonly UserRepository $users,
private readonly EventDispatcherInterface $dispatcher,
) {
}
public function handle(RegisterUserCommand $command): User
{
$user = User::register(
email: $command->email,
name: $command->name,
);
$this->users->save($user);
$this->dispatcher->dispatch(new UserRegistered(
userId: $user->id,
email: $user->email,
name: $user->name,
));
return $user;
}
}The use case depends on EventDispatcherInterface, not a concrete implementation. You can swap Symfonyâs dispatcher for a test double or a lightweight custom implementation without changing the use case. Thatâs the value of a standard.
StoppableEventInterface lets an event halt propagation. If a validation listener determines that an order cannot be processed, it can mark the event as stopped, and no further listeners run:
final class OrderPlaced implements StoppableEventInterface
{
private bool $stopped = false;
// ... constructor ...
public function isPropagationStopped(): bool
{
return $this->stopped;
}
public function stopPropagation(): void
{
$this->stopped = true;
}
}
final class ValidateInventory
{
public function __invoke(OrderPlaced $event): void
{
if (! $this->inventory->hasStock($event->lineItems)) {
$event->stopPropagation();
// Log insufficient inventory
}
}
}Building a Simple Event Dispatcher
Understanding the internals of an event dispatcher makes you a better architect. Hereâs a minimal implementation that covers the core concepts:
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\StoppableEventInterface;
final class SimpleListenerProvider implements ListenerProviderInterface
{
/** @var array<string, array<callable>> */
private array $listeners = [];
public function addListener(string $eventClass, callable $listener): void
{
$this->listeners[$eventClass][] = $listener;
}
/** @return iterable<callable> */
public function getListenersForEvent(object $event): iterable
{
$eventClass = $event::class;
return $this->listeners[$eventClass] ?? [];
}
}
final class SimpleEventDispatcher implements EventDispatcherInterface
{
public function __construct(
private readonly ListenerProviderInterface $provider,
) {
}
public function dispatch(object $event): object
{
foreach ($this->provider->getListenersForEvent($event) as $listener) {
if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
break;
}
$listener($event);
}
return $event;
}
}Usage:
$provider = new SimpleListenerProvider();
$dispatcher = new SimpleEventDispatcher($provider);
$provider->addListener(
UserRegistered::class,
function (UserRegistered $event): void {
$this->mailer->sendWelcomeEmail($event->email);
},
);
$provider->addListener(
UserRegistered::class,
function (UserRegistered $event): void {
$this->slack->notifyChannel("#new-users", "{$event->name} registered!");
},
);
$dispatcher->dispatch(new UserRegistered(
userId: 1,
email: '[email protected]',
name: 'Alice',
));This dispatcher does exactly what it needs to do. It gets listeners from the provider, iterates them, calls each one, and respects stoppable events. Thereâs no magic, no configuration files, no dependency injection container required.
A production dispatcher would add error handling, priority-based ordering, and possibly asynchronous execution. But the coreâforeach over listeners, call each oneâis the same.
The Symfony EventDispatcher Component
Symfonyâs EventDispatcher is the de facto standard in the PHP ecosystem. It predates PSR-14 and is fully compatible with it. Symfony extends the pattern with three key features: priority-based listeners, subscriber classes, and event naming using strings.
Event Subscribers
Instead of registering listeners one at a time, a subscriber tells the dispatcher which events it handles:
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class UserNotifier implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
UserRegistered::class => ['onUserRegistered', 0],
OrderPlaced::class => ['onOrderPlaced', 10],
PaymentReceived::class => ['onPaymentReceived', -5],
];
}
public function onUserRegistered(UserRegistered $event): void
{
$this->mailer->sendWelcomeEmail($event->email);
}
public function onOrderPlaced(OrderPlaced $event): void
{
$this->mailer->sendConfirmation($event->orderId, $event->email);
}
public function onPaymentReceived(PaymentReceived $event): void
{
$this->mailer->sendReceipt($event->transactionId, $event->amount);
}
}The integer after the method name is the priority. Higher values run first. The default is zero. Listeners with priority 10 run before listeners with priority 0, which run before listeners with priority -5. This matters when one listenerâs output is another listenerâs inputâfor example, a normalization listener should run before a logging listener.
Registering Subscribers
use Symfony\Component\EventDispatcher\EventDispatcher;
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new UserNotifier());
$dispatcher->addSubscriber(new AuditLogger());
$dispatcher->dispatch(new UserRegistered(
userId: 42,
email: '[email protected]',
name: 'Jane Doe',
));Subscribers make registration explicit and self-documenting. Open UserNotifier and you see exactly which events it handles and in what priority order.
Domain Events in DDD
Domain-Driven Design formalizes the concept of domain eventsâevents that domain experts care about. A domain event captures something meaningful that happened in the domain. Itâs not âbutton clickedâ or âHTTP request received.â Itâs âInvoicePaidâ and âSubscriptionCancelledâ and âInventoryThresholdReached.â
Domain events serve two purposes within DDD:
Recording what happened. Every domain event is a permanent record. An OrderShipped event, stored in your database, lets you answer questions about what shipped, when, and to whom, without relying on current state.
Cross-aggregate communication. An aggregate (a cluster of domain objects treated as a unit) should not directly modify another aggregate. Instead, it emits a domain event. A separate process picks up that event and tells the other aggregate to update itself.
final class Order
{
private function __construct(
private OrderId $id,
private OrderStatus $status,
private array $lineItems,
private CustomerId $customerId,
/** @var DomainEvent[] */
private array $recordedEvents = [],
) {
}
public static function place(
OrderId $id,
CustomerId $customerId,
array $lineItems,
): self {
$order = new self($id, OrderStatus::PENDING, $lineItems, $customerId);
$order->recordThat(new OrderPlaced(
orderId: $id->toString(),
customerId: $customerId->toString(),
lineItems: array_map(
fn(LineItem $item) => ['sku' => $item->sku, 'quantity' => $item->quantity],
$lineItems,
),
));
return $order;
}
public function markAsPaid(): void
{
if ($this->status !== OrderStatus::PENDING) {
throw new \DomainException('Only pending orders can be paid.');
}
$this->status = OrderStatus::PAID;
$this->recordThat(new OrderPaid(
orderId: $this->id->toString(),
customerId: $this->customerId->toString(),
));
}
/** @return DomainEvent[] */
public function releaseEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
private function recordThat(DomainEvent $event): void
{
$this->recordedEvents[] = $event;
}
}The aggregate records events internally and releases them after persistence. An infrastructure layer collects the released events and dispatches them through the event dispatcher:
final class DoctrineOrderRepository implements OrderRepository
{
public function __construct(
private readonly EntityManager $entityManager,
private readonly EventDispatcherInterface $dispatcher,
) {
}
public function save(Order $order): void
{
$this->entityManager->persist($order);
$this->entityManager->flush();
foreach ($order->releaseEvents() as $event) {
$this->dispatcher->dispatch($event);
}
}
}Events are dispatched after persistence. If the database save fails, no events are dispatched. This prevents phantom events where your code acts on something that never actually persisted.
Event Sourcing Concepts
Event sourcing takes domain events to their logical conclusion: instead of storing current state, store every event that led to the current state. The current state is derived by replaying all events.
final class EventSourcedOrder
{
private function __construct(
private OrderId $id,
private OrderStatus $status = OrderStatus::PENDING,
private array $lineItems = [],
private ?CustomerId $customerId = null,
) {
}
/** Rebuild from stored events */
public static function fromEvents(OrderId $id, DomainEvent ...$events): self
{
$order = new self($id);
foreach ($events as $event) {
$order->apply($event);
}
return $order;
}
public static function place(OrderId $id, CustomerId $customerId, array $lineItems): self
{
$order = new self($id);
$order->apply(new OrderPlaced(
orderId: $id->toString(),
customerId: $customerId->toString(),
lineItems: $lineItems,
));
return $order;
}
private function apply(DomainEvent $event): void
{
match ($event::class) {
OrderPlaced::class => $this->applyOrderPlaced($event),
OrderPaid::class => $this->applyOrderPaid($event),
OrderShipped::class => $this->applyOrderShipped($event),
OrderCancelled::class => $this->applyOrderCancelled($event),
default => throw new \InvalidArgumentException('Unknown event: ' . $event::class),
};
}
private function applyOrderPlaced(OrderPlaced $event): void
{
$this->status = OrderStatus::PENDING;
$this->customerId = new CustomerId($event->customerId);
$this->lineItems = $event->lineItems;
}
private function applyOrderPaid(OrderPaid $event): void
{
$this->status = OrderStatus::PAID;
}
private function applyOrderShipped(OrderShipped $event): void
{
$this->status = OrderStatus::SHIPPED;
}
private function applyOrderCancelled(OrderCancelled $event): void
{
$this->status = OrderStatus::CANCELLED;
}
}Event sourcing gives you a complete audit trail, temporal queries (what did the order look like on Tuesday?), and the ability to rebuild state from scratch. The cost is complexity: you need an event store, projection handlers, and careful schema management.
You donât need full event sourcing to benefit from domain events. Start with dispatching events after persistence. If your audit or rebuilding requirements grow, the events are already in place to support event sourcing later.
Testing Event-Driven Code
Event-driven code is more testable than procedural side-effect code, but it demands a different testing strategy. Hereâs how to approach it.
Unit Test Listeners in Isolation
A listener should be testable without the dispatcher:
final class SendWelcomeEmailTest extends TestCase
{
public function test_sends_welcome_email_on_registration(): void
{
$mailer = $this->createMock(MailerInterface::class);
$mailer
->expects($this->once())
->method('send')
->with($this->callback(function (Email $email): bool {
return $email->subject === 'Welcome!'
&& $email->to === '[email protected]';
}));
$listener = new SendWelcomeEmail($mailer);
$listener(new UserRegistered(
userId: 1,
email: '[email protected]',
name: 'Jane',
));
}
}No dispatcher. No event provider. Just construct the listener with its dependencies, call it with an event, and assert the outcome.
Test Event Emission
Test that your use cases emit the correct events:
final class RegisterUserUseCaseTest extends TestCase
{
public function test_dispatches_user_registered_event(): void
{
$events = [];
$dispatcher = new class implements EventDispatcherInterface {
public array $dispatched = [];
public function dispatch(object $event): object
{
$this->dispatched[] = $event;
return $event;
}
};
$useCase = new RegisterUserUseCase(
users: new InMemoryUserRepository(),
dispatcher: $dispatcher,
);
$useCase->handle(new RegisterUserCommand(
email: '[email protected]',
name: 'Test User',
));
$this->assertCount(1, $dispatcher->dispatched);
$this->assertInstanceOf(UserRegistered::class, $dispatcher->dispatched[0]);
$this->assertSame('[email protected]', $dispatcher->dispatched[0]->email);
}
}An anonymous class implementing EventDispatcherInterface captures dispatched events without bootstrap overhead. Test what events were dispatched and that they carry the right data.
Integration Test the Full Chain
Test the complete flow once: dispatch an event and verify that all listeners produce their intended side effects:
final class UserRegistrationFlowTest extends TestCase
{
public function test_full_registration_flow(): void
{
// Bootstrap real dispatcher with real listeners
$dispatcher = $this->createDispatcher();
$dispatcher->dispatch(new UserRegistered(
userId: 1,
email: '[email protected]',
name: 'Test User',
));
// Assert side effects
$this->assertEmailSentTo('[email protected]');
$this->assertSlackMessageSent('#new-users');
$this->assertUserInCache(userId: 1);
}
}One integration test per major event confirms the wiring is correct. Everything else is faster, narrower unit tests.
Common Pitfalls
Event-driven programming introduces new failure modes. Here are the ones that bite most developers.
Memory Leaks from Accumulated Listeners
Registering listeners inside a request loop (in a long-running process like Swoole or ReactPHP) without clearing them causes listeners to accumulate. Each request adds more listeners to the provider, and they never get removed. After ten thousand requests, your dispatcher iterates ten thousand listeners for every event.
Fix: reset the provider between requests in long-running processes, or register global listeners once during boot and never dynamically add them.
Circular Event Chains
Listener A dispatches Event X. Listener B handles Event X and dispatches Event Y. Listener C handles Event Y and dispatches Event X. Infinite loop.
final class AuditListener
{
public function __invoke(OrderPlaced $event): void
{
$this->dispatcher->dispatch(new EntryCreated(
message: "Order {$event->orderId} placed",
));
}
}
final class SearchIndexListener
{
public function __invoke(EntryCreated $event): void
{
$this->dispatcher->dispatch(new OrderPlaced(
orderId: $this->extractOrderId($event->message),
// ...
));
}
}Prevent this with a recursion guard. Track the current dispatch depth or maintain a set of processed event IDs. If depth exceeds a threshold, throw an exception.
final class RecursionGuardDispatcher implements EventDispatcherInterface
{
private int $depth = 0;
public function __construct(
private readonly EventDispatcherInterface $inner,
private readonly int $maxDepth = 10,
) {
}
public function dispatch(object $event): object
{
if ($this->depth >= $this->maxDepth) {
throw new \RuntimeException(
'Event dispatch depth exceeded. Possible circular event chain.',
);
}
$this->depth++;
try {
return $this->inner->dispatch($event);
} finally {
$this->depth--;
}
}
}Too Many Events
Every set method emits an event. Every HTTP request produces fifty events. Your event log is useless because itâs noise. Event-driven code becomes event-chaos code.
Not everything is an event. A domain event should represent something a business stakeholder cares about. âUserChangedPasswordâ is a domain event. âButtonClickedâ is not. âInvoiceOverdueâ is a domain event. âCacheKeySetâ is not.
If you find yourself dispatching dozens of events per request, step back. Ask: would a non-technical person describe this as a meaningful occurrence? If not, it probably doesnât need to be an event.
Synchronous Listener Failures
By default, listeners run synchronously in the same process. If one listener throws an exception, subsequent listeners never run. The original dispatch also fails, potentially rolling back a database transaction.
$dispatcher->dispatch(new OrderPlaced(/* ... */));
// Listener 1: SendEmail â throws because SMTP server is down
// Listener 2: UpdateInventory â never runs
// Listener 3: NotifyWarehouse â never runsSolutions:
- Wrap each listener invocation in a try-catch so one failure doesnât silence the rest
- Use a queue for side effects that donât need immediate execution (email sending, webhook calls, PDF generation)
- Decide which listeners are critical (must succeed for the operation to be valid) and which are best-effort (fire and forget)
// In a custom dispatcher
foreach ($listeners as $listener) {
try {
$listener($event);
} catch (\Throwable $e) {
$this->logger->error('Listener failed', [
'listener' => $listener::class,
'event' => $event::class,
'exception' => $e,
]);
// Continue to next listener
}
}Listeners that send emails, call external APIs, or generate reports should almost always be async. Put their work on a message queue and dispatch from a worker process. The event itself stays synchronousâit records what happened. The side effects are decoupled in time.
Implicit Dependencies Between Listeners
Two listeners for the same event run in registration order. If Listener A normalizes data and Listener B sends it to a third party, they have an implicit dependency. Changing the registration order breaks the system silently.
Make dependencies explicit. If a listener needs processed data, process the data before dispatch, or create a specific event stage with separate priorities. Better yet, make each listener self-sufficient.
Real-World Example: Order Processing Pipeline
Letâs wire together a complete example. An order is placed. Multiple systems need to react.
final class PlaceOrderUseCase
{
public function __construct(
private readonly OrderRepository $orders,
private readonly EventDispatcherInterface $dispatcher,
) {
}
public function handle(PlaceOrderCommand $command): Order
{
$order = Order::place(
id: OrderId::generate(),
customerId: new CustomerId($command->customerId),
lineItems: $command->lineItems,
);
$this->orders->save($order);
// Dispatch after save guarantees event only fires on success
$this->dispatcher->dispatch(new OrderPlaced(
orderId: $order->id()->toString(),
customerId: $command->customerId,
total: $order->total(),
lineItems: $command->lineItems,
occurredAt: new \DateTimeImmutable(),
));
return $order;
}
}Registration of listeners:
// infrastructure/registration.php
$dispatcher->addListener(OrderPlaced::class, new SendOrderConfirmation($mailer));
$dispatcher->addListener(OrderPlaced::class, new UpdateInventory($inventoryService));
$dispatcher->addListener(OrderPlaced::class, new ChargeCustomer($paymentGateway));
$dispatcher->addListener(OrderPlaced::class, new NotifyWarehouse($slack));
$dispatcher->addListener(OrderPlaced::class, new RecordAnalytics($analytics));Each listener handles one concern. Adding a new requirementâsay, âcreate a support ticket for orders over $500ââmeans writing a new listener and registering it. No changes to PlaceOrderUseCase. No changes to existing listeners.
final class CreateSupportTicketForHighValueOrders
{
public function __construct(
private readonly TicketSystem $tickets,
) {
}
public function __invoke(OrderPlaced $event): void
{
if ($event->total->amount > 50000) { // $500.00 in cents
$this->tickets->create(
title: "High-value order {$event->orderId}",
priority: Priority::HIGH,
metadata: [
'order_id' => $event->orderId,
'customer_id' => $event->customerId,
'total' => $event->total->amount,
],
);
}
}
}
// Register it
$dispatcher->addListener(
OrderPlaced::class,
new CreateSupportTicketForHighValueOrders($ticketSystem),
);Zero modification to existing code. Thatâs the power of event-driven architecture.
Event-Driven Architecture Benefits
Loose coupling. The use case that places an order doesnât import mailer classes, HTTP clients, or Slack SDKs. It depends on EventDispatcherInterface and OrderRepository. Everything else is a listener registered elsewhere.
Extensibility. Adding behavior means adding listeners, not modifying existing classes. You can ship a new integration in a separate deployment without touching core business logic. This is particularly valuable in modular monoliths and plugin-based systems.
Single responsibility. Each listener does exactly one thing. SendOrderConfirmation sends email. UpdateInventory updates stock. NotifyWarehouse posts to Slack. You can test, deploy, and reason about each one independently.
Audit and observability. Every meaningful occurrence in your system produces an event. Log those events and you have a complete record of what happened and when. You can replay events to debug issues, rebuild caches, or generate reports.
Graceful degradation. If the email service is down, the order still places. The failed listener logs an error and moves on. The customer gets their confirmation when the email service recovers. The core operationâplacing an orderânever depends on the success of its side effects.
When Event-Driven Architecture Hurts
Event-driven code is not free. It adds indirection. Finding the chain of behavior for a single operation requires hunting through listener registrations, subscriber classes, and priority configurations.
Use events when:
- You have multiple independent reactions to the same occurrence
- New reactions are added regularly by different teams
- You need to maintain an audit trail
- Cross-cutting concerns (logging, metrics, notifications) are polluting your business logic
Skip events when:
- One thing happens, and one thing reacts, and thatâs the whole flow
- Performance requirements demand minimal dispatch overhead
- The team is small and the requirements are stable
- Youâre inside a tight loop processing thousands of items per second
Event-driven architecture is a tool, not a goal. Use it where it solves real coupling problems. Leave it out where direct method calls are clearer and faster.
Best Practices Summary
Use past tense for event names. UserRegistered, OrderPlaced, PaymentFailed. The event describes something that already happened. You canât unregister a user or unplace an order. The language reinforces the semantics.
Keep events immutable. Readonly properties, no setters, no behavior. An event is data with a timestamp.
Dispatch after persistence. Events should fire only after the database confirms the write. This prevents phantom events that trigger side effects for operations that never actually completed.
Name events after the domain, not the implementation. UserRegistered, not DatabaseRowInserted. The event should mean something to a domain expert, not just to a developer.
Handle listener failures gracefully. One failed listener should not prevent other listeners from running. Log the failure, notify operations, and continue.
Keep listeners stateless where possible. A listener should not maintain internal state between invocations. If you need to track state, use the event data or an external store.
Document event contracts. Each eventâs properties and their meanings should be documented. New listeners depend on this contract. Breaking changes to an event break every listener.
Final Thoughts
Event-driven programming changes how you think about code. Instead of âI need to do X, then Y, then Z,â you start thinking âX happened. What needs to react?â The difference is subtle but transformative.
Your core business logic becomes stable. It describes the rules: users register, orders place, payments process. The reactionsâemails, notifications, analytics, integrationsâlive outside the core, attached by events. New requirements plug in without disturbing existing behavior.
Start small. Pick one operation that has multiple side effects. Extract the event. Register listeners. Watch how the coupling dissolves. Youâll find yourself reaching for events whenever you encounter a method that âalso does this other thing.â
The pattern scales from a single file with two listeners to a distributed system with thousands of event types. The fundamental mechanics are the same: something happened, and the code that cares about it gets to react, independently, without asking permission from the code that made it happen.