00:00

Event-Driven Architecture in Microservices

Modern applications must stay fast and available even when traffic spikes or a single service fails. A popular way to reach that goal is Event-Driven Architecture (EDA). Instead of services calling each other and waiting for replies, they announce what has happened, and any interested service reacts in its own time. This tutorial explains the idea in plain words, walks through a real order flow, and shows how to build it with Spring Boot and Apache Kafka.

What is Event-Driven Architecture (EDA)?

Event-Driven Architecture is a design style in which the flow of a system is driven by events. An event is a record of something that has already happened, such as "an order was placed", "a payment succeeded" or "stock dropped below ten units". Because it describes the past, an event is a fact and is never changed after it is created.

A service that notices a change publishes an event. Other services subscribe to the events they care about and react to them. The publisher does not know who is listening or how many listeners exist. This one rule is what keeps event-driven services loosely coupled.

Simple analogy: Think of a radio station. The station broadcasts a programme without knowing who is tuned in. Listeners can switch on, switch off or join later, and the station keeps working either way.

Key Components of EDA

  • Event: An immutable message that describes a state change, usually with an ID, a type, a timestamp and a small payload.
  • Event Producer (Publisher): The service that detects the change and publishes the event.
  • Event Consumer (Subscriber): A service that receives the event and does its own work in response.
  • Event Channel (Message Broker): The infrastructure that stores and delivers events, for example Apache Kafka, RabbitMQ, Amazon SNS/SQS or AWS EventBridge.
  • Topic / Queue: A named channel inside the broker that groups related events, such as order-events.

Types of Events

Not every event carries the same amount of information. Choosing the right style affects coupling and network traffic.

Event StyleWhat It ContainsWhen to Use
Event NotificationOnly an ID and the event type, e.g. OrderPlaced {orderId}Consumers can call back for details; payload stays tiny.
Event-Carried State TransferAll the data consumers need, e.g. items, prices, addressConsumers should work without calling the producer.
Domain EventA business fact named in domain language, e.g. PaymentRefundedModelling business processes with Domain-Driven Design.
Change Data Capture (CDC)Row-level database changes read from the database logStreaming data out of legacy systems without code changes.

Event vs Command vs Query

MessageMeaningExample NameReceivers
EventSomething already happenedOrderPlacedZero, one or many
CommandPlease do somethingReserveStockExactly one handler
QueryPlease tell me somethingGetOrderStatusOne handler, returns data

A handy naming rule: events use the past tense, commands use the imperative.

How Does It Work? An E-Commerce Example

Consider an online order. In a traditional monolith, one large module might validate the order, charge the card, reserve stock and book the courier in a single chain. If the courier step is slow or broken, the customer waits or the whole order fails.

With EDA, the same process becomes a series of small, independent reactions to events:

Order flow
 Customer
    |
    v
 Order Service ---------- publishes OrderPlaced
    |
    v
 [ Message Broker ] ---------------+
    |                              |
    v                              v
 Payment Service              Inventory Service
    | PaymentProcessed             | InventoryReserved
    v                              v
 [ Message Broker ] <--------------+
    |
    v
 Notification Service ---- waits for both, publishes OrderConfirmed
    |
    v
 [ Message Broker ]
    |
    v
 Shipping Service -------- publishes ShipmentScheduled

Step-by-Step Flow

  1. Event: OrderPlaced
    The customer clicks "Place Order". The Order Service saves the order with status PENDING and publishes an OrderPlaced event containing the order ID, customer ID, items and total. It replies to the customer straight away and does not wait for anything else.
  2. Event: PaymentProcessed
    The Payment Service subscribes to OrderPlaced. It charges the customer and, on success, publishes PaymentProcessed. On failure it publishes PaymentFailed instead.
  3. Event: InventoryReserved
    At the same time, the Inventory Service also receives OrderPlaced. It reserves the items so nobody else can buy them and publishes InventoryReserved.
  4. Event: OrderConfirmed
    The Notification Service listens for both PaymentProcessed and InventoryReserved. When both have arrived for the same order ID, it publishes OrderConfirmed and emails the customer.
  5. Event: ShipmentScheduled
    Finally, the Shipping Service reacts to OrderConfirmed, books a courier pickup, generates a tracking number and publishes ShipmentScheduled.

Choreography vs Orchestration

There are two common ways to coordinate a multi-step business process across services.

  • Choreography: Each service listens for events and decides on its own what to do next. There is no central controller. The flow above is choreography. It is simple and highly decoupled, but the overall process can be hard to see once it spans many services.
  • Orchestration: A central orchestrator sends commands to each service and waits for their reply events. The flow is easy to read in one place, but the orchestrator becomes an extra component to build and run.

A practical rule: use choreography for short flows with a few steps, and switch to orchestration when a process has many steps, branches or compensations.

Implementation with Spring Boot and Apache Kafka

Let us build the first two steps of the flow: the Order Service publishes OrderPlaced, and the Inventory Service consumes it. The examples use Spring Boot 3 and Spring for Apache Kafka.

Step 1: Add the Dependency

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

Step 2: Configure Kafka

application.yml
spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all
      properties:
        enable.idempotence: true
    consumer:
      group-id: inventory-service
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "com.javabytechie.events"

Step 3: Define the Event

A Java record is a perfect fit for an event because it is immutable by design.

OrderPlacedEvent.java
package com.javabytechie.events;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;

public record OrderPlacedEvent(
        String eventId,
        String orderId,
        String customerId,
        List<OrderItem> items,
        BigDecimal totalAmount,
        Instant occurredAt) {

    public record OrderItem(String productId, int quantity) {
    }

    public static OrderPlacedEvent of(String orderId, String customerId,
                                      List<OrderItem> items, BigDecimal totalAmount) {
        return new OrderPlacedEvent(
                UUID.randomUUID().toString(),
                orderId,
                customerId,
                items,
                totalAmount,
                Instant.now());
    }
}

Step 4: Publish the Event (Order Service)

OrderEventPublisher.java
package com.javabytechie.order;

import com.javabytechie.events.OrderPlacedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;

@Component
public class OrderEventPublisher {

    private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);
    private static final String TOPIC = "order-events";

    private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;

    public OrderEventPublisher(KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publish(OrderPlacedEvent event) {
        // Using orderId as the key keeps all events of one order in the same partition (ordered).
        kafkaTemplate.send(TOPIC, event.orderId(), event)
                .whenComplete((result, ex) -> {
                    if (ex == null) {
                        log.info("Published OrderPlaced for order {} at offset {}",
                                event.orderId(), result.getRecordMetadata().offset());
                    } else {
                        log.error("Failed to publish OrderPlaced for order {}", event.orderId(), ex);
                    }
                });
    }
}
OrderController.java
package com.javabytechie.order;

import com.javabytechie.events.OrderPlacedEvent;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderEventPublisher publisher;

    public OrderController(OrderEventPublisher publisher) {
        this.publisher = publisher;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.ACCEPTED)
    public String placeOrder(@RequestBody PlaceOrderRequest request) {
        String orderId = UUID.randomUUID().toString();

        publisher.publish(OrderPlacedEvent.of(
                orderId,
                request.customerId(),
                request.items(),
                request.totalAmount()));

        // 202 Accepted: the order is received; the rest happens asynchronously.
        return orderId;
    }

    public record PlaceOrderRequest(
            String customerId,
            List<OrderPlacedEvent.OrderItem> items,
            BigDecimal totalAmount) {
    }
}

Step 5: Consume the Event (Inventory Service)

InventoryEventListener.java
package com.javabytechie.inventory;

import com.javabytechie.events.OrderPlacedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class InventoryEventListener {

    private static final Logger log = LoggerFactory.getLogger(InventoryEventListener.class);

    private final InventoryService inventoryService;
    private final ProcessedEventRepository processedEvents;

    public InventoryEventListener(InventoryService inventoryService,
                                  ProcessedEventRepository processedEvents) {
        this.inventoryService = inventoryService;
        this.processedEvents = processedEvents;
    }

    @Transactional
    @KafkaListener(topics = "order-events", groupId = "inventory-service")
    public void onOrderPlaced(OrderPlacedEvent event) {
        // Idempotency check: skip events we have already handled.
        if (processedEvents.existsById(event.eventId())) {
            log.info("Duplicate event {} ignored", event.eventId());
            return;
        }

        inventoryService.reserve(event.orderId(), event.items());
        processedEvents.save(new ProcessedEvent(event.eventId()));

        log.info("Stock reserved for order {}", event.orderId());
    }
}

Reliability: Idempotency, Retries and Dead Letter Topics

Most brokers guarantee at-least-once delivery, which means a consumer may see the same event twice (for example after a restart). Your design must handle this calmly.

  • Idempotent consumers: Store each processed eventId (as shown above) or make the operation naturally repeatable, such as "set status to PAID" instead of "add 1 to counter".
  • Retries with back-off: Temporary failures like a database timeout should be retried a few times with a delay.
  • Dead Letter Topic (DLT): If an event still fails after all retries, move it to a separate topic so it does not block the others. A team can inspect and replay it later.
KafkaErrorConfig.java
package com.javabytechie.inventory;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.util.backoff.FixedBackOff;

@Configuration
public class KafkaErrorConfig {

    @Bean
    public DefaultErrorHandler errorHandler(KafkaTemplate<?, ?> kafkaTemplate) {
        // After retries are exhausted, send the record to "order-events.DLT".
        DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate);

        // Retry 3 times, waiting 1 second between attempts.
        DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3));

        // Bad data will never succeed, so do not waste retries on it.
        handler.addNotRetryableExceptions(IllegalArgumentException.class);
        return handler;
    }
}

Transactional Outbox Pattern

A classic bug in event-driven systems is the dual write: the service saves to its database and then publishes to the broker. If the application crashes between the two steps, the database and the event stream disagree.

The Outbox pattern fixes this. The service writes the business data and the event into an outbox table in the same local transaction. A separate relay then reads the outbox and publishes the events. Either both writes succeed or neither does.

OrderService.java
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxRepository outboxRepository;
    private final ObjectMapper objectMapper;

    public OrderService(OrderRepository orderRepository,
                        OutboxRepository outboxRepository,
                        ObjectMapper objectMapper) {
        this.orderRepository = orderRepository;
        this.outboxRepository = outboxRepository;
        this.objectMapper = objectMapper;
    }

    @Transactional
    public Order placeOrder(PlaceOrderRequest request) throws JsonProcessingException {
        Order order = orderRepository.save(Order.pending(request));

        OrderPlacedEvent event = OrderPlacedEvent.of(
                order.getId(), request.customerId(), request.items(), request.totalAmount());

        // Same transaction as the order insert: both commit or both roll back.
        outboxRepository.save(new OutboxEvent(
                event.eventId(),
                "order-events",
                order.getId(),
                objectMapper.writeValueAsString(event)));

        return order;
    }
}
OutboxRelay.java
@Component
public class OutboxRelay {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    public OutboxRelay(OutboxRepository outboxRepository,
                       KafkaTemplate<String, String> kafkaTemplate) {
        this.outboxRepository = outboxRepository;
        this.kafkaTemplate = kafkaTemplate;
    }

    @Scheduled(fixedDelay = 2000)
    @Transactional
    public void publishPendingEvents() {
        List<OutboxEvent> pending =
                outboxRepository.findTop100ByPublishedFalseOrderByCreatedAtAsc();

        for (OutboxEvent outboxEvent : pending) {
            // join() waits for the broker acknowledgement before marking it as sent.
            kafkaTemplate.send(outboxEvent.getTopic(),
                               outboxEvent.getAggregateId(),
                               outboxEvent.getPayload()).join();
            outboxEvent.markPublished();
        }
    }
}

In production, many teams replace the polling relay with a Change Data Capture tool such as Debezium, which reads the outbox table straight from the database log.

  • Saga Pattern: Manages a business transaction across several services using a sequence of local transactions and compensating events (for example, PaymentFailed triggers InventoryReleased). See the Saga Pattern tutorial.
  • CQRS (Command Query Responsibility Segregation): Separates the write model from read models. Events keep the read models up to date.
  • Event Sourcing: Stores the full list of events as the source of truth, and rebuilds the current state by replaying them. It gives a complete audit trail for free.
  • Database per Service: Each service owns its data, and events are how other services learn about changes. See Database Per Service.

Key Benefits of Event-Driven Architecture

  • Loose Coupling: The Order Service knows nothing about Payment or Shipping. Teams can change, deploy and scale their services independently.
  • High Scalability: Scale only what is busy. During a flash sale you can add more Inventory consumers without touching Shipping.
  • Resilience and Fault Tolerance: If Shipping is down, its events wait safely in the broker. When it recovers, it catches up with no data lost, and the rest of the system keeps running.
  • Responsive User Experience: The customer gets an instant reply while slower work runs in the background.
  • Easy Extensibility: Need a loyalty-points feature? Add a new consumer of PaymentProcessed. No existing service has to change.
  • Real-Time Insight and Audit: The event stream is a live history of the business that analytics, monitoring and auditing tools can read.

Challenges of Event-Driven Architecture

  • Eventual Consistency: Data across services is updated a little later, not instantly. The UI and business rules must accept short delays.
  • Harder Debugging: A single request may touch many services at different times. You need correlation IDs and distributed tracing.
  • Duplicate and Out-of-Order Events: Consumers must be idempotent and must not assume a perfect order unless the broker guarantees it (Kafka orders events only within a partition).
  • Schema Evolution: Changing an event's structure can break consumers. Use versioning and a schema registry.
  • Operational Overhead: A broker cluster needs monitoring, capacity planning and security.

Best Practices

  1. Name events in the past tense using business language (OrderShipped, not UpdateOrderTable).
  2. Give every event a unique eventId, a type, a version and a timestamp.
  3. Use a business key (such as orderId) as the message key so related events stay in order.
  4. Make every consumer idempotent.
  5. Use the Outbox pattern instead of writing to the database and broker separately.
  6. Configure retries with back-off and a dead letter topic for each consumer.
  7. Only add new optional fields to events; never remove or rename fields without a new version.
  8. Propagate a correlation ID and use tracing tools such as OpenTelemetry.
  9. Monitor consumer lag to spot slow or stuck services early.
  10. Do not force EDA everywhere; a simple synchronous call is fine when you need an immediate answer.

Conclusion

Event-Driven Architecture lets microservices communicate by sharing facts instead of giving orders. That small shift brings loose coupling, independent scaling, strong fault tolerance and an easy path to new features. It also introduces eventual consistency, duplicate handling and extra monitoring, so patterns like idempotent consumers, dead letter topics, the Outbox pattern and Sagas are essential companions. Start with one well-defined business flow, apply the best practices above, and grow your event-driven system step by step.