Platforms, Microservices, DevOps and APIs · Hands-on 9

Hands-on 9 — Kafka Messaging

The order-service publishes OrderCreated; a notification-service reacts on its own

Should the email block the customer?

Every call the order-service makes — to account-service, to product-service — happens before it answers.
Now we must send a confirmation email. Add one more REST call?

  • The customer does not need the email to finish the order: it is fire-and-forget.
  • So publish an OrderCreated event to Kafka and return.
  • A new notification-service consumes it asynchronously — the order-service does not even know it exists.

Architecture

Before

client → gateway → order-service → account-service, product-service (REST, sync)

After

Same REST calls, plus:
order-service → topic order-events → notification-service (Kafka, async)

Decoupling: tomorrow an analytics-service can consume order-events without touching a single line of the order-service.

The steps

  1. Docker Compose — add Kafka (KRaft mode, no ZooKeeper) and Kafka UI.
  2. order-service — spring-kafka, the OrderCreatedEvent record, producer config, publisher.
  3. notification-service — a new Python / FastAPI consumer.
  4. Test — create an order, see the event in Kafka UI and in the notification logs.
  5. Reliability — dead letter queue and idempotent consumers.

1 · Docker Compose

Service Image Port Role
kafka apache/kafka:4.3.1 9092 broker + KRaft controller
kafka-ui provectuslabs/kafka-ui:latest 8090 → 8080 browse topics, messages, consumer lag

Key Kafka settings:

  • KAFKA_PROCESS_ROLES: broker,controller — KRaft: Kafka keeps its own metadata; ZooKeeper was removed in Kafka 4.0.
  • KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 — clients connect by the service name.
  • KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" — order-events is created on first publish.
  • KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 — a single broker in development.

2 · order-service: the event

pom.xml:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

store.order.OrderCreatedEvent:

public record OrderCreatedEvent(
    String orderId,
    String customerId,
    Double totalAmount,
    String timestamp
) {}

A record: immutable, like the fact it represents.

2 · Producer configuration

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 waits for every in-sync replica before confirming. No effect on a single broker; in production it prevents data loss on broker failover.

2 · Publishing

@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);
    }
}

In OrderService, after saving: eventPublisher.publishOrderCreated(savedOrder);

Topic order-events, key = order id, value = the event as JSON.

3 · notification-service (Python / FastAPI)

notification-service/
    main.py
    Dockerfile
    requirements.txt
fastapi==0.110.0
kafka-python==2.3.2
uvicorn==0.29.0
  • Dockerfile: python:3.12-slim, runs uvicorn main:app on port 8080.
  • On startup, a daemon thread runs the consumer loop.
  • GET /health → {"status": "up"}.
  • In compose: build: ./notification-service, depends_on: kafka, PYTHONUNBUFFERED=1.

3 · The consumer

consumer = KafkaConsumer(
    "order-events",
    bootstrap_servers="kafka:9092",
    group_id="notification-group",
    value_deserializer=lambda m: json.loads(m.decode("utf-8")),
    auto_offset_reset="earliest",
    enable_auto_commit=False,
)
for message in consumer:
    event = message.value
    logger.info(f"[NOTIFICATION] Order {event['orderId']} ... sending confirmation email")
    consumer.commit()

enable_auto_commit=False + commit() after processing: a crash mid-processing means the message is consumed again — at-least-once.

4 · Test

docker compose up --build -d
docker compose ps        # kafka, kafka-ui, notification-service, order-service
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 }
docker compose logs notification-service
# [NOTIFICATION] Order ord-7f3a for customer cust-42 — sending confirmation email (total: 49.98)
  • Kafka UI http://localhost:8090 → Topics → order-events → Messages: the event with orderId: ord-7f3a.

5 · When processing fails: DLQ

A message that keeps failing must not block the stream. Send it to order-events-dlq instead:

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()

Either way the offset advances: the main stream continues, the DLQ is inspected later.

Pitfalls

At-least-once means duplicates happen. The consumer must be idempotent: before sending, check whether an email was already sent for this orderId.

  • Publish after saving the order — never announce an order that does not exist.
  • Inside the Docker network the broker is kafka:9092, not localhost:9092.
  • acks: all only protects data when there is more than one broker.
  • Use Kafka UI to check topics, messages and consumer group lag while debugging.

Takeaways

  1. Use REST when the caller needs the answer now; use events for fire-and-forget work.
  2. The order-service publishes OrderCreated to order-events and does not know its consumers.
  3. Kafka runs in KRaft mode in compose (no ZooKeeper); Kafka UI shows topics, messages and lag.
  4. Spring: KafkaTemplate.send(topic, key, event) with a JSON serializer and acks: all.
  5. Python: a KafkaConsumer in group notification-group with manual commit → at-least-once.
  6. Failing messages go to a DLQ; consumers must be idempotent.

Outline: (1) why not one more REST call; (2) Kafka (KRaft mode) and Kafka UI in compose; (3) the order-service producer; (4) the Python notification-service consumer; (5) test; (6) DLQ, idempotency.