Anıl Şenocak

In modern distributed systems, we often need to:
Traditional messaging with Kafka is fire-and-forget (asynchronous), but many business scenarios require synchronous behavior:
This implementation creates a synchronous abstraction layer over Kafka’s asynchronous nature by:
┌─────────────────┐ request-topic ┌─────────────────┐
│ Kafka Client │ ──────────────────► │ Kafka Server │
│ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │Pending │ │ │ │Request │ │
│ │Requests │ │ │ │Processor │ │
│ │Map │ │ │ │ │ │
│ └─────────────┘ │ │ └─────────────┘ │
│ │ ◄────────────────── │ │
└─────────────────┘ response-topic- └─────────────────┘
{instanceId}
Purpose: Sends synchronous requests and waits for responses
Key Features:
Implementation Highlights
fun sendSyncRequest(request: String): String {
val correlationId: UUID = UUID.randomUUID()
val future = CompletableFuture<String>()
pendingRequests\[correlationId\] = future
// Send request with correlation ID and instance ID in headers
val headers: List<RecordHeader> = listOf(
RecordHeader("correlationId", correlationId.toString().toByteArray()),
RecordHeader("instanceId", kafkaProperties.instanceId.toByteArray())
)
val record: ProducerRecord<String, String> = ProducerRecord(
kafkaProperties.requestTopic,
null,
correlationId.toString(),
request,
headers
)
producer.send(record) { metadata: RecordMetadata?, exception: Exception? ->
log.info("Sent request to topic ${kafkaProperties.requestTopic} with correlationId: $correlationId")
if (exception != null) {
future.completeExceptionally(exception)
pendingRequests.remove(key = correlationId)
}
}
return try {
// Block until response is received or timeout occurs
future.get(kafkaProperties.requestTimeout, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
pendingRequests.remove(key = correlationId)
log.error("Request timed out or failed. Exception: ${e.localizedMessage}")
throw RuntimeException("Request timed out or failed", e)
} finally {
log.info("Request with correlationId: $correlationId completed")
future.complete(null)
}
}
Start the response consumer in a separate thread to listen for responses based on correlation IDs:
private fun consumeResponses() {
while (true) {
try {
val records: ConsumerRecords<String, String> = consumer.poll(Duration.ofMillis(100))
for (record: ConsumerRecord<String, String> in records) {
log.info("Received response for correlationId: ${record.key()}")
val correlationId: UUID = record.headers()
.first { it.key() == "correlationId" }
?.value()
?.let { String(bytes = it) }
?.let { UUID.fromString(it) }
?: continue
val future: CompletableFuture<String>? = pendingRequests.remove(key = correlationId)
future?.complete(record.value())
}
} catch (e: Exception) {
log.error("Error consuming responses. Exception: ${e.localizedMessage}")
}
}
}
Purpose: Processes requests and sends responses back to the appropriate client.
Key Features:
private fun consumeRequests() {
while (running) {
try {
// Poll for new requests every 100ms
val records: ConsumerRecords<String, String> = consumer.poll(Duration.ofMillis(100))
for (record: ConsumerRecord<String, String> in records) {
log.info("Received request: ${record.value()} from topic: ${record.topic()}")
// Extract correlation ID from message headers
val correlationId: String = record.headers()
.first { it.key() == "correlationId" }
?.value()
?.let { String(it) }
?: continue // Skip messages without correlation ID
// Extract instance ID to route response correctly
val instanceId: String = record.headers()
.first { it.key() == "instanceId" }
?.value()
?.let { String(it) }
?: continue // Skip messages without instance ID
// Process the actual business logic
val processedResponse = processBusinessLogic(record.value(), correlationId)
// Send response to client-specific topic
sendResponse(instanceId, correlationId, processedResponse)
}
} catch (e: Exception) {
log.error("Error processing requests: ${e.localizedMessage}")
}
}
}
Processing Flow Explained
private fun sendResponse(instanceId: String, correlationId: String, response: String) {
// Construct instance-specific response topic
val responseTopicName = "${kafkaProperties.responseTopic}\-$instanceId"
// Create response record with correlation headers
val responseRecord: ProducerRecord<String, String> = ProducerRecord(
responseTopicName, // Topic: response-topic-{instanceId}
null, // Partition: let Kafka decide
correlationId, // Key: correlation ID for ordering
response, // Value: processed response
listOf( // Headers: for client correlation
RecordHeader("correlationId", correlationId.toByteArray())
)
)
// Send response asynchronously with callback
producer.send(responseRecord) { metadata: RecordMetadata?, exception: Exception? ->
if (exception != null) {
log.error("Failed to send response for correlation ID $correlationId: ${exception.localizedMessage}")
} else {
log.info("Response sent to $responseTopicName for correlation ID: $correlationId")
}
}
}
kafka:
bootstrap-servers: localhost:9092
request-topic: request-topic
response-topic: response-topic
request-timeout: 30000
The project includes a complete Kafka setup using KRaft mode (no Zookeeper required):
kafka_kraft:
image: docker.io/bitnami/kafka:3.4
ports:
- "9092:9092"
environment:
- KAFKA_ENABLE_KRAFT=yes
- KAFKA_AUTO_CREATE_TOPICS_ENABLE=true
Each application instance gets its own response topic:
High Throughput Streaming: Pure async is better for high volume
Fire-and-Forget Operations: No need for response correlation
Long-Running Processes: Better suited for callback patterns
Simple Request/Response: HTTP REST might be simpler
| Aspect | Kafka Sync Pattern | HTTP REST | Traditional MQ |
|---|---|---|---|
| Scalability | High (with partitioning) | Medium | Medium |
| Reliability | High (built-in replication) | Medium | High |
| Complexity | High | Low | Medium |
| Performance | Medium (due to polling) | High | Medium |
| Use Case | Distributed async-to-sync | Simple request/response | Traditional messaging |
This Kafka synchronous pattern bridges the gap between Kafka’s asynchronous nature and synchronous business requirements. It’s particularly valuable in microservice architectures where you need the benefits of Kafka (scalability, durability, ordering) while maintaining request-response semantics.
The implementation demonstrates advanced Kafka concepts including:
This pattern is production-ready and can be adapted for various enterprise scenarios requiring reliable, scalable, synchronous communication over Kafka infrastructure.
Full code example can be found in github