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.
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 Style | What It Contains | When to Use |
|---|---|---|
| Event Notification | Only an ID and the event type, e.g. OrderPlaced {orderId} | Consumers can call back for details; payload stays tiny. |
| Event-Carried State Transfer | All the data consumers need, e.g. items, prices, address | Consumers should work without calling the producer. |
| Domain Event | A business fact named in domain language, e.g. PaymentRefunded | Modelling business processes with Domain-Driven Design. |
| Change Data Capture (CDC) | Row-level database changes read from the database log | Streaming data out of legacy systems without code changes. |
Event vs Command vs Query
| Message | Meaning | Example Name | Receivers |
|---|---|---|---|
| Event | Something already happened | OrderPlaced | Zero, one or many |
| Command | Please do something | ReserveStock | Exactly one handler |
| Query | Please tell me something | GetOrderStatus | One 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:
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
-
Event:
OrderPlaced
The customer clicks "Place Order". The Order Service saves the order with status PENDING and publishes anOrderPlacedevent containing the order ID, customer ID, items and total. It replies to the customer straight away and does not wait for anything else. -
Event:
PaymentProcessed
The Payment Service subscribes toOrderPlaced. It charges the customer and, on success, publishesPaymentProcessed. On failure it publishesPaymentFailedinstead. -
Event:
InventoryReserved
At the same time, the Inventory Service also receivesOrderPlaced. It reserves the items so nobody else can buy them and publishesInventoryReserved. -
Event:
OrderConfirmed
The Notification Service listens for bothPaymentProcessedandInventoryReserved. When both have arrived for the same order ID, it publishesOrderConfirmedand emails the customer. -
Event:
ShipmentScheduled
Finally, the Shipping Service reacts toOrderConfirmed, books a courier pickup, generates a tracking number and publishesShipmentScheduled.
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
<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
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.
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)
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);
}
});
}
}
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)
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.
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.
@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;
}
}
@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.
Related Patterns
- Saga Pattern: Manages a business transaction across several services using a sequence of local transactions and compensating events (for example,
PaymentFailedtriggersInventoryReleased). 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
- Name events in the past tense using business language (
OrderShipped, notUpdateOrderTable). - Give every event a unique
eventId, a type, a version and a timestamp. - Use a business key (such as
orderId) as the message key so related events stay in order. - Make every consumer idempotent.
- Use the Outbox pattern instead of writing to the database and broker separately.
- Configure retries with back-off and a dead letter topic for each consumer.
- Only add new optional fields to events; never remove or rename fields without a new version.
- Propagate a correlation ID and use tracing tools such as OpenTelemetry.
- Monitor consumer lag to spot slow or stuck services early.
- 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.
Frequently Asked Questions
1. What is Event-Driven Architecture in simple words?
It is a way of building software where services announce things that have happened (events), and other services react to those announcements. Nobody waits for a direct reply, so each part can work at its own pace.
2. What exactly is an event?
An event is an immutable record of a fact that already took place, such as OrderPlaced or PaymentFailed. It usually carries a unique ID, a type, a timestamp and some data about the change.
3. How is an event different from a command?
A command asks one specific service to do something (ReserveStock) and may be rejected. An event reports something that already happened (StockReserved), cannot be rejected, and can be read by any number of services.
4. What is the role of a message broker in EDA?
The broker receives events from producers, stores them safely and delivers them to consumers. It decouples the two sides in time and location: producers and consumers do not need to be online at the same moment or know each other's addresses.
5. Which message brokers are commonly used?
- Apache Kafka: High-throughput, durable event streaming with replay.
- RabbitMQ: Flexible routing and classic queue-based messaging.
- Amazon SNS/SQS and AWS EventBridge: Managed services on AWS.
- Azure Service Bus and Google Pub/Sub: Managed options on other clouds.
6. Is Event-Driven Architecture the same as asynchronous communication?
Not exactly. Asynchronous communication simply means the sender does not wait for a response. EDA is a specific style of asynchronous communication built around publishing facts. You can send asynchronous commands without using EDA.
7. What is the difference between choreography and orchestration?
In choreography, each service listens to events and decides its next action on its own, with no central controller. In orchestration, a central orchestrator tells each service what to do and tracks progress. Choreography is more decoupled; orchestration is easier to follow for long, complex workflows.
8. What is eventual consistency?
It means that different services may briefly hold different views of the data, but they will all agree once every event has been processed. For example, the order page may show "Processing" for a second before it shows "Confirmed".
9. How do I handle duplicate events?
Make consumers idempotent so processing the same event twice has the same effect as processing it once. A common approach is to save each handled eventId and skip any ID that already exists:
if (processedEvents.existsById(event.eventId())) {
return; // already handled
}
handle(event);
processedEvents.save(new ProcessedEvent(event.eventId()));
10. Does Kafka guarantee the order of events?
Kafka keeps order only within a single partition. To keep all events for one entity in order, publish them with the same key (for example, the orderId) so they always land in the same partition.
11. What is a Dead Letter Topic or Dead Letter Queue?
It is a separate topic or queue where events are moved after they fail processing too many times. This stops one bad message from blocking the rest and lets engineers inspect, fix and replay it later.
12. What is the Transactional Outbox pattern and why is it needed?
It solves the dual-write problem. Instead of saving to the database and then publishing to the broker (which can fail halfway), the service saves the business data and the event to an outbox table in one transaction. A relay or CDC tool then publishes the outbox rows to the broker.
13. How is Event Sourcing related to EDA?
Event Sourcing stores every change to an entity as an event and rebuilds the current state by replaying them. EDA is about services communicating through events. You can use EDA without Event Sourcing, but the two work well together.
14. How does the Saga pattern use events?
A Saga splits a business transaction into local steps, one per service. Each step publishes an event that triggers the next. If a step fails, compensating events undo the earlier steps, for example PaymentFailed leads to InventoryReleased and OrderCancelled.
15. How should I version events safely?
- Add new fields as optional and keep old fields working.
- Include a
versionfield in each event. - For breaking changes, publish a new event type or version (
OrderPlacedV2) alongside the old one for a while. - Use a schema registry with Avro, Protobuf or JSON Schema to enforce compatibility.
16. How can I trace a request across many event-driven services?
Put a correlation ID in every event header and pass it along to any new events. Combine it with distributed tracing tools such as OpenTelemetry, Zipkin or Jaeger so you can see the full journey of one request.
17. How do I test event-driven microservices?
Use unit tests for event handlers, Testcontainers to run a real Kafka or RabbitMQ instance in integration tests, and contract tests to make sure producers and consumers agree on the event structure. Spring Kafka also offers an @EmbeddedKafka annotation for lightweight tests.
18. When should I avoid Event-Driven Architecture?
Avoid it when the caller needs an immediate answer (such as a login check), when the application is small and simple, or when the team is not ready to run a broker and handle eventual consistency. A plain REST or gRPC call is often the better choice in these cases.
19. What happens if a consumer service is down?
Events remain stored in the broker. When the consumer comes back, it continues from its last committed position (offset in Kafka) and processes the backlog. Producers and other consumers are not affected during the outage.
20. How much data should an event carry?
Carry enough data for consumers to do their work without calling the producer back, but avoid sending sensitive or unrelated fields. A good balance is the identifiers, the key business values that changed and a timestamp. Very large objects are better stored elsewhere and referenced by ID.