Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Microservices with Kafka

Kafka serves as the messaging backbone for many microservices architectures, providing asynchronous communication, event-driven integration, and data streaming between services. This document covers Kafka-specific architectural decisions and patterns for microservices environments.


How topics are owned and managed significantly impacts team autonomy, data governance, and operational complexity.

Each service owns its output topics. Services publish events about their domain; other services subscribe as needed.

Per-Service Topic OwnershipPer-Service Topic OwnershipOrder ServiceInventory ServiceNotification Serviceorders.eventsinventory.eventsnotifications.eventsTopic naming: {domain}.{type}Owner: Order teamSchema: Order team definesowns/producesowns/producesowns/producessubscribessubscribessubscribes

Benefits:

  • Clear ownership and accountability
  • Teams control their own schemas
  • Independent deployment and scaling
  • Natural bounded context alignment

Topic naming conventions:

{service}.{entity}.{event-type}
Examples:
orders.order.created
orders.order.shipped
inventory.stock.reserved
inventory.stock.depleted
payments.payment.completed

Multiple services collaborate on shared domain topics. Useful for cross-cutting concerns.

Shared Domain TopicsShared Domain TopicsService AService BService Cdomain.customer.eventsShared ownership requires:- Schema compatibility rules- Cross-team coordination- Clear event type ownershipproduces CustomerCreatedproduces CustomerVerifiedproduces CustomerSuspended

When to use:

  • Aggregate events from multiple sources
  • Cross-cutting audit/compliance streams
  • Shared reference data

Governance requirements:

  • Central schema registry with compatibility enforcement
  • Clear documentation of which service owns which event types
  • Breaking change coordination process

Consumer group design affects scaling, fault isolation, and message ordering guarantees.

Each service instance joins the same consumer group, sharing partition load.

Consumer Group Per ServiceConsumer Group Per ServiceOrder ServiceNotification Servicepayments.eventsOrder Service(3 instances)Order Service(3 instances)Notification Service(2 instances)Notification Service(2 instances)payments.events(6 partitions)payments.events(6 partitions)consumer-group:order-service(partitions 0-5)consumer-group:notification-service(partitions 0-5)Each service has independent:- Offset tracking- Scaling- Failure handling

Configuration:

# Order Service
group.id=order-service
client.id=order-service-${HOSTNAME}
# Notification Service
group.id=notification-service
client.id=notification-service-${HOSTNAME}

A service may need multiple consumption patterns for the same topic.

Multiple Groups in One ServiceMultiple Groups in One ServiceAnalytics ServiceReal-timeProcessorBatchAggregatorAuditLoggerorders.eventsSame service, different processing needs:- Different offset management- Different scaling requirements- Different failure handlinganalytics-realtime(latest offset)analytics-batch(periodic reset)analytics-audit(all events)

Use cases:

  • Real-time vs batch processing
  • Primary processing vs audit logging
  • Different retention/replay requirements

Schema management becomes critical when multiple teams produce and consume events.

Schema Evolution FlowSchema Evolution FlowProducerSchemaKafkaConsumerProducerServiceProducerServiceSchemaRegistrySchemaRegistryKafkaKafkaConsumerServiceConsumerServiceSchema RegistrationRegister schema v2Check compatibility(BACKWARD)alt[Compatible]Schema ID: 42[Incompatible]409 ConflictFix schemaMessage FlowMessage withschema ID: 42MessageGet schema 42Schema definitionDeserialize
StrategyProducer ChangesConsumer ChangesUse Case
BACKWARDAdd optional fields, remove fieldsMust handle missing fieldsDefault for events
FORWARDRemove fields, add required fieldsMust ignore unknown fieldsAPI responses
FULLOnly optional field additionsHandle missing + ignore unknownStrict contracts
NONEAny change allowedMust coordinateDevelopment only

Recommended approach:

# Production topics
orders.events: BACKWARD_TRANSITIVE
payments.events: BACKWARD_TRANSITIVE
# Internal/development
orders.internal.debug: NONE
build.gradle
// Shared schema module published as library
// teams depend on specific versions
dependencies {
implementation 'com.company:order-events-schema:2.3.0'
implementation 'com.company:payment-events-schema:1.5.0'
}

Schema ownership rules:

  1. Producing service owns the schema
  2. Schema changes require PR review from known consumers
  3. Breaking changes require deprecation period
  4. Schema modules versioned with semantic versioning

Correlation across service boundaries is essential for debugging and monitoring.

Trace Propagation Through KafkaTrace Propagation Through KafkaOrder ServiceInventory ServiceNotification ServiceAPI GatewayOrder ServiceKafkaInventory ServiceNotification ServiceAPI GatewayAPI GatewayOrder ServiceOrder ServiceKafkaKafkaInventory ServiceInventory ServiceNotification ServiceNotification ServiceOrder ServiceInventory ServiceNotification ServiceHTTP RequestX-Trace-ID: abc123ProducerRecordheader[trace-id]: abc123header[span-id]: span-001ConsumerRecordExtract trace-id: abc123Create child span: span-002ConsumerRecordExtract trace-id: abc123Create child span: span-003All operations visible undersingle trace ID: abc123
public class TracingProducerInterceptor implements ProducerInterceptor<String, Object> {
@Override
public ProducerRecord<String, Object> onSend(ProducerRecord<String, Object> record) {
Span currentSpan = Tracer.currentSpan();
if (currentSpan != null) {
record.headers().add("trace-id",
currentSpan.context().traceId().getBytes());
record.headers().add("span-id",
currentSpan.context().spanId().getBytes());
record.headers().add("parent-span-id",
currentSpan.context().parentSpanId().getBytes());
}
return record;
}
}
public class TracingConsumerInterceptor implements ConsumerInterceptor<String, Object> {
@Override
public ConsumerRecords<String, Object> onConsume(ConsumerRecords<String, Object> records) {
for (ConsumerRecord<String, Object> record : records) {
String traceId = extractHeader(record, "trace-id");
String parentSpanId = extractHeader(record, "span-id");
// Create child span linked to producer
Span span = Tracer.newChildSpan(traceId, parentSpanId)
.name("kafka.consume")
.tag("topic", record.topic())
.tag("partition", record.partition())
.start();
// Store in thread-local for downstream use
Tracer.setCurrentSpan(span);
}
return records;
}
}
HeaderPurposeExample
trace-idUnique ID for entire request flowabc123def456
span-idID for this specific operationspan-001
parent-span-idID of calling operationspan-000
correlation-idBusiness correlation (order ID, etc.)order-789
causation-idID of event that caused this eventevent-456

Kafka consumers require special consideration during deployments to avoid message loss or duplication.

Rolling Deployment with Consumer RebalanceRolling Deployment with Consumer RebalanceBeforeDuringAfterv1v1v1v2v1v1v2v2v2Rebalance triggered:1. Instance removed from group2. Partitions reassigned3. New instance joins4. Another rebalance

Configuration for smooth rolling deploys:

# Reduce rebalance disruption
session.timeout.ms=30000
heartbeat.interval.ms=10000
max.poll.interval.ms=300000
# Cooperative rebalancing (Kafka 2.4+)
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor
Blue/Green Consumer DeploymentBlue/Green Consumer DeploymentBlue (Current)Green (New)order-service-blue-1order-service-blue-2order-service-green-1order-service-green-2orders.commandsGreen uses different consumer group.On cutover:1. Stop blue consumers2. Reset green offsets to blue position3. Start green consumersgroup: order-service(active)group: order-service-green(standby, different group)

Cutover procedure:

Terminal window
# 1. Record blue's current offsets
kafka-consumer-groups.sh --describe --group order-service
# 2. Stop blue deployment
kubectl scale deployment order-service-blue --replicas=0
# 3. Reset green to blue's offsets
kafka-consumer-groups.sh --group order-service-green \
--reset-offsets --to-current --execute
# 4. Rename green's group (or reconfigure)
# Application config: group.id=order-service
# 5. Start green
kubectl scale deployment order-service-green --replicas=3

Route percentage of partitions to canary instances.

Canary Consumer DeploymentCanary Consumer DeploymentStable (v1)Canary (v2)v1 instances (10)v2 instances (2)orders.events(12 partitions)Same consumer group.Canary gets ~17% traffic.Monitor error rates beforescaling canary up.partitions 0-9partitions 10-11

For cases requiring synchronous-style responses over async infrastructure.

Request-Reply PatternRequest-Reply PatternClient ServicerequestsresponsesServer ServiceClient ServiceClient ServicerequestsrequestsresponsesresponsesServer ServiceServer ServiceRequestcorrelation-id: req-123reply-to: responsesConsume requestProcessResponsecorrelation-id: req-123Consume responsewhere correlation-id = req-123Client creates temporaryconsumer or filters bycorrelation-id

Implementation considerations:

public class KafkaRequestReply {
private final Map<String, CompletableFuture<Response>> pending =
new ConcurrentHashMap<>();
public CompletableFuture<Response> request(Request request, Duration timeout) {
String correlationId = UUID.randomUUID().toString();
CompletableFuture<Response> future = new CompletableFuture<>();
pending.put(correlationId, future);
// Send request
ProducerRecord<String, Request> record =
new ProducerRecord<>("requests", request);
record.headers().add("correlation-id", correlationId.getBytes());
record.headers().add("reply-to", "responses".getBytes());
producer.send(record);
// Timeout handling
future.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
.whenComplete((r, ex) -> pending.remove(correlationId));
return future;
}
// Response consumer
@KafkaListener(topics = "responses")
public void handleResponse(ConsumerRecord<String, Response> record) {
String correlationId = extractHeader(record, "correlation-id");
CompletableFuture<Response> future = pending.remove(correlationId);
if (future != null) {
future.complete(record.value());
}
}
}

Anti-Pattern Alert

Request-reply over Kafka adds significant latency compared to direct HTTP/gRPC. Use only when:

  • Decoupling is more important than latency
  • Request must be durable (survives client restart)
  • Load leveling is required

Event Notification vs Event-Carried State Transfer

Section titled “Event Notification vs Event-Carried State Transfer”
Event Styles ComparisonEvent Styles ComparisonEvent NotificationEvent-Carried State Transfer{"type": "OrderCreated","orderId": "123"} Consumer must call backto get order details{"type": "OrderCreated","orderId": "123","customerId": "456","items": [...],"total": 99.99,"shippingAddress": {...}} Consumer has all needed dataNo callback required
AspectEvent NotificationEvent-Carried State
Message sizeSmallLarge
CouplingHigher (callback needed)Lower (self-contained)
FreshnessAlways currentPoint-in-time snapshot
Consumer complexityHigherLower
Producer complexityLowerHigher

Recommendation: Prefer event-carried state transfer for microservices to reduce runtime coupling.


Anti-Pattern: Kafka as RPCAnti-Pattern: Kafka as RPCService AService BKafkaProblems:- High latency (100ms+ vs 10ms HTTP)- Complex error handling- Timeout management difficult- Resource waste while waiting Use HTTP/gRPC for sync callsRequestWait for responseProcessResponse
Anti-Pattern: Topic ProliferationAnti-Pattern: Topic ProliferationProblematicBetterorders.createdorders.updatedorders.shippedorders.deliveredorders.cancelledorders.refundedorders.events(event type in payload)100s of topics = operational burden:- Monitoring complexity- ACL management- Consumer group sprawlSingle topic, multiple event types:- Ordered within partition- Simpler operations- Event type filtering in consumer
Anti-Pattern: Distributed MonolithAnti-Pattern: Distributed MonolithService AService BService CKafkaIf services must process in strict orderand cannot function independently,Kafka adds complexity without benefit. Consider: Is this really microservices,or a monolith with network hops?Must processbefore C can startSequentialdependency

# Prometheus alerts per service
groups:
- name: kafka-consumer-lag
rules:
- alert: OrderServiceConsumerLag
expr: kafka_consumer_group_lag{group="order-service"} > 10000
for: 5m
labels:
service: order-service
severity: warning
- alert: NotificationServiceConsumerLag
expr: kafka_consumer_group_lag{group="notification-service"} > 50000
for: 10m
labels:
service: notification-service
severity: warning
Terminal window
# Order service can produce to its own topics
kafka-acls.sh --add --allow-principal User:order-service \
--producer --topic 'orders.*'
# Order service can consume from payment events
kafka-acls.sh --add --allow-principal User:order-service \
--consumer --topic 'payments.events' --group 'order-service'
# Deny order service access to other service topics
kafka-acls.sh --add --deny-principal User:order-service \
--producer --topic 'inventory.*'
@Component
public class ResilientKafkaConsumer {
private final CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("kafka-consumer");
@KafkaListener(topics = "orders.events")
public void consume(ConsumerRecord<String, OrderEvent> record) {
circuitBreaker.executeRunnable(() -> {
processEvent(record.value());
});
}
private void processEvent(OrderEvent event) {
// Call downstream service
// If downstream fails repeatedly, circuit opens
// Consumer pauses processing (backpressure)
}
}

ConcernRecommendation
Topic ownershipPer-service topics with clear naming conventions
Consumer groupsOne group per service; multiple groups for different processing needs
Schema governanceSchema registry with BACKWARD compatibility; producing service owns schema
TracingPropagate trace context via headers; use standard correlation IDs
DeploymentCooperative rebalancing; blue/green for zero-downtime
CommunicationEvent-carried state transfer; avoid Kafka for sync RPC