Blog

Designing Services That Talk Through Events, Not Calls

Event-driven architecture replaces brittle chains of synchronous calls with services that publish facts and react independently, trading immediate consistency for resilience and decoupling.

April 21, 20266 min readMuhammad Shehzaib
ARCHITECTUREKAFKAEVENT-DRIVENMICROSERVICES

Most backend developers start with synchronous, request-response thinking: the order service calls the inventory service, which calls the payment service, and everyone waits in a chain. It works until one service slows down and the whole call stack times out, or until you need to add a fifth consumer of order data and find yourself editing the order service again. Event-driven architecture flips the direction of dependency. Instead of services commanding each other, they publish events describing facts that already happened, and interested services react on their own schedule. The order service announces order-placed and moves on; inventory, billing, and notifications each respond without the order service knowing they exist. This is not just a Kafka feature, it is an architectural stance with real tradeoffs. Let us walk through the patterns that make it work and the failure modes that make it hard.

Events Versus Commands

The first distinction to internalize is between an event and a command. A command is an instruction to do something, like charge-payment, directed at a specific service, and it expects that service to act. An event is a statement that something already happened, like payment-charged, broadcast to whoever cares, with no expectation about who listens. This sounds pedantic but it shapes coupling profoundly. When you send commands, the sender knows and depends on the receiver, recreating the tight coupling you were trying to escape. When you publish events, the producer is ignorant of consumers, so you can add, remove, or change reactors without touching the producer. A healthy event-driven system is mostly events with commands used sparingly for genuine directives. Naming matters too: name events in the past tense to reinforce that they describe history, not requests, which keeps the team thinking in facts.

Choreography Versus Orchestration

There are two ways to coordinate a multi-step workflow across services. In choreography, each service listens for events and reacts by emitting its own events, with no central controller; the workflow emerges from the chain of reactions. It is highly decoupled but the overall process exists only implicitly, scattered across services, which makes it hard to see and debug. In orchestration, a central coordinator explicitly drives the steps, telling each service what to do and tracking progress. Orchestration is easier to reason about and monitor but reintroduces a coordinating dependency. Neither is universally right. Short, stable flows often suit choreography, while complex flows with many compensating steps benefit from an orchestrator. A common mature pattern is choreography for loosely related reactions and a dedicated orchestrator, sometimes implemented as a saga, for the critical business transaction that must complete or roll back cleanly.

The Saga Pattern

Once you abandon synchronous calls, you also abandon distributed ACID transactions across services, because there is no shared transaction to commit. The saga pattern fills the gap. A saga models a business transaction as a sequence of local transactions, each in its own service, where every step has a compensating action that undoes it. If booking a trip means reserving a flight, a hotel, and a car, and the car reservation fails, the saga triggers compensations to cancel the hotel and flight. There is no rollback in the database sense; you achieve consistency by deliberately applying inverse operations. This is more work than a single transaction and forces you to design compensations up front, but it is the only realistic way to maintain consistency across independently deployed services. Sagas come in choreographed and orchestrated flavors, mirroring the broader coordination choice you already faced.

The Outbox Pattern

A subtle bug haunts naive event-driven services: the dual-write problem. Your service updates its database and then publishes an event to Kafka, but these are two separate systems. If the database commit succeeds and the publish fails, or the reverse, your state and your events disagree, and downstream services act on a reality that never fully happened. The transactional outbox pattern solves this. Instead of publishing directly, you write the event into an outbox table in the same database transaction as your state change, so they commit atomically. A separate process, often change-data-capture tooling like Debezium, reads the outbox and publishes to Kafka reliably. Now there is a single atomic commit and the event publication is guaranteed to follow eventually. For a MERN developer this is the cure for the classic save-then-emit race, and it is foundational to trustworthy event-driven systems.

Embracing Eventual Consistency

Synchronous systems give you read-your-writes consistency almost for free: you update a record and the next read reflects it immediately. Event-driven systems usually do not. When the order service publishes order-placed, the inventory and reporting services update their own views moments later, so for a brief window different services hold different truths. This eventual consistency is not a defect; it is the price of decoupling and the source of resilience, since services keep working even when their peers are briefly behind. The hard part is designing user experiences and APIs that tolerate it. You might show an optimistic UI, return a pending status, or surface a processing state rather than pretending the action is fully complete. Fighting eventual consistency by adding synchronous checks usually reintroduces the coupling you escaped. Designing for it deliberately, and communicating it to product teams, is a core skill in this style.

Schemas And Contracts

When services communicate by events, the event schema becomes the contract between them, and an unmanaged contract is a future outage. If a producer renames a field or changes a type, every consumer parsing that field can break, and because consumers are decoupled, you may not even know who they are. A schema registry, paired with a format like Avro or Protobuf, enforces compatibility rules so producers cannot publish a change that breaks existing readers. Backward-compatible evolution, such as adding optional fields rather than removing or renaming required ones, lets producers and consumers deploy independently. Treat your events as a public API with versioning discipline, not as internal JSON you can reshape freely. The decoupling that makes event-driven architecture powerful also hides your dependencies, so explicit, enforced schemas are what keep that hidden web from quietly tearing apart during a routine deploy.

Handling Failure And Poison Messages

In a synchronous call, a failure returns an error to the caller who decides what to do. In an event-driven flow, a consumer that cannot process a message must decide for itself, and a single bad message can stall an entire partition if the consumer retries forever. The standard tools are retries with backoff for transient failures and a dead-letter topic for messages that repeatedly fail. After a bounded number of attempts, you route the poison message to a dead-letter topic for later inspection, allowing the consumer to advance and keep processing healthy traffic. You also need idempotent consumers, since redelivery is normal here. Observability matters more than in synchronous systems because failures are silent and asynchronous; you cannot see them in a request trace. Instrument consumer lag, dead-letter volume, and processing errors so that a quietly stuck consumer becomes visible before it becomes an incident.

When Not To Go Event-Driven

Event-driven architecture is not free, and applying it everywhere is a common overcorrection. It adds operational complexity, demands schema governance, makes end-to-end flows harder to trace, and forces you to reason about eventual consistency and duplicate handling everywhere. For a simple CRUD application with one service and immediate-consistency needs, a synchronous design is clearer and cheaper. The pattern earns its keep when you have multiple services that must react to the same facts, when you need to add consumers without modifying producers, when bursty load benefits from buffering, or when temporal decoupling lets services fail independently. A pragmatic path is to start synchronous, identify the genuine integration seams where decoupling pays off, and introduce events there rather than rewriting everything. Adopt event-driven thinking as a targeted tool for specific problems, not as a default architecture you impose on every service you build.