Skip to content
Muhammet Şafak
tr
Updated 3 min read

Event-Driven Architecture: What It Solves and Hides

The price of loose coupling through events: balancing the flexibility it buys against the risk of making your system's flow impossible to trace

TL;DR

Events buy loose coupling by letting the producer stay unaware of its consumers, and the same move makes the flow invisible: no compiler verifies an event contract the way it verifies a method call. Two costs are paid up front or not at all — versioned, schema-validated payloads, and a correlation id carried into every log and every downstream event. A single-listener event is a method call in disguise.

Event-Driven Architecture: What It Solves and Hides — cover image

I once watched a team where no one could give a straight answer to “what happens when an order is created?” In the codebase, eight separate listeners were subscribed to the OrderCreated event; you couldn’t read from any single place which one ran in which order, under which condition. The flow didn’t live in the code — it lived in people’s heads, and incompletely at that.

Event-driven architecture loosens coupling. This post is a reminder that something else loosens along with it: causality.

What do events solve?

Code that publishes an event doesn’t know who listens to it. OrderCreated is published; billing, notifications, and analytics each listen to it separately. The order code is unaware of all three.

The benefit is real: adding a new consumer happens without touching the producer. Independent development, independent deployment, fan-out. Parts that don’t know each other.

What do events hide?

The flip side of the same coin: as coupling loosens, the flow becomes invisible.

In a direct method call, the flow is the code — you go to the definition and read on. Not so with an event. To see what happens when OrderCreated is published, you have to find every listener by hand. The compiler won’t help you: a compiler verifies the contract of a method call, but no one verifies the contract of an event.

The line from the modular monolith post applies here too — like a network contract, an event contract sits in the compiler’s blind spot.

Don’t choose it before paying: two prerequisites

Before moving to an event-driven architecture, you pay two costs up front.

Schema discipline. An event is a contract. When the payload of OrderCreated changes, the eight places listening to it won’t know — not until it breaks in production. Events must be versioned, fields must be added in a backward-compatible way, payloads must be validated against a schema. Without this discipline, loose coupling means “silent breakage.”

Traceability. To be able to see a request travel through the system, every event must carry a correlation ID:

event(new OrderCreated(
    orderId: $order->id,
    correlationId: request()->header('X-Correlation-Id') ?? (string) Str::uuid(),
));

If this ID isn’t carried into the logs and into subsequent events, then in a multi-listener flow there is no answer to “why did this job run?” Tracing isn’t an optional add-on to event-driven; it’s a prerequisite.

In Laravel: in-process events ≠ event-driven architecture

Laravel’s event()/listener mechanism, when it runs synchronously, is really an organized method call — same process, same transaction, same stack trace. This is safe and traceable; use it freely.

The difficulties event-driven architecture brings start when an event crosses a boundary: into a queue, into a message broker, into another process. The moment you add ShouldQueue to a listener and send the event to RabbitMQ, the schema and tracing debt kicks in. Don’t conflate the two: using in-process listeners does not move you into “event-driven architecture.”

When should you move to event-driven?

Actually carrying an event across a boundary is justified in these cases:

  • There’s a real fan-out. A single event is listened to by many independent consumers that shouldn’t know about each other.
  • The consumer must be asynchronous. The work shouldn’t keep the producer waiting for a response; “accepted” is enough.
  • The consumers must scale or be distributed independently. This also brings us to the microservices decision — the same measured-signal threshold.

Without these, a direct service call is both more readable and safer. An event with a single listener is just a method call in disguise — and a harder one to trace, at that.


Event-driven architecture loosens coupling; in doing so, it loosens causality too. If you want the first, pay the bill for the second — schema and tracing — up front.

Loose coupling isn’t free; you pay for it with invisible flow.

Frequently asked

2 questions

  • Does using in-process Laravel events mean you have an event-driven architecture?

    No. A synchronous listener is an organized method call — same process, same transaction, same stack trace — and it is safe and traceable. The difficulties start when the event crosses a boundary into a queue, a broker or another process; that is where the schema and tracing debt kicks in.

  • What has to be paid for before moving to event-driven?

    Two things, up front. Schema discipline: an event is a contract, so it must be versioned, extended in a backward-compatible way and validated against a schema, or loose coupling just means silent breakage. And traceability: every event carries a correlation id into the logs and into the events it triggers, otherwise there is no answer to why a job ran.

Sources

Related

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind