DEV Community

Cover image for Event-Driven Architecture Explained: Events, Commands, and the Tradeoffs Nobody Mentions
Arnav Sharma
Arnav Sharma

Posted on

Event-Driven Architecture Explained: Events, Commands, and the Tradeoffs Nobody Mentions

Event-driven architecture: what happens after "payment succeeded"

Your checkout handler used to do two things. Charge the card, save the order. Ship it.

Then product asked for a confirmation email. Fine, three things. Then analytics wanted a purchase event. Then the warehouse needed a stock reservation. Then the loyalty team added points accrual. Then fraud detection wanted a copy. Then someone said "we should notify the seller too."

Now your handler does seven things after payment succeeds, and if the email service is slow, the customer stares at a spinner while all seven finish in sequence. One fails? The whole request blows up. Not great.

This is the exact pain point that pushes teams toward event-driven architecture. Instead of one handler calling seven services synchronously, you announce "hey, payment succeeded" and let each service react on its own.


Events vs commands

First distinction that matters: an event is a fact about something that already happened. "OrderPlaced." Immutable. Past tense. The producer doesn't know or care who's listening.

A command is an imperative. "PlaceOrder." It's directed at a specific service and expects a result.

The difference sounds academic until you realize it changes coupling entirely. A command ties you to the receiver. An event doesn't. Your checkout service publishes "PaymentSucceeded" and moves on. Whether three services or thirty react to it, the checkout service doesn't change.

And the envelope carrying either one? That's just a message. The broker (Kafka, SQS, EventBridge, whatever) routes messages. It doesn't care about the semantics inside.

Three flavors of event

Martin Fowler identified four patterns that people lump under "event-driven." Three of them are actually about events, and they solve different problems.

Event notification is the thin version. The event carries just an ID and a type: { "type": "OrderPlaced", "orderId": "abc-123" }. Receivers call back to the source if they need details. Low coupling, but chatty.

Event-carried state transfer is the fat version. The event includes the full state delta: order total, line items, shipping address. Receivers cache this locally and never call back. Great for resilience. But now every consumer is coupled to your schema.

Event sourcing goes further. You don't store current state at all. You store every event that ever happened, in order, and rebuild state by replaying them. Think git commits. You can reconstruct any past state, get a full audit trail, and answer "what did this look like last Tuesday?" The cost is complexity and storage.

Which flavor you pick depends on the problem. Most teams start with notification, move to state transfer when the callback traffic gets annoying, and reach for event sourcing only when audit or temporal queries are a hard requirement.

⚡ Choreography vs orchestration

Once events flow, you need a topology. Two options.

Choreography is decentralized. Services react to events and emit their own events. Nobody's in charge. OrderPlaced triggers PaymentService, which emits PaymentConfirmed, which triggers InventoryService, which emits StockReserved. It's like a dance where everyone knows their part.

// Each service just listens and reacts
import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";

const sns = new SNSClient({});

async function onPaymentConfirmed(event: PaymentEvent) {
  const reservation = await reserveStock(event.data.orderId);

  // announce what happened, move on
  await sns.send(new PublishCommand({
    TopicArn: process.env.INVENTORY_TOPIC_ARN,
    Message: JSON.stringify({
      eventId: crypto.randomUUID(),
      type: "StockReserved",
      data: { orderId: event.data.orderId, warehouseId: reservation.warehouseId },
    }),
  }));
}
Enter fullscreen mode Exit fullscreen mode

Simple fanouts? Choreography works beautifully. But the flow is implicit. When something breaks, figuring out what happened means tracing events across five services with correlation IDs. Painful.

Orchestration puts a central coordinator in charge. Step Functions, Temporal, Conductor. The orchestrator calls each step, handles retries, and owns the rollback logic. You get a visible workflow graph and explicit error handling. But now that coordinator is a single point of logic (though not necessarily a single point of failure).

So: choreography for simple fan-outs with few steps. Orchestration for complex multi-step flows where ordering matters or where you need compensation logic. Most real systems use both. The order fan-out is choreographed, but the payment+inventory+shipping sequence might be orchestrated.

🎯 What actually gets harder

I'm not going to pretend this is all upside. Event-driven architecture trades one set of problems for another.

Debugging causality. There's no request trace anymore. A customer says "my order didn't go through" and you're grepping across six services for a correlation ID. You need distributed tracing (OpenTelemetry) and correlation IDs on every event from day one. Retrofitting this is miserable.

Eventual consistency the user can see. "I placed an order but it's not showing up." The event hasn't propagated yet. Your UI needs to handle this honestly — optimistic updates, polling, or just telling the user "processing, check back in a moment." Pretending it's instantaneous will create support tickets.

Testing gets weird. Async delivery, non-deterministic ordering, retries firing at random intervals. Integration tests for event-driven flows are harder to write and flakier to maintain. Contract tests on event schemas help, but they're not a substitute for end-to-end verification.

Monitoring is different. Consumer lag, dead letter queue depth, processing latency. These are your new first-class metrics. If you don't watch them, you'll find out about problems from angry users instead of dashboards.

🛠️ The dual-write problem

Here's a trap that bites almost every team the first time. Your service needs to save an order to the database AND publish an "OrderPlaced" event. Two writes to two different systems.

What if the DB write succeeds but the publish fails? You've got an order with no event. Downstream services never find out. What if you publish first and the DB write fails? You've announced something that didn't actually happen.

This is the dual-write problem, and "just retry" doesn't fix it. You can't get atomicity across a database and a message broker without some pattern.

The standard fix is the transactional outbox. Instead of publishing directly, you write the event to an outbox table in the same database transaction as your business data. One atomic write. A separate relay process polls the outbox (or uses CDC to tail the transaction log) and publishes events to the broker.

// Same DB transaction = atomic
async function placeOrder(order: Order, db: Pool) {
  const client = await db.connect();
  try {
    await client.query("BEGIN");
    await client.query(
      "INSERT INTO orders (id, user_id, total, status) VALUES ($1, $2, $3, $4)",
      [order.id, order.userId, order.total, "confirmed"]
    );
    // event goes in the same transaction
    await client.query(
      "INSERT INTO outbox (event_id, event_type, payload) VALUES ($1, $2, $3)",
      [crypto.randomUUID(), "OrderPlaced", JSON.stringify(order)]
    );
    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

Both rows land or neither does. The relay picks up new outbox rows and publishes them. If the relay fails, it retries from where it left off. Consumers still need to be idempotent (since at-least-once delivery means duplicates are normal), but that's a topic for a dedicated post on idempotency patterns.

Scope and where to go from here

I've intentionally kept this post at the conceptual level. There are rabbit holes everywhere.

Message queues (Kafka, RabbitMQ, SQS) each have different delivery guarantees and ordering behavior. Choosing between them matters. Idempotent consumers need a deduplication strategy. Retries need backoff and dead letter queues. And when multiple services need to coordinate rollbacks, you're in saga territory — a pattern that's been around since Garcia-Molina and Salem named it in 1987.

Each of those deserves its own post. For now, the mental model is what matters: events decouple, topology shapes your failure modes, and the dual-write problem will bite you if you don't address it upfront.

If you're already using Kafka and want to understand how partitions and consumer groups affect your event flow, I wrote about that in depth here.


📌 Takeaways

  • Events are facts (past tense, immutable). Commands are directives. Don't confuse them.
  • Pick the right event flavor: notification for low coupling, state transfer for resilience, sourcing for audit trails
  • Choreography works for simple fan-outs. Orchestration wins for complex multi-step flows with rollback needs.
  • The dual-write problem is real. Use a transactional outbox.
  • Eventual consistency isn't a bug to hide. Design your UI around it.

More from me

More posts on distributed systems and backend architecture at arnavsharma.dev.

Top comments (0)