Intermediate · Decouple
Production Implementation: Outbox Order Events
Commit events beside business state with the outbox pattern, consume idempotently, and prove both under failure.
Primary source: Spring Boot 3.5 Reference: Messaging with Apache Kafka
Lesson 4 of 4
1d streakThe Architectural Question
Dual writes—save to the database then publish to Kafka—are guaranteed to diverge under crashes, producing orders without events or events for orders that rolled back.
What this guide proves
- Write the outbox row in the same transaction as business data
- Publish via a relay and consume deduplicated on event id
- Prove no lost and no duplicate side effects under crash
Schema Registry & Apache Avro Compatibility Rules
In large-scale enterprise messaging, publishing unstructured JSON dictionaries across microservices leads to silent breaking changes. Confluent Schema Registry with Apache Avro or Protobuf establishes strict schema contracts.
| Compatibility Mode | Evolution Rule | Best Practice |
|---|---|---|
| BACKWARD | Consumers with new schema can read data written with old schema | Delete fields; add optional fields with defaults |
| FORWARD | Consumers with old schema can read data written with new schema | Add fields; delete optional fields with defaults |
| FULL | Both backward and forward compatible | Recommended for decoupled microservices with rolling updates |
Consumer Concurrency & Partition Alignment
A Kafka consumer group cannot scale beyond the number of partitions in a topic. If a topic has 6 partitions and your consumer factory configures concurrency = 12, 6 consumer threads will remain completely idle.
Align your topic partition count with target throughput and configure container concurrency to match partition distribution across your Kubernetes pods.
Distributed Tracing & W3C Trace Context over Kafka Headers
Spring Boot can connect Kafka observations to Micrometer Tracing when the tracing bridge and exporter are configured, but propagation is not automatic in every setup. Spring Kafka requires observation to be enabled on the KafkaTemplate and listener container; only then can configured propagators inject and extract W3C context such as traceparent.
End-to-End Production Messaging Verification Checklist
Verify production event-driven pipeline performance and partition health:
# 1. Check consumer lag across consumer groups
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group inventory-group --describe
# Verify LAG is 0 under nominal traffic
# 2. Inspect active consumer partition assignments
# Verify all partitions are evenly balanced across active podsActive Recall Checkpoint
What happens if you have 8 partitions and 10 consumer instances in the same consumer group?
Hint: Recall the rule: at most one consumer per partition per consumer group.
Show answer & explanation
8 consumers are assigned exactly 1 partition each, and 2 consumers remain idle as hot standbys with no assigned partitions.
Working Implementation
Code Walkthrough
Emit order-created events through an outbox table and make the consumer safe to run twice on every message.
package com.example.messaging;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Service;
@Service
public class MessagingCapstoneLab {
private final KafkaTemplate<String, String> kafkaTemplate;
public MessagingCapstoneLab(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishEvent(String topic, String key, String payload) {
kafkaTemplate.send(topic, key, payload);
}
@KafkaListener(
topics = "orders.events.v1",
groupId = "fulfillment-service",
concurrency = "3"
)
public void handleOrderEvent(
String payload,
@Header(KafkaHeaders.RECEIVED_KEY) String key,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition) {
// Concurrency = 3 distributes partitions across 3 worker threads in this pod
}
}Core Spring Implementation
Outbox-backed order event flow
Persist order events in a transactional outbox, relay committed rows to Kafka, and deduplicate consumers so redelivery cannot double-create fulfillment.
Component Boundaries
- 01OrderService saves the order and the outbox row in one database transaction; Kafka receives nothing until that commit.
- 02A relay polls committed outbox rows, publishes them to orders.placed, and marks rows published.
- 03FulfillmentConsumer guards every side effect behind a unique-indexed processed_events check on eventId.
Required Evidence
- Poison messages land in orders.placed.dlt via @RetryableTopic or DefaultErrorHandler with DeadLetterPublishingRecoverer, and replay requires an operator command plus an audit entry.
- An automated test republishes one eventId twice and asserts exactly one fulfillment row.
- kafka-consumer-groups.sh --describe --group fulfillment shows consumer lag draining to 0 within the release SLO after each deploy.
Implementation sequence
Step-by-Step Integration
Follow these structured steps to inject Core Spring functionality cleanly across your boundaries.
Commit the outbox row beside business state
OrderService.java
Splitting the write across broker and database either loses events on crash or publishes rolled-back work; one transaction makes both artifacts appear together or not at all.
@Transactional
OrderId place(PlaceOrder command) {
Order order = orders.save(Order.from(command));
outbox.save(OutboxEvent.orderPlaced(order.id(), command.requestId()));
return order.id();
}Verify Configuration
Throw inside place() after orders.save, then confirm psql returns 0 for SELECT count(*) FROM outbox WHERE request_id = ? and 0 orders rows for the same requestId.
Make the consumer idempotent on eventId
FulfillmentConsumer.java
Kafka delivers at-least-once; producer retries and group rebalances replay consumed offsets, so the unique index on processed_events turns duplicate deliveries into no-ops.
@KafkaListener(topics = "orders.placed", groupId = "fulfillment")
@Transactional
void on(OrderPlaced event) {
if (processedEvents.existsById(event.eventId())) return;
fulfillment.createFor(event.orderId());
processedEvents.save(new ProcessedEvent(event.eventId()));
}Verify Configuration
kafka-console-producer sends the same OrderPlaced payload twice; SELECT count(*) FROM fulfillments stays 1 and the second delivery returns before fulfillment.createFor runs.
Synthesis & Production Verification
Deliberate Failure Condition
- Break: Publish to Kafka after committing the order in two separate steps
- Observe: Downstream consumers miss some created orders while others exist twice
- Repair: The outbox makes the database the source of truth; a relay publishes what committed, and consumer-side deduplication absorbs at-least-once delivery.
Production Acceptance Criteria
- Crash between commit and publish and lose nothing after relay catch-up
- Replay one consumed event twice with a single applied effect
- Monitor outbox backlog so relay failure is visible
Key Takeaway
Production event-driven architecture aligns consumer concurrency with partition distribution, enforces schema evolution rules, and propagates W3C distributed tracing spans.
Architectural & Technical Review
Core Interview Questions
Q01How do Confluent Schema Registry compatibility modes prevent breaking changes across microservices?Show answer
FULL compatibility ensures that services deployed on older versions can read events written by newer versions, and vice versa. It enforces adding optional fields with defaults and forbids removing required fields without deprecation cycles.
Q02What happens if consumer container concurrency exceeds the number of partitions in a Kafka topic?Show answer
Because Kafka assigns at most one consumer thread per partition per consumer group, excess consumer threads remain idle, wasting memory and thread resources without increasing throughput.
Q03How does Micrometer Tracing propagate trace context across asynchronous Kafka boundaries?Show answer
KafkaTemplate injects W3C traceparent and tracestate headers into Kafka record headers, and @KafkaListener extracts them, seamlessly linking asynchronous event processing back to the originating HTTP request trace.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Publishing untyped JSON schemas with arbitrary field renaming across microservice boundaries — Causes silent deserialization crashes in downstream consumer services during rolling deployments.
- Pitfall: Over-allocating consumer concurrency beyond topic partition count — Spawns idle worker threads that consume heap memory without contributing to message consumption.
Module Certification Quiz
Prove Module Mastery
Comprehensive question set covering all lessons in this module.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources