Overview
Event-Driven Architecture (EDA) with Kafka decouples services through asynchronous event streams. Events are immutable facts that happened — "OrderPlaced", "PaymentProcessed", "InventoryReserved". Services react to events without knowing who produced them. This page covers the patterns that make EDA robust: event sourcing for audit and replay, CQRS for read/write optimization, and schema evolution for long-running event streams.
Architecture
EVENT-DRIVEN ORDER PROCESSING
════════════════════════════════════════════════════════
Order Service Kafka Topics
────────────────────────── ──────────────────────────
1. User places order ┌─── order.placed ───────────────▶ Inventory
2. Validate + persist │ │
3. Emit OrderPlaced ──────────┘ ┌─── inventory.reserved ──────────┘
│
Payment Service ◀────────────────── └─── ▶ Payment Service
│ │
│ charge card ┌─── payment.processed ──────┐
│ emit PaymentProcessed ──────────┘ │
│
Notification Service ◀────────────────────────────────────────────┘
│ (listens to OrderPlaced + PaymentProcessed)
│ sends confirmation email
FAILURE COMPENSATION:
PaymentFailed →
Inventory Service listens → release reservation (compensating tx)
Order Service listens → mark order FAILED
EVENT SOURCING vs STATE STORAGE
════════════════════════════════════════════════════════
Traditional (state store):
table: orders { id, status, total, ... }
UPDATE orders SET status='CONFIRMED' WHERE id=1
→ only current state, history lost
Event Sourcing:
events table (append-only):
[OrderCreated][PaymentProcessed][OrderShipped][OrderDelivered]
→ current state = replay all events for order-id
→ full audit trail, time-travel queries, replayable
Snapshot: save state every N events to speed up replay
(don't replay from beginning for an order with 1000 events)
CQRS (Command Query Responsibility Segregation)
════════════════════════════════════════════════════════
WRITE SIDE: optimized for commands (normalize, transactional)
READ SIDE: optimized for queries (denormalized, no joins)
User places order → Command → Write DB (normalized)
→ Event published to Kafka
Kafka consumer → updates Read DB (denormalized view)
User queries dashboard → Read DB (fast, no joins needed)
Technical Implementation
Domain Event Publishing with Outbox Pattern
// Transactional outbox: event written to DB in same transaction as state change
// Debezium CDC reads outbox table and publishes to Kafka
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id private UUID id;
private String topic;
private String aggregateId;
private String eventType;
private String payload; // JSON
private Instant createdAt;
private boolean published;
}
@Service
@Transactional
public class OrderCommandHandler {
public Order placeOrder(PlaceOrderCommand cmd) {
// 1. Business logic + state change
Order order = Order.create(cmd.userId(), cmd.items());
orderRepo.save(order);
// 2. Write event to outbox — SAME transaction
outboxRepo.save(OutboxEvent.builder()
.id(UUID.randomUUID())
.topic("order.placed")
.aggregateId(order.getId())
.eventType("OrderPlaced")
.payload(toJson(new OrderPlacedEvent(order)))
.createdAt(Instant.now())
.published(false)
.build());
return order;
// Transaction commits both order + event atomically
// Debezium reads outbox table via binlog → publishes to Kafka
}
}
Avro Schema with Schema Registry
// Order event schema: order.avsc
// {
// "namespace": "com.example.events",
// "type": "record",
// "name": "OrderPlaced",
// "fields": [
// {"name": "orderId", "type": "string"},
// {"name": "userId", "type": "string"},
// {"name": "total", "type": "double"},
// {"name": "currency", "type": {"type": "string", "default": "USD"}} ← NEW FIELD
// ]
// }
@Bean
public KafkaTemplate<String, OrderPlaced> kafkaTemplate() {
Map<String, Object> producerProps = Map.of(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092",
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class,
// Schema Registry: validates schema compatibility before publishing
"schema.registry.url", "http://schema-registry:8081",
"auto.register.schemas", false, // don't auto-register in prod
"use.latest.version", true
);
return new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(producerProps));
}
// Schema evolution rules (BACKWARD compatible — new schema can read old data):
// SAFE: add optional field with default value
// SAFE: remove a field (old consumers ignore it)
// UNSAFE: rename a field (breaks deserialization)
// UNSAFE: change field type
// UNSAFE: add required field without default
Event Store for Event Sourcing
@Repository
public class OrderEventStore {
private final JdbcTemplate jdbc;
// Load current order state by replaying its events
public Order loadOrder(String orderId) {
List<DomainEvent> events = jdbc.query(
"SELECT event_type, payload FROM order_events WHERE order_id = ? ORDER BY sequence_number",
(rs, row) -> deserialize(rs.getString("event_type"), rs.getString("payload")),
orderId
);
if (events.isEmpty()) throw new OrderNotFoundException(orderId);
// Replay events to rebuild state
Order order = Order.empty();
for (DomainEvent event : events) {
order.apply(event); // apply() is the state transition function
}
return order;
}
// Append new events (never update, only insert)
@Transactional
public void save(String orderId, List<DomainEvent> newEvents, long expectedVersion) {
long currentVersion = getCurrentVersion(orderId);
if (currentVersion != expectedVersion) {
throw new ConcurrencyException("Order " + orderId + " was modified concurrently");
}
long seq = currentVersion;
for (DomainEvent event : newEvents) {
jdbc.update(
"INSERT INTO order_events (order_id, sequence_number, event_type, payload) VALUES (?, ?, ?, ?)",
orderId, ++seq, event.getClass().getSimpleName(), toJson(event)
);
}
}
}
Interview Preparation
Q: What is the difference between domain events and integration events?
Domain events are facts meaningful within a bounded context — OrderConfirmed, InventoryReserved. They carry domain language and are often used internally within a service or aggregate (e.g., triggering @EventListener in the same service). Integration events cross service boundaries — they're published to Kafka and consumed by other services. Integration events should be versioned, schema-validated (Avro + Schema Registry), and designed for backward compatibility. Domain events can be richer and change more freely; integration events are contracts between teams and change slowly.
Q: What is the Outbox Pattern and why is it needed?
Without the outbox, you'd either: (a) commit the DB transaction then publish to Kafka — if Kafka publish fails, the transaction committed but the event was never sent (inconsistency), or (b) publish to Kafka then commit DB — if DB commit fails, the event was already published for an action that didn't happen. The outbox pattern writes the event to an outbox table in the same database transaction as the business state change — they succeed or fail atomically. A relay (Debezium CDC, polling relay) reads the outbox and publishes to Kafka, marking events as published. This guarantees at-least-once delivery: an event is published if and only if the business transaction committed.
Q: What is CQRS and when is it worth the complexity?
CQRS (Command Query Responsibility Segregation) separates the write model (commands that change state) from the read model (queries that read state). The write side is optimized for consistency — normalized schema, transactional. The read side is optimized for query patterns — denormalized, prejoined, potentially stored in a different database (Elasticsearch for search, Redis for latency). Events from the write side asynchronously update the read side. Worth the complexity when: read patterns are diverse and don't fit normalized schema, read load vastly exceeds write load (most e-commerce), or different consistency requirements exist for reads vs writes. Not worth it for simple CRUD apps where a single normalized schema works fine.
Learning Resources
- Martin Fowler — Event Sourcing
- Martin Fowler — CQRS
- Debezium — CDC and Outbox
- Confluent Schema Registry
- Designing Event-Driven Systems — Ben Stopford (free PDF from Confluent)