9. Kafka Messaging
Goal
Add asynchronous messaging to the platform. When an order is created, publish an OrderCreated event to Apache Kafka. A new notification-service (Python/FastAPI) consumes this event asynchronously and simulates sending a confirmation email β without the order-service knowing the notification-service exists.
The platform currently uses synchronous REST calls for all inter-service communication. Every call the order-service makes β to account-service, to product-service β happens inline, before the response is returned to the client. This works for operations where the caller needs a result immediately. But some operations are fire-and-forget: when an order is created, sending an email confirmation doesn't need to block the customer. This hands-on adds Apache Kafka as the messaging backbone for exactly these cases.
Slides
Click the slides and use β β to move through them; F goes full screen and P opens the presenter view, with the notes. Open the slides in a new tab
1. Architecture
flowchart LR
client[Client] -->|REST| gw[Gateway]
gw -->|REST| order[order-service]
order -->|REST sync| account[account-service]
order -->|REST sync| product[product-service] flowchart LR
client[Client] -->|REST| gw[Gateway]
gw -->|REST| order[order-service]
order -->|REST sync| account[account-service]
order -->|REST sync| product[product-service]
order -->|"Kafka (async)\norder-events"| kafka[(Kafka)]
kafka -->|"Kafka (async)"| notification[notification-service] Decoupling
The order-service publishes to a topic called order-events. It has no knowledge of the notification-service. Tomorrow you could add an analytics-service consuming the same topic without touching a single line of order-service code.
2. Docker Compose β Adding Kafka
Add Kafka and Kafka UI to docker-compose.yml. A single Kafka 4 node runs in KRaft mode β no ZooKeeper container. Kafka UI provides a browser-based interface for inspecting topics and messages during development.
kafka:
image: apache/kafka:4.3.1
ports:
- "9092:9092"
environment:
# KRaft combined mode: this single node is both broker and controller
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
# single broker: internal topics cannot have more than 1 replica
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
kafka-ui:
image: provectuslabs/kafka-ui:latest
depends_on:
- kafka
ports:
- "8090:8080"
environment:
KAFKA_CLUSTERS_0_NAME: local
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
KRaft β no ZooKeeper
Kafka keeps its cluster metadata (topics, partitions, leaders) in its own Raft-based controller quorum, KRaft. Before, this metadata lived in a separate ZooKeeper ensemble; Kafka 4.0 (2025) removed ZooKeeper support entirely. Here the single node is both broker and controller (KAFKA_PROCESS_ROLES: broker,controller): clients talk to the PLAINTEXT listener on kafka:9092, and the controller quorum uses the CONTROLLER listener on port 9093. See Event-Driven Architecture.
Kafka UI
Access Kafka UI at http://localhost:8090. You can browse topics, inspect messages, and monitor consumer group lag β essential for debugging during development.
3. Order Service β Publishing Events
3.1 Dependency
Add spring-kafka to the order-service's pom.xml:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
3.2 Event Record
Create OrderCreatedEvent.java in the store.order package:
package store.order;
public record OrderCreatedEvent(
String orderId,
String customerId,
Double totalAmount,
String timestamp
) {}
3.3 Kafka Producer Configuration
Add the following to application.yaml:
spring:
kafka:
bootstrap-servers: kafka:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: all
retries: 3
properties:
enable.idempotence: true
acks=all
Setting acks=all means the Kafka broker will wait for all in-sync replicas to acknowledge the write before confirming. In a single-broker development setup this has no effect, but in production it prevents data loss during broker failover.
3.4 Publisher
Create OrderEventPublisher.java:
package store.order;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import java.time.Instant;
@Component
@RequiredArgsConstructor
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
public void publishOrderCreated(Order order) {
var event = new OrderCreatedEvent(
order.getId().toString(),
order.getCustomerId(),
order.getTotalAmount(),
Instant.now().toString()
);
kafkaTemplate.send("order-events", order.getId().toString(), event);
}
}
3.5 Calling the Publisher
In OrderService.java, inject OrderEventPublisher and call it after saving the order:
4. Notification Service (Python / FastAPI)
A new Python microservice that consumes order-events and logs a simulated email notification.
4.1 File Structure
4.2 Code
Add the notification-service to docker-compose.yml:
notification-service:
build: ./notification-service
depends_on:
- kafka
environment:
- PYTHONUNBUFFERED=1
enable_auto_commit=False
Manual commit (consumer.commit()) ensures the offset advances only after the message has been successfully processed. If the service crashes mid-processing, it will re-consume the message on restart β at-least-once delivery semantics.
5. Testing
5.1 Start Everything
$ docker compose up --build -d
$ docker compose ps
NAME STATUS
kafka running
kafka-ui running
notification-service running
order-service running
5.2 Create an Order
$ curl -s -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"cust-42","items":[{"productId":"p1","quantity":2}]}' \
| jq .
{
"id": "ord-7f3a",
"status": "CREATED",
"totalAmount": 49.98
}
5.3 Verify in Kafka UI
Open http://localhost:8090, navigate to Topics β order-events β Messages. You should see the OrderCreated event with orderId: ord-7f3a.
5.4 Verify Notification
$ docker compose logs notification-service
notification-service | INFO: [NOTIFICATION] Order ord-7f3a for customer cust-42 β sending confirmation email (total: 49.98)
6. Reliability β Dead Letter Queue
When a consumer fails to process a message repeatedly, it should go to a DLQ rather than blocking the stream. The Python manual DLQ pattern publishes failed events to a separate order-events-dlq topic instead of crashing:
try:
process_event(event)
consumer.commit()
except Exception as e:
logger.error(f"Failed to process {event['orderId']}: {e} β sending to DLQ")
dlq_producer.send("order-events-dlq", value=event)
consumer.commit()
At-least-once delivery
Kafka guarantees at-least-once delivery β a message may be delivered more than once (e.g., after a consumer crash before commit). Your consumer logic should be idempotent: processing the same event twice must produce the same result as processing it once. For the notification service, this means checking whether an email was already sent for this orderId before sending again.
7. Checklist
- Kafka (KRaft mode) + Kafka UI added to
docker-compose.yml -
OrderCreatedEventrecord created in order-service -
spring-kafkadependency added,application.yamlconfigured -
OrderEventPublisherpublishes toorder-eventsafter save - notification-service consumes events and logs confirmations
- Kafka UI shows the event after creating an order
- DLQ pattern understood (idempotent consumer)