Microservices7 min read·By Liyabona Saki·

Designing Event-Driven Microservices with Kafka and Spring Boot

A complete production guide to event-driven microservices with Kafka and Spring Boot — producers, consumers, topics, partitions, consumer groups, retry strategies, schema evolution and operational best practices.

Persistent Out-of-Order State Corruption

In a distributed system, assuming events arrive in the order they were produced is a recipe for a corrupted database. Between the producer, the Kafka partition, and your Spring Boot consumer, there are a dozen places where network jitter or a retry loop can flip the order of two operations. If a UserAddressUpdated event arrives before the UserCreated event, your consumer might throw a 404 Not Found or, worse, upsert a ghost record with missing fields.

To defeat this, you must treat the Kafka partition key as your primary synchronization tool. Kafka guarantees ordering per partition, not per topic. If you use a round-robin distribution, you lose the safety of the sequence.

yaml
spring:
  cloud:
    stream:
      kafka:
        binder:
          producer-properties:
            enable.idempotence: true
            acks: all
            retries: 2147483647
            max.in.flight.requests.per.connection: 5

By setting the partition key to the aggregate_id (e.g., userId), you ensure that all events for a specific entity end up in the same partition and are processed sequentially by a single consumer thread. Setting enable.idempotence: true is non-negotiable; it prevents the producer from introducing duplicates if a network ACK is lost during a retry. Without this, the broker might receive the same message twice, but with different internal sequence numbers, breaking your business logic.

Poison Pill Deadlocks and Consumer Starvation

A single malformed JSON payload or a breaking schema change can halt your entire processing pipeline. When a Spring Boot @KafkaListener fails to deserialize a message or hits an unhandled exception, the default behavior often involves retrying the offset indefinitely or logging an error and moving on. The former causes "Consumer Starvation"—where one bad message blocks all subsequent valid messages—and the latter results in silent data loss.

The mitigation is a multi-tier Dead Letter Topic (DLT) strategy combined with a SeekToCurrentErrorHandler. Instead of letting the consumer crash, you catch the exception, publish the payload to a .DLT topic with a header containing the stack trace, and acknowledge the original message.

java
@Configuration
public class KafkaRetryConfig {
    @Bean
    public DefaultErrorHandler errorHandler(KafkaOperations<Object, Object> template) {
        // Retry 3 times with a 1-second backoff
        var backOff = new FixedBackOff(1000L, 3);
        var recoverer = new DeadLetterPublishingRecoverer(template,
            (record, ex) -> new TopicPartition(record.topic() + ".DLT", record.partition()));
        
        DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
        // Do not retry for non-retriable exceptions like Deserialization issues
        handler.addNotRetryableExceptions(MethodArgumentNotValidException.class);
        handler.addNotRetryableExceptions(MessageConversionException.class);
        return handler;
    }
}

This ensures that the pipeline continues to move. In production at scale, we found that 90% of failures were transient (DB deadlocks or 503s), while 10% were "Poison Pills" (schema mismatches). Separating these via the addNotRetryableExceptions method prevents useless retries that inflate your p99 latency.

Zombie Consumers and Split-Brain Rebalancing

In a high-throughput environment, "Stop-the-World" rebalances are the enemy of availability. If a consumer takes too long to process a batch (exceeding max.poll.interval.ms), the Kafka broker assumes the consumer is dead, kicks it out of the group, and reassigns its partitions to another node. If the "dead" consumer eventually finishes and tries to commit its offset, it becomes a "Zombie."

The adversary here is the "Split-Brain" state where two different consumers believe they own the same partition. This leads to double-processing and race conditions.

text
Cluster State during Rebalance:
[Consumer A] --- holds P0 --- [Processing expensive batch...]
[Broker]     --- heartbeat timeout! --- [Triggering Rebalance]
[Consumer B] --- assigned P0 --- [Started processing same data!]
[Consumer A] --- attempts commit --- [Error: CommitFailedException]

To defeat this, transition from eager rebalancing to Incremental Cooperative Rebalancing. By setting partition.assignment.strategy to org.apache.kafka.clients.consumer.CooperativeStickyAssignor, the broker only revokes the specific partitions that need to move, rather than stopping the entire consumer group. Furthermore, tune your max.poll.records down. If your processing logic takes 500ms per record and your max.poll.interval.ms is 300 seconds (the default), do not poll more than 500 records. We observed a drop in p99 latency from 480ms to 90ms simply by aligning the batch size with the actual processing throughput of the JVM.

Schema Drift and Payload Sabotage

As microservices evolve, the "Producer" might add a field or rename an existing one. If the "Consumer" is using a strict POJO mapping without a schema registry, the service will crash the moment the first new message hits the wire. This is "Payload Sabotage"—where an upstream change destroys downstream stability.

The mitigation is the Confluent Schema Registry (or its equivalent) using Avro or Protobuf. Do not use JSON for inter-service communication in Kafka. JSON is too flexible; it lacks formal contracts that can be enforced at the broker level.

When you use Avro, the producer checks the schema against the registry before sending. If the change is not "Backward Compatible," the registry rejects the schema, and the CI/CD pipeline fails. This moves the failure from the production runtime to the build phase.

java
// Logic for a robust consumer with schema evolution
@KafkaListener(topics = "${app.topic.orders}", groupId = "order-processor")
public void processOrder(@Payload OrderV2 order, @Header(KafkaHeaders.RECEIVED_KEY) String key) {
    // OrderV2 can handle optional fields from OrderV1 
    // because Avro generated classes handle nullability and defaults natively
    log.info("Processing order {} for user {}", order.getOrderId(), key);
}

In one production incident, a dev renamed user_id to userId in a JSON payload. Every consumer in the cluster crashed simultaneously. If we had been using Avro with a BACKWARD compatibility level, the registry would have blocked that schema update, saving hours of rollback effort.

The Dual-Write Atomicity Gap

The most dangerous threat to data integrity in event-driven systems is the "Dual-Write" problem. This occurs when your service updates its local database AND THEN tries to publish an event to Kafka. If the database commit succeeds but the Kafka publish fails (perhaps due to a timeout or the broker being unreachable), your system is now in an inconsistent state. The database thinks the "Order is Created," but no other microservice knows about it.

You cannot wrap a DB transaction and a Kafka producer in the same @Transactional block and expect it to work perfectly; they are different resources with no distributed transaction coordinator (XA is not an option for high-scale Kafka).

To defeat the Dual-Write gap, implement the Transactional Outbox Pattern. Instead of sending to Kafka directly, write the event into an OUTBOX table in your local Postgres/MySQL database within the same transaction as your business logic.

sql
BEGIN;
INSERT INTO orders (id, status) VALUES (123, 'CREATED');
INSERT INTO outbox (id, aggregate_type, payload) VALUES (456, 'ORDER', '{"id":123, "status":"CREATED"}');
COMMIT;

A separate process (like a Debezium Kafka Connect agent or a scheduled Spring task) then polls the OUTBOX table and publishes to Kafka. This guarantees At-Least-Once Delivery. If the publisher fails after sending but before deleting the outbox entry, it will simply retry and send a duplicate—which is fine, because your consumers should be idempotent anyway.

Operational Visibility Degradation

In a monolith, you can trace a request via a stack trace. In a Kafka-driven architecture, breadcrumbs are lost across thread boundaries and network hops. This is "Visibility Degradation," where you know an event was produced but have no idea why it never reached the final consumer.

To defeat this, you must propagate Correlation IDs through Kafka headers. Spring Cloud Sleuth (now Micrometer Tracing) does this by default, injecting traceId and spanId into the Kafka record headers.

bash
# Debugging a message using kafkacat / kcat
kcat -b localhost:9092 -t orders-topic -C \
  -f '\nKey: %k, Payload: %s, Headers: %h'

Running the above command allows you to inspect the X-B3-TraceId in the headers. Without these headers, debugging a distributed race condition becomes an exercise in guesswork based on timestamps. When we enabled header-based tracing, the mean-time-to-resolution (MTTR) for cross-service bugs dropped from 4 hours to under 15 minutes.

Idempotency and the Replay Attack

If a consumer crashes after processing a message but before committing its offset to Kafka, it will receive the same message again when it restarts. This is a "Replay Attack" from the system's own perspective. If your logic is balance = balance - 100, a replay will drain the user's account twice.

Every event-driven consumer must be idempotent. This is achieved by maintaining a ProcessedEvent table in your database.

1. Start a local DB transaction. 2. Check if the event_id exists in processed_events. 3. If yes, ignore the message and commit (it's a duplicate). 4. If no, perform the business logic, insert the event_id into processed_events, and commit.

This "Read-then-Write" strategy ensures that even if Kafka delivers a message ten times, the side effect only happens once. The hard-won lesson here: never rely on Kafka's offset as your only source of truth for delivery. Offsets are about the consumer's position in the stream; your database is the only source of truth for the business state. Every professional Kafka implementation eventually reaches a point where the business state must protect itself from the messaging layer.

Ordering guarantees and how designs get them wrong

Kafka's ordering guarantee is 'within a partition, in order' — nothing stronger. Most incorrect event-driven designs assume more. Three specific mistakes to avoid. Cross-partition ordering assumed: two events for related aggregates land in different partitions and are consumed out of order; consumers see 'account closed' before 'account opened'. Fix: partition by the aggregate key that must be ordered (usually user_id or entity_id). Adding partitions later breaks ordering: the hash of a key changes when partition count changes; the same key now lands on a different partition and its history is split. Fix: pick the partition count with headroom at day zero, or accept a full rebuild when you scale. Consumer parallelism assumed independent of partition count: you cannot process a partition with more consumers than one within a consumer group. If a partition is slow, adding consumers does nothing. Partition sizing bounds your throughput ceiling.

Go deeper

Further reading

Source Code

Get the full project on GitHub

View repo →
#Kafka#Microservices#Spring Boot#Event-Driven#Architecture

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Microservices

Spring Boot Microservices Architecture Explained Step by Step

A complete, beginner-friendly walkthrough of microservices architecture using Spring Boot — services, gateway, discovery, config and observability.

Related tutorials