DEV Community

Said Olano
Said Olano

Posted on

Event-Driven Architecture: Building Responsive Distributed Systems

Event-Driven Architecture: Building Responsive, Scalable Systems

Introduction

Your e-commerce system processes an order. Right now, this is what happens:

POST /orders
  ↓
Order Service saves to database
  ├─ Calls Payment Service (wait...)
  ├─ Calls Inventory Service (wait...)
  ├─ Calls Notification Service (wait...)
  └─ Calls Analytics Service (wait...)
Enter fullscreen mode Exit fullscreen mode

Every service waits for the previous one. If any service is slow, the entire order takes forever to process.

Event-Driven Architecture flips this on its head.

Instead of synchronous request-response, services communicate through events. When something happens, an event is published. Other services listen and react independently.

POST /orders
  ↓
Order Service publishes: OrderCreated event
  ├─ Payment Service listens → processes independently
  ├─ Inventory Service listens → updates independently
  ├─ Notification Service listens → sends email independently
  └─ Analytics Service listens → logs independently
  ↓
Response returns immediately
Enter fullscreen mode Exit fullscreen mode

This is Event-Driven Architecture: loosely coupled, highly responsive, infinitely scalable.

Core Concepts

1. Events

An event is an immutable record of something that happened.

{
  "event_type": "OrderCreated",
  "event_id": "evt-12345",
  "timestamp": "2026-09-28T10:30:00Z",
  "aggregate_id": "order-456",
  "data": {
    "order_id": "order-456",
    "customer_id": "cust-789",
    "items": [
      {"product_id": "prod-1", "quantity": 2, "price": 29.99}
    ],
    "total": 59.98
  }
}
Enter fullscreen mode Exit fullscreen mode

Event Characteristics:

  • Immutable (never changes)
  • Past-tense (OrderCreated, not OrderCreate)
  • Contains business context
  • Includes timestamp and ID for ordering
  • Minimal data (only what's needed)

2. Event Producers

Services that publish events when something significant happens.

@Service
public class OrderService {
    @Autowired
    private EventPublisher eventPublisher;

    public Order createOrder(OrderRequest request) {
        Order order = new Order(request);
        orderRepository.save(order);

        // Publish event
        eventPublisher.publish(new OrderCreated(
            order.getId(),
            order.getCustomerId(),
            order.getItems(),
            order.getTotal()
        ));

        return order;
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Event Consumers

Services that listen to and react to events published by other services.

@Service
public class PaymentService {

    @EventListener
    public void handleOrderCreated(OrderCreated event) {
        log.info("Processing payment for order: {}", 
            event.getOrderId());

        // Charge customer
        Payment payment = chargeCustomer(
            event.getCustomerId(),
            event.getTotal()
        );

        // Publish payment event
        eventPublisher.publish(new PaymentProcessed(
            event.getOrderId(),
            payment.getTransactionId()
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Event Broker

The message broker that delivers events from producers to consumers.

Popular options:

  • Apache Kafka: High throughput, event streaming
  • RabbitMQ: Reliable message delivery
  • AWS SNS/SQS: Cloud-native
  • Google Pub/Sub: Managed service
  • Azure Event Hubs: Microsoft ecosystem

Architecture Patterns

Pattern 1: Pub-Sub (Fan-Out)

One event, multiple consumers.

OrderCreated event published
    ↓
    ├─→ Payment Service
    ├─→ Inventory Service
    ├─→ Notification Service
    └─→ Analytics Service

All consume simultaneously & independently
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Highly decoupled
  • Parallel processing
  • Easy to add new consumers

Cons:

  • Hard to debug (multiple paths)
  • Eventual consistency
  • No guaranteed order across consumers

Pattern 2: Event Sourcing

Store all state changes as a sequence of events.

Instead of storing current state:

Order {
  status: "shipped",
  paid: true,
  ...
}
Enter fullscreen mode Exit fullscreen mode

Store the history:

Events:
1. OrderCreated(order-1, customer-1, items, total)
2. PaymentProcessed(order-1, txn-12345)
3. InventoryDecremented(order-1, items)
4. OrderShipped(order-1, tracking-123)
Enter fullscreen mode Exit fullscreen mode

Advantages:

  • Complete audit trail (who changed what, when)
  • Time travel (replay events to any point)
  • Natural for distributed systems
  • Enables event-driven UI updates

Implementation:

@Service
public class OrderEventStore {

    public Order getOrder(String orderId) {
        // Fetch all events for this order
        List<DomainEvent> events = 
            eventStore.getEventsForAggregate(orderId);

        // Replay events to reconstruct current state
        Order order = new Order(orderId);
        for (DomainEvent event : events) {
            order.apply(event);
        }

        return order;
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: CQRS (Command Query Responsibility Segregation)

Separate write operations (commands) from read operations (queries).

Command Flow (writes):
OrderCreated command
  ↓
Order Service updates event store
  ↓
Publishes OrderCreated event

Query Flow (reads):
Customer views orders
  ↓
Read from optimized read database
  ↓
Instant response
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Optimized data models for reads vs writes
  • Independent scaling
  • Complex queries don't affect write latency

Real-World Scenario: Order Fulfillment

Synchronous (Traditional)

POST /orders/create
  ↓
1. Create order (100ms)
2. Call payment service (500ms) - waits
3. Call inventory service (200ms) - waits
4. Send notification (300ms) - waits
5. Log analytics (150ms) - waits
  ↓
Total: 1250ms

If payment is down:
└─ Entire operation fails or times out
Enter fullscreen mode Exit fullscreen mode

Event-Driven (Async)

POST /orders/create
  ↓
1. Create order (100ms)
2. Publish OrderCreated event (10ms)
3. Return immediately (110ms total)
  ↓
Response: 110ms ✅

Parallel processing:
├─ Payment Service processes independently (500ms)
├─ Inventory Service processes independently (200ms)
├─ Notification Service processes independently (300ms)
└─ Analytics Service processes independently (150ms)

If payment is down:
├─ Order still created
├─ Customer notified
├─ Analytics recorded
└─ Payment retried later

Total perceived latency: 110ms (not 1250ms!)
Enter fullscreen mode Exit fullscreen mode

Implementation Patterns

Using Kafka

@Configuration
public class KafkaProducerConfig {

    @Bean
    public ProducerFactory<String, OrderEvent> 
            producerFactory() {
        return new DefaultProducerFactory<>(
            producerConfigs());
    }

    @Bean
    public KafkaTemplate<String, OrderEvent> 
            kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }
}

@Service
public class OrderEventPublisher {

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public void publishOrderCreated(Order order) {
        OrderCreatedEvent event = new OrderCreatedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getItems(),
            order.getTotal()
        );

        kafkaTemplate.send(
            "order-events",
            order.getId(),
            event
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Consumer:

@Service
public class PaymentServiceListener {

    @KafkaListener(topics = "order-events", 
                   groupId = "payment-service")
    public void handleOrderCreated(
            OrderCreatedEvent event) {
        log.info("Processing payment for order: {}", 
            event.getOrderId());

        // Process payment
        processPayment(event);
    }
}
Enter fullscreen mode Exit fullscreen mode

Challenges & Solutions

Challenge 1: Eventual Consistency

Events aren't processed instantly. Between event publish and processing, data is inconsistent.

Problem:

Customer clicks "view order"
  ↓
Order just created, event published
  ↓
Payment service still processing
  ↓
Customer sees "order status: pending" ✓
  ↓
2 seconds later, payment processes
  ↓
Customer refreshes, sees "order status: paid" ✓
Enter fullscreen mode Exit fullscreen mode

Solution:

  • Expose "eventual" status upfront
  • Provide real-time updates (WebSocket/SSE)
  • Make timeouts acceptable for your domain

Challenge 2: Message Loss

What if a consumer crashes before processing an event?

Solution 1: Message Brokers Handle It

Kafka offset management:
├─ Consumer processed up to offset 1000
├─ Crash occurs before processing 1001
├─ Consumer restarts
└─ Reprocesses from 1001
Enter fullscreen mode Exit fullscreen mode

Solution 2: Idempotency

// Same event processed twice = same result
public void handleOrderCreated(OrderCreatedEvent event) {
    // Check: is this order already processed?
    if (paymentRepository.exists(event.getOrderId())) {
        log.info("Payment already processed, skipping");
        return;
    }

    // Process payment
    processPayment(event);
}
Enter fullscreen mode Exit fullscreen mode

Challenge 3: Debugging Across Services

With sync calls, you can trace the flow. With events, they're loose.

Solution:

  • Correlation IDs (trace events across services)
  • Distributed tracing (Jaeger, Zipkin)
  • Event replay and audit logs
Request comes in:
correlation_id = "req-12345-xyz"

Service 1 publishes event:
{
  event: OrderCreated,
  correlation_id: "req-12345-xyz",
  ...
}

Service 2 listens and republishes:
{
  event: PaymentProcessed,
  correlation_id: "req-12345-xyz",  // Same ID
  ...
}

Tracing tool shows entire flow!
Enter fullscreen mode Exit fullscreen mode

Challenge 4: Message Ordering

Events might arrive out of order.

Problem:

Event 1: OrderCreated
Event 2: PaymentProcessed
Event 3: OrderShipped

Consumer receives:
1, 3, 2 (out of order!)
Enter fullscreen mode Exit fullscreen mode

Solution:

// Partition by order_id
// Events for same order_id go to same partition
// Partitions process sequentially

Partition 0: order-1 events (ordered)
Partition 1: order-2 events (ordered)
Partition 2: order-3 events (ordered)
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Event Naming Convention

Domain + Action + Status

✅ Good:
- OrderCreated
- PaymentProcessed
- InventoryReserved
- NotificationSent

❌ Bad:
- OrderEvent
- Update
- Notify
Enter fullscreen mode Exit fullscreen mode

2. Event Payload Design

{
  "event_type": "OrderCreated",
  "event_id": "unique-id",
  "timestamp": "ISO8601",
  "aggregate_id": "order-123",
  "aggregate_version": 1,
  "data": {
    "order_id": "order-123",
    "customer_id": "customer-456",
    "items": [...],
    "total": 99.99
  },
  "correlation_id": "req-xyz",
  "causation_id": "cmd-abc"
}
Enter fullscreen mode Exit fullscreen mode

3. Versioning Events

Events change over time. Handle schema evolution:

Version 1:
  fields: [order_id, customer_id, total]

Version 2 (add discount):
  fields: [order_id, customer_id, total, discount]

Version 3 (add currency):
  fields: [order_id, customer_id, total, discount, currency]

Consumers handle multiple versions:
if event.version == 1:
  discount = 0
  currency = "USD"
elif event.version == 2:
  currency = "USD"
Enter fullscreen mode Exit fullscreen mode

4. Error Handling

@KafkaListener(topics = "order-events")
public void handleOrderCreated(OrderCreatedEvent event) {
    try {
        processOrderCreated(event);
    } catch (TemporaryException e) {
        // Retry later
        deadLetterTopic.send(event);
    } catch (PermanentException e) {
        // Log and alert
        log.error("Cannot process order event", e);
        alerting.notifyOps("Order processing failed");
    }
}
Enter fullscreen mode Exit fullscreen mode

When to Use Event-Driven

✅ Good fit:

  • Real-time notifications (email, SMS, push)
  • Cross-service communication
  • Audit logging
  • Complex workflows (sagas)
  • Asynchronous processing

❌ Poor fit:

  • Simple CRUD operations
  • High-frequency operations (<1ms)
  • Operations requiring immediate consistency
  • Single-service features

Conclusion

Event-Driven Architecture transforms how systems scale and respond.

Instead of synchronous blocking calls:

  • Publish events and return immediately
  • Let services react independently
  • Scale each service based on demand
  • Survive component failures gracefully

The tradeoff? You must embrace eventual consistency and monitoring.

But the payoff is systems that are:
✅ Responsive (low latency)
✅ Scalable (independent scaling)
✅ Resilient (failures don't cascade)
✅ Auditable (complete event trail)

Start with Kafka or RabbitMQ, design your events carefully, and let your system evolve beyond request-response synchronization.

Top comments (1)

Collapse
 
kevinpruett023_kevinpruet profile image
Lee •

Good Post!
I wanna have meaningful conversation about collaboration with you.
How about you?