Skip to main content
Architecture

Event-Driven vs Request-Driven: A Practical Decision Framework

Ravinder··8 min read
ArchitectureEvent-DrivenMicroservicesKafkaREST
Share:
Event-Driven vs Request-Driven: A Practical Decision Framework

The Question That Starts Every Architecture Review

"Should this be synchronous or asynchronous?" is asked in every microservices architecture review I have been part of. The answers I hear split roughly into two camps: the event-driven evangelists who want to put a message queue in front of everything, and the pragmatists who make a REST call because it is simple and it works.

Both camps are right sometimes. Both camps are catastrophically wrong when they apply their preference without thinking about the problem.

This post is a decision framework. Not a manifesto for events. Not a defence of REST. A set of questions you should answer before choosing a communication style — and a clear map from those answers to the right pattern.


The Mental Models

Before comparing, you need a sharp mental model of what each pattern actually is.

Request-Driven (Synchronous)

The caller sends a request and blocks waiting for a response. The caller knows who it is calling. The callee must be available. The response is part of the interaction.

sequenceDiagram participant Checkout as Checkout Service participant Inventory as Inventory Service participant Payment as Payment Service Checkout->>Inventory: Reserve items (HTTP POST) Inventory-->>Checkout: 200 OK {reservationId} Checkout->>Payment: Charge card (HTTP POST) Payment-->>Checkout: 200 OK {transactionId} Checkout-->>Checkout: Both succeeded — confirm order

The key property: the result of step 1 is needed to execute step 2. Checkout cannot proceed without knowing whether inventory was reserved. This is inherently synchronous.

Event-Driven (Asynchronous)

The producer emits an event describing something that happened. It does not know who, if anyone, is listening. It does not wait for a response. Consumers receive the event when they are ready and process it independently.

sequenceDiagram participant Order as Order Service participant Kafka as Event Bus (Kafka) participant Notif as Notification Service participant Analytics as Analytics Service participant Warehouse as Warehouse Service Order->>Kafka: Publish OrderConfirmed event Note over Order: Returns immediately — no waiting Kafka-->>Notif: OrderConfirmed Kafka-->>Analytics: OrderConfirmed Kafka-->>Warehouse: OrderConfirmed Note over Notif,Warehouse: Each processes independently, at their own pace

The key property: the producer does not care what happens next. Three consumers or thirty — the producer code does not change. This is the fan-out superpower of events.


The Decision Framework

Run through these questions in order. The first question that has a clear answer typically determines your pattern.

flowchart TD Q1{"Does the caller need\nthe result to proceed?"} Q1 -->|Yes| RD["Request-Driven\n(REST / gRPC)"] Q1 -->|No| Q2 Q2{"Does more than one\nconsumer need this?"} Q2 -->|Yes| ED["Event-Driven\n(Kafka / SNS)"] Q2 -->|No| Q3 Q3{"Can the consumer\nbe temporarily unavailable?"} Q3 -->|Yes| ED Q3 -->|No| Q4 Q4{"Is the interaction\nsimple point-to-point?"} Q4 -->|Yes| RD Q4 -->|No| Q5 Q5{"Is audit trail / replay\ncritical?"} Q5 -->|Yes| ED Q5 -->|No| RD style RD fill:#DBEAFE,stroke:#3B82F6 style ED fill:#D1FAE5,stroke:#10B981

Let me walk through each question with the reasoning behind it.

Q1: Does the caller need the result to proceed?

This is the most important question and the most misunderstood. "Need" is the operative word. Teams often say they need the result when they actually mean they want confirmation.

  • Order service charging a card: needs the result. Cannot confirm the order without knowing if payment succeeded. → Request-Driven.
  • Order service sending a confirmation email: does not need the result. Email delivery failure does not cancel the order. → Event-Driven.

If you find yourself writing await on a message queue consumer to get the response back, you have built synchronous request-response over an async channel. You have the worst of both worlds: the latency of async with the coupling of sync. Use REST instead.

Q2: Does more than one consumer need this?

Events scale horizontally in a way that synchronous calls cannot. When an order is placed, the following might all care:

  • Notification service (confirmation email + SMS)
  • Analytics service (funnel tracking)
  • Warehouse service (pick list generation)
  • Loyalty service (points calculation)
  • Fraud service (pattern analysis)

With request-driven, the caller must know about all of them and call each one. When you add the sixth consumer, you modify the caller. With events, you add the sixth consumer's subscription and the producer never changes. → Event-Driven wins decisively here.

Q3: Can the consumer be temporarily unavailable?

Events provide buffering. If the notification service is down for ten minutes, Kafka holds the events. When the service comes back, it processes the backlog. No events lost.

Synchronous calls fail immediately when the target is unavailable. You need retries, circuit breakers, and fallback logic in the caller. For fire-and-forget operations where durability matters more than immediacy, events handle this better.


The Patterns in Practice

Pattern 1: Synchronous core, async side-effects

This is the most pragmatic hybrid and the one I use most often. The primary transaction path is synchronous. Side-effects (notifications, analytics, cache invalidation) are events.

flowchart LR Client --> API["Order API"] API -->|"sync"| DB["Order DB"] API -->|"sync"| Payment["Payment Service"] DB -->|"Transactional Outbox"| Kafka Kafka -->|"async"| Notif["Notify"] Kafka -->|"async"| Analytics Kafka -->|"async"| Warehouse style API fill:#DBEAFE,stroke:#3B82F6 style Kafka fill:#FEF3C7,stroke:#F59E0B style Notif fill:#D1FAE5,stroke:#10B981 style Analytics fill:#D1FAE5,stroke:#10B981 style Warehouse fill:#D1FAE5,stroke:#10B981

The transactional outbox pattern is the glue here. You write the order and the outbox event in the same database transaction. A background publisher reads the outbox and publishes to Kafka. This guarantees that if the order is persisted, the event will eventually be published — even if the service crashes immediately after the write.

// Transactional outbox — write order + event atomically
@Transactional
public OrderConfirmation confirmOrder(OrderRequest request) {
    Order order = orderRepository.save(new Order(request));
    
    OutboxEvent event = OutboxEvent.builder()
        .aggregateType("Order")
        .aggregateId(order.getId().toString())
        .eventType("OrderConfirmed")
        .payload(objectMapper.writeValueAsString(new OrderConfirmedEvent(order)))
        .build();
    
    outboxRepository.save(event);  // Same transaction as order
    
    return new OrderConfirmation(order.getId(), order.getStatus());
}

Pattern 2: Event-driven with request-driven fallback

When the event consumer needs data the event does not carry, it can make a synchronous call to fetch it. The event triggers the action; the request fetches the context.

// Consumer: handle OrderConfirmed event, fetch details synchronously
@KafkaListener(topics = "order.confirmed")
public void handleOrderConfirmed(OrderConfirmedEvent event) {
    // Event carries orderId — fetch full order details synchronously
    Order order = orderClient.getOrder(event.getOrderId());
    
    notificationService.sendConfirmationEmail(
        order.getCustomerEmail(),
        order.getItems(),
        order.getShippingAddress()
    );
}

This is a valid and common pattern. Events for triggering, REST for enrichment.


Common Mistakes

Mistake 1: Async request-response over Kafka

Service A → publishes "GetUserRequest" to Kafka
Service B → consumes, processes, publishes "GetUserResponse" to reply topic
Service A → blocks waiting for reply

This is synchronous communication cosplaying as async. You get the operational complexity of Kafka (ordering guarantees, consumer group management, offset tracking) with the latency characteristics of blocking calls. Just use gRPC.

Mistake 2: Events with embedded commands

Events should describe what happened, not instruct what to do.

// Bad — command disguised as an event
{
  "type": "OrderPlaced",
  "action": "SEND_EMAIL",
  "template": "order_confirmation",
  "recipient": "user@example.com"
}
 
// Good — pure event, consumer decides what to do
{
  "type": "OrderPlaced",
  "orderId": "ord-123",
  "customerId": "cust-456",
  "placedAt": "2026-04-11T14:30:00Z",
  "totalAmount": 89.99
}

When the event contains a command (action: SEND_EMAIL), the producer now has knowledge of what consumers do. That coupling defeats the decoupling purpose of events.

Mistake 3: Ignoring ordering guarantees

Kafka provides ordering within a partition. If you need strict ordering across all events for an entity, you must ensure all events for that entity go to the same partition. Use the entity ID as the partition key.

// Producer — partition by orderId to guarantee ordering
ProducerRecord<String, OrderEvent> record = new ProducerRecord<>(
    "order.events",
    order.getId().toString(),  // Partition key = orderId
    new OrderStatusChangedEvent(order)
);

When to Mix Both in One Flow

A well-designed system often uses both patterns in a single business flow. Here is a complete order processing flow that illustrates appropriate use of each:

sequenceDiagram participant Client participant OrderAPI participant PaymentSvc participant Kafka participant WarehouseSvc participant NotificationSvc Client->>OrderAPI: Place order (sync) OrderAPI->>PaymentSvc: Charge card (sync — need result) PaymentSvc-->>OrderAPI: Payment confirmed OrderAPI-->>Client: Order confirmed (sync response) Note over OrderAPI: After responding to client... OrderAPI->>Kafka: Publish OrderConfirmed (async) Kafka-->>WarehouseSvc: OrderConfirmed (async — fire and forget) Kafka-->>NotificationSvc: OrderConfirmed (async — fire and forget)

The critical path (client → API → payment → response) is synchronous because the client needs confirmation. Everything after the confirmation is asynchronous because the client is already gone.


The Right Question Is Not "Which Is Better?"

It is "what does this specific interaction require?"

  • Need the result: use synchronous.
  • Fan-out to multiple consumers: use events.
  • Temporal decoupling required: use events.
  • Simple point-to-point, result needed: use synchronous.
  • Audit trail, replay, backpressure: use events.

Most real systems use both patterns, applied judiciously. The systems that get into trouble are the ones that chose a pattern as a religion rather than as a tool. Events are not more "modern" than REST. REST is not more "simple" than Kafka. They solve different problems. Respect the difference.