Event-Driven Architecture in Java: Designing Systems That React to What Happened
Most business systems are built around requests. A customer clicks "place order", the frontend calls the order service, the order service calls payment, then inventory, then shipping, and the caller waits for everything to finish. This synchronous chain is easy to understand and easy to debug at small scale. It also creates tight coupling: every service must know about every other service that cares about an order, and a slow or failing dependency blocks the whole flow.
Event-driven architecture (EDA) offers a different model. Instead of asking other services to do things, a service announces that something happened: OrderPlaced, PaymentCaptured, InventoryReserved. Interested services subscribe to those facts and react on their own schedule. The producer does not need to know who is listening, and the system can grow new reactions without changing the code that emitted the original event.
This article explains the core ideas, the patterns that matter most in practice, and how to implement them in Java with Spring Boot and Apache Kafka. It also covers the trade-offs that teams often discover too late: eventual consistency, duplicate delivery, and the difficulty of debugging flows that no single service owns.
Events, commands, and queries
The vocabulary matters because mixing these concepts causes design mistakes.
- An event is a fact about the past, named in the past tense:
OrderPlaced. It is immutable and can have many consumers. - A command is a request for an action in the future, named as an imperative:
ReserveInventory. It has one intended handler. - A query asks for information and changes nothing.
Event-driven systems are built mostly from events, with commands used sparingly inside a bounded context. When a team publishes ReserveInventory on a shared topic and then waits for a reply, they have rebuilt synchronous coupling with extra latency. A useful test: if the producer would be surprised by a new consumer, the message is probably an event. If the producer expects a specific reaction, it is probably a command, and it should go directly to its owner.
Core patterns
Three patterns cover most real designs.
Event notification is the lightest form. An event carries only an identifier and perhaps a few fields, and consumers call back to the source if they need more detail. This keeps messages small and avoids copying data, but it increases coupling at runtime, because consumers depend on the source being available.
Event-carried state transfer puts the relevant data into the event itself. A CustomerAddressChanged event carries the new address, so downstream services can maintain their own copies. This improves autonomy, at the cost of larger messages and the need to manage schema evolution carefully.
Event sourcing stores the sequence of events as the source of truth, and derives current state by replaying them. It provides a complete audit trail and makes it easy to rebuild projections, but it is a significant commitment. Use it where history matters, such as financial ledgers, and avoid it as a default.
Most teams start with event notification or event-carried state transfer, and reserve event sourcing for the few aggregates that truly need it.
Modeling an event
Events should be explicit, versioned, and small enough to be useful without being a dumping ground for the database row. A Java record is a good fit:
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record OrderPlaced(
UUID eventId,
int schemaVersion,
Instant occurredAt,
UUID orderId,
UUID customerId,
BigDecimal totalAmount,
String currency) {
public static OrderPlaced of(UUID orderId, UUID customerId, BigDecimal total, String currency) {
return new OrderPlaced(UUID.randomUUID(), 1, Instant.now(), orderId, customerId, total, currency);
}
}
The eventId is essential. Message brokers usually guarantee at-least-once delivery, which means consumers will sometimes see the same event twice. A stable identifier lets consumers detect and ignore duplicates. The schemaVersion field makes it possible to evolve the payload without breaking older consumers, and occurredAt records when the fact happened, which is often different from when it was processed.
Publishing reliably: the outbox pattern
A common bug looks like this: the service saves an order to its database, then publishes OrderPlaced to Kafka. If the process crashes between the two steps, the order exists but nobody knows about it. Reversing the order has the opposite problem: an event announces an order that was never saved.
The transactional outbox pattern solves this. The service writes the event to an outbox table in the same database transaction as the state change. A separate relay process reads unpublished rows and sends them to the broker, marking them as published afterward. Because the state change and the outbox row commit together, the system never loses or invents events.
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
@Service
public class OrderService {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public OrderService(JdbcTemplate jdbc, ObjectMapper json) {
this.jdbc = jdbc;
this.json = json;
}
@Transactional
public void placeOrder(UUID orderId, UUID customerId, java.math.BigDecimal total) throws Exception {
jdbc.update("INSERT INTO orders (id, customer_id, total, status) VALUES (?, ?, ?, 'PLACED')",
orderId, customerId, total);
OrderPlaced event = OrderPlaced.of(orderId, customerId, total, "USD");
jdbc.update("""
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES (?, 'Order', ?, 'OrderPlaced', ?::jsonb, now())
""",
event.eventId(), orderId, json.writeValueAsString(event));
}
}
The relay is a scheduled job or a change-data-capture connector such as Debezium. A simple polling relay looks like this:
import java.util.List;
import java.util.Map;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class OutboxRelay {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 500)
public void publishPending() {
List<Map<String, Object>> rows = jdbc.queryForList("""
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
""");
for (Map<String, Object> row : rows) {
String key = row.get("aggregate_id").toString();
String payload = row.get("payload").toString();
String topic = "orders." + row.get("event_type").toString().toLowerCase();
kafka.send(topic, key, payload).join();
jdbc.update("UPDATE outbox SET published_at = now() WHERE id = ?", row.get("id"));
}
}
}
Two details matter. The message key is the aggregate ID, which keeps events for the same order in one partition and therefore in order. And the relay may publish an event and crash before marking it, so it will publish again after restart. That is acceptable because consumers are designed to handle duplicates.
Consuming safely: idempotency
Every consumer must assume it will receive the same event more than once, and possibly out of order across different aggregates. The standard defense is to record processed event IDs in the same transaction as the side effect.
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.databind.ObjectMapper;
@Component
public class InventoryConsumer {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public InventoryConsumer(JdbcTemplate jdbc, ObjectMapper json) {
this.jdbc = jdbc;
this.json = json;
}
@KafkaListener(topics = "orders.orderplaced", groupId = "inventory-service")
@Transactional
public void onOrderPlaced(String payload) throws Exception {
OrderPlaced event = json.readValue(payload, OrderPlaced.class);
int inserted = jdbc.update(
"INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING",
event.eventId());
if (inserted == 0) {
return; // already processed, ignore the duplicate
}
reserveStockFor(event.orderId());
}
private void reserveStockFor(UUID orderId) {
// Business logic: reserve items, update stock, emit InventoryReserved via the outbox
}
}
The insert into processed_events and the business update share a transaction, so either both happen or neither does. A duplicate delivery finds the event ID already present and exits quietly. Keep the processed-event table pruned after a retention window that exceeds your maximum redelivery time.
Failure handling
Consumers fail. A database is unavailable, a downstream API times out, or a message contains data that cannot be processed. Three mechanisms help:
-
Retries with backoff for transient failures. Spring Kafka's retry topics or a
DefaultErrorHandlerwith exponential backoff handle this well. - Dead-letter topics for messages that keep failing. Route them somewhere visible, with the original payload and the error, and assign an owner to inspect them.
- Replay from the broker. Because Kafka retains events for a configured period, a fixed consumer can reprocess history. This only works if consumers are idempotent, which is why idempotency comes first.
Do not let a poison message block an entire partition indefinitely. A consumer stuck on one bad event is a silent outage for everything behind it.
Observability for flows nobody owns
The hardest operational problem in event-driven systems is understanding a business flow that spans five services and three topics. No single log shows the full story. Teams mitigate this in three ways.
First, propagate a correlation identifier in every event header, and log it everywhere. A search on one order ID should reveal every event, every consumer, and every failure.
Second, measure consumer lag. A growing lag means a consumer cannot keep up, and it is often the earliest warning of an incident. Alert on lag trends, not only on errors.
Third, keep a catalog of events: who publishes each one, who consumes it, what schema version is in use, and who to contact. Without this, changing a field becomes guesswork.
Trade-offs to accept deliberately
Event-driven architecture is not free. It trades immediate consistency for autonomy and resilience. A customer may see an order as "placed" before inventory is reserved, and the system must handle the case where reservation fails afterward, typically with a compensating event such as OrderCancelled or a saga. Users need interfaces that show pending states honestly rather than pretending everything is instant.
Testing becomes harder, because behavior emerges from the interaction of many consumers. Invest in contract tests for event schemas, and in integration tests that run the real broker for critical flows. Keep a small number of well-understood end-to-end scenarios, rather than trying to test every combination.
Finally, avoid the temptation to make everything an event. Synchronous calls are the right choice when a caller needs an answer now, such as validating a payment card before showing a confirmation screen. Choose the style per interaction, not per architecture diagram.
A practical adoption roadmap
Weeks 1 to 2: Pick one business flow with clear value, such as order processing, and map its current synchronous calls. Identify which ones are notifications masquerading as requests.
Weeks 3 to 4: Introduce the outbox table and a relay in the producing service. Publish events for the flow while keeping the existing calls, so you can compare behavior.
Weeks 5 to 6: Add one consumer with idempotency, retries, and a dead-letter topic. Migrate one downstream responsibility to it, and monitor lag and error rates.
Weeks 7 and beyond: Create the event catalog, add correlation identifiers across services, and gradually retire the synchronous calls that no longer need to be synchronous.
Key takeaways
- Events describe facts in the past tense; commands request future actions and should have a single owner.
- Use the transactional outbox so state changes and events are never out of step.
- Assume at-least-once delivery, and make every consumer idempotent with a processed-events record.
- Key messages by aggregate ID to preserve ordering where it matters.
- Plan for failures with retries, dead-letter topics, and replay, and keep poison messages from blocking partitions.
- Make business flows observable with correlation IDs, consumer lag monitoring, and an event catalog.
- Choose synchronous or asynchronous interaction deliberately, per use case.
Event-driven architecture rewards teams that treat events as contracts and take the operational discipline seriously. Start with one flow, get the publishing and consuming patterns right, and let the rest of the system follow when the benefits are proven.
Top comments (0)