Contents

Event-driven architecture is an approach in which parts of a system communicate by publishing events about what happened: "order paid", "user signed up". Other parts subscribe to the events they care about and react on their own. This reduces coupling: the payments module does not need to know about the warehouse, notifications or analytics. The price is asynchrony, which has to be made reliable.

Here is how events differ from commands, how to avoid losing an event, how to handle redelivery and when the approach is overkill.

What is event-driven architecture?

Event-driven architecture builds a system around the facts that happen in it. Instead of a chain of direct calls — "payments calls the warehouse, the warehouse calls delivery" — the payments module publishes an event, and the warehouse, delivery and notifications handle it independently.

What it gives you:

  • loose coupling — new reactions to an event are added without changing the source;
  • resilience — if the notification service is down, payments keep working and events wait for it;
  • scaling — heavy handlers run in the background and scale separately;
  • history — the event stream shows what happened in the system and when.

How does an event differ from a command?

An event reports a fact that has happened and expects no reply; a command asks someone to perform a specific action. Mixing them up is a common cause of tangled architecture: if an "event" actually requires a specific result from a specific recipient, it is a command.

Trait Event Command
Tense Past: OrderPaid Imperative: ReserveStock
Recipients Any number; the source does not know them One specific handler
Reply Not expected A result or error is expected
Can the recipient refuse No, the fact already happened Yes

On drawing module boundaries, see Domain-Driven Design explained.

Numbered mail envelopes on wooden shelves Photo: Unsplash

How do you avoid losing an event?

The classic mistake is to save an order to the database and then immediately send an event to the broker: if the application crashes in between, the order exists but the event does not. The fix is the Transactional Outbox pattern: the event is written to a table in the same transaction as the data change, and a separate process delivers it to the broker.

DB::transaction(function () use ($order, $payment) {
    $order->markPaid($payment);
    $order->save();

    OutboxMessage::create([
        'type'    => 'order.paid',
        'payload' => ['order_id' => $order->id, 'amount' => $payment->amount],
    ]);
});

// A separate process reads unsent outbox rows,
// publishes them to the broker and marks them as sent

The event is then guaranteed to appear if the data change was saved, and not to appear if the transaction rolled back.

How do you handle redelivery and ordering?

Brokers usually guarantee at-least-once delivery, so one event may arrive twice. Handlers are made idempotent: before processing they check whether an event with that ID was already handled. More on the technique in idempotent payments and webhooks.

Rules for reliable processing:

  1. Every event has a unique ID and a creation time.
  2. The handler records the IDs of processed events.
  3. If order matters, events for one entity go to the same partition or queue.
  4. Failing events go to a separate queue after several retries.
  5. The event format is versioned, and old versions are supported while consumers exist.

We compared brokers in Kafka vs RabbitMQ.

When is event-driven architecture overkill?

It is overkill when one small team owns the system, business processes are simple and linear, and users need an immediate, consistent result. Direct calls are then easier to write, read and debug.

Signs you do not need events yet:

  • an action has one reaction and that will not change;
  • the user needs the result in the same request;
  • there is no load that justifies moving work to the background;
  • there is no monitoring or tracing — without them an asynchronous system is hard to maintain.

A good middle ground is events inside a monolith using the framework's built-in mechanism and job queues. If part of the system later becomes a separate service — see monolith vs microservices — the events will already be in place.

In short

  • Event-driven architecture connects parts of a system through facts rather than direct calls.
  • An event reports what happened; a command asks for an action.
  • The Outbox pattern guarantees an event is not lost between the database and the broker.
  • Handlers must be idempotent, and failing events must go to a separate queue.
  • For simple processes and small teams, events inside a monolith are enough.

FAQ

  • Does event-driven architecture require a message broker?

    Within a single application, events can be handled by the framework's built-in mechanism and a job queue. A broker becomes necessary when events travel between separate services or teams.

  • How do you debug an event-driven system?

    You need a correlation ID passed along with each event, a log of published and processed events, and tracing. Without them the chain of cause and effect is very hard to reconstruct.

  • What happens to an event that cannot be processed?

    After several retries it goes to a separate dead-letter queue and the team is alerted. Once the bug is fixed, the event can be reprocessed.