Event-driven architecture has become fashionable, which means it’s being applied in situations where it doesn’t fit. Events are a powerful architectural tool for specific problems. They’re a significant operational burden when applied indiscriminately.
Understanding when events help and when they hurt requires understanding what problems they actually solve.
Events vs Commands
The distinction matters more than most tutorials acknowledge.
A command expresses intent: “place this order,” “cancel this payment,” “reserve this inventory item.” It’s addressed to a specific recipient and implies that recipient is responsible for handling it. Commands can be rejected. They have an expected response.
An event expresses something that has already happened: “order was placed,” “payment was cancelled,” “inventory item was reserved.” Events are facts about the past. They’re not addressed to specific recipients. They cannot be rejected — they already happened.
This distinction affects design significantly:
// Command — creates a response obligation
public interface OrderCommandHandler {
PlaceOrderResult handle(PlaceOrderCommand command);
}
// Event — fact, published to whoever cares
public record OrderPlaced(
OrderId orderId,
CustomerId customerId,
List<OrderLine> items,
Instant occurredAt
) {}
Using events where you need commands, or commands where you need events, produces confused architecture. The inventory service shouldn’t publish a “DecreaseInventoryCommand” — it should handle OrderPlaced events and decide internally whether and how to reserve inventory.
When Events Are the Right Tool
Decoupling producers from consumers. When the orders service publishes OrderPlaced, it doesn’t know who will handle it. The notification service, the inventory service, the analytics service, the shipping service — all can subscribe independently. Adding a new consumer doesn’t require changing the producer.
Asynchronous processing. Some operations don’t need to complete synchronously before returning a response to the user. Sending confirmation emails, updating analytics, synchronizing external systems — these can all happen asynchronously after the primary transaction commits.
Audit trails. Events are a natural mechanism for capturing system history. Every event that flows through the system is a record of something that happened, with a timestamp, that can be replayed, queried, and analyzed.
Scaling independently. Consumers can scale independently of producers. If email sending is slow, scale the notification service. The orders service is unaffected.
Cross-service workflows. Long-running business processes that span multiple services (order fulfillment, user onboarding) are naturally modeled as event choreography: each service reacts to events from others and publishes its own events when done.
What Events Actually Cost
This is where most introductions to event-driven architecture stop short.
Eventual consistency. When an order is placed and an OrderPlaced event is published, the inventory service’s view of available stock is not immediately updated. There’s a window — usually milliseconds, potentially longer under failures — where the system is inconsistent. Code that reads from multiple services during this window may see inconsistent data.
If your users and your business processes can tolerate eventual consistency, this is fine. Many can. Some cannot. An e-commerce inventory check that shows items as available when they’re actually reserved is a business problem, not just a technical one.
Ordering is not guaranteed by default. Most message brokers don’t guarantee strict ordering across partitions. Even within a partition, consumer failures and retries can cause out-of-order processing.
Events published: OrderPlaced, PaymentCompleted, OrderShipped
Events processed: OrderPlaced, OrderShipped, PaymentCompleted
Your consumers must be designed to handle out-of-order events, or you need strict ordering guarantees (Kafka partition keys) for events that have ordering dependencies.
At-least-once delivery means duplicate events. Your consumers will process the same event more than once. Kafka, SQS, and most message brokers guarantee at-least-once delivery. Exactly-once is either impossible or extremely expensive.
Every event consumer must be idempotent:
@KafkaListener(topics = "orders.placed")
public void onOrderPlaced(OrderPlaced event) {
// Check if we've already processed this event
if (processedEventLog.contains(event.eventId())) {
log.debug("Duplicate event {}, skipping", event.eventId());
return;
}
// Process...
inventoryService.reserve(event.orderId(), event.items());
processedEventLog.record(event.eventId());
}
Schema evolution is hard. Events are a contract between producers and consumers. When the producer changes the event schema, every consumer must be updated — but consumers may be deployed independently, at different times. During the transition, both old and new consumers are running.
Backward compatibility rules:
- New fields must be optional with defaults
- Don’t remove fields — mark them deprecated and remove later
- Don’t change field semantics even if the name is the same
- Version your events explicitly
// Safe: adding optional field
record OrderPlaced(
OrderId orderId,
CustomerId customerId,
List<OrderLine> items,
Instant occurredAt,
String promoCode // New optional field — consumers that ignore it still work
) {}
// Unsafe: removing or renaming fields
// Unsafe: changing field type
Dead-letter queues require operational attention. Events that fail processing multiple times end up in a dead-letter queue (DLQ). DLQs accumulate events that require investigation and manual intervention. They are not a safety net that requires no attention — they’re an inbox of operational problems.
Who monitors the DLQ? Who decides when to replay vs. discard? How are DLQ events handled when there’s a bug fix that would have prevented them? These are operational questions that need answers before you go to production.
Debugging is harder. A failed synchronous call has a clear request-response flow. A failed event consumer has a timestamp, an event payload, a consumer error, and a lot of context that’s no longer in the same place. Distributed tracing helps, but it requires explicit instrumentation.
The Operational Complexity Tax
Running an event-driven system requires Kafka (or equivalent), consumer groups, partition management, consumer lag monitoring, dead-letter queues, replay mechanisms, and schema registries.
This is not unreasonable overhead for a system at sufficient scale. It is significant overhead for a small service with three consumers that process 1,000 events per day.
Before introducing events, ask:
- What coupling problem am I solving?
- Can this be solved with a synchronous call?
- Does eventual consistency work for this business requirement?
- Who will manage the operational infrastructure?
- Do all consumers need to be deployed independently?
If the answers don’t clearly favor events, a direct synchronous call is simpler and easier to understand.
Outbox Pattern: Reliable Event Publishing
Publishing events from within a database transaction is harder than it looks:
// WRONG — not atomic
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
// If this fails, the order is saved but no event is published
eventBus.publish(new OrderPlaced(order.id(), ...));
}
The outbox pattern solves this:
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
// Both writes are in the same transaction — atomic
outboxRepository.save(OutboxMessage.of("orders.placed", order));
}
// Separate publisher reads and publishes outbox messages
@Scheduled(fixedDelay = 100)
public void publishOutboxMessages() {
outboxRepository.findUnpublished().forEach(msg -> {
eventBus.publish(msg.topic(), msg.payload());
outboxRepository.markPublished(msg.id());
});
}
Choreography vs Orchestration
Choreography: each service publishes events and reacts to others’ events. No central coordinator.
Orchestration: a coordinator service explicitly directs other services through commands and awaits responses.
Choreography produces highly decoupled systems where no service knows about others’ internals. It also makes it hard to understand the overall flow — you need to follow a chain of events across multiple services to understand what happens when an order is placed.
Orchestration makes the flow explicit in one place. It introduces the coordinator as a new component that every other service depends on.
Neither is universally better. Complex workflows with many possible outcomes are often clearer with orchestration (Temporal, AWS Step Functions). Simple fan-out patterns are natural choreography.
When to Use Synchronous Calls Instead
Use synchronous calls when:
- You need an immediate response (user is waiting)
- Consistency must be immediate (not eventual)
- The operation is simple and the services are tightly coupled anyway
- The additional operational complexity isn’t worth the decoupling
Use events when:
- The producer genuinely doesn’t need to know about consumers
- Processing can be deferred without affecting the user experience
- You need to fan out to multiple independent consumers
- You’re building an audit trail or event log
The answer is almost never “use events for everything” or “use synchronous calls for everything.” Both have their place in a well-designed system.