Plataformas, Microsserviços, DevOps e APIs · Hands-on 9

Hands-on 9 — Mensageria com Kafka

O order-service publica OrderCreated; um notification-service reage por conta própria

O e-mail deve bloquear o cliente?

Toda chamada que o order-service faz — ao account-service, ao product-service — acontece antes de ele responder.
Agora precisamos enviar um e-mail de confirmação. Mais uma chamada REST?

  • O cliente não precisa do e-mail para concluir o pedido: é fire-and-forget.
  • Então publique um evento OrderCreated no Kafka e retorne.
  • Um novo notification-service o consome de forma assíncrona — o order-service nem sabe que ele existe.

Arquitetura

Antes

cliente → gateway → order-service → account-service, product-service (REST, síncrono)

Depois

As mesmas chamadas REST, mais:
order-service → tópico order-events → notification-service (Kafka, assíncrono)

Desacoplamento: amanhã um analytics-service pode consumir order-events sem mexer em uma única linha do order-service.

Os passos

  1. Docker Compose — adicionar o Kafka (modo KRaft, sem ZooKeeper) e o Kafka UI.
  2. order-service — spring-kafka, o record OrderCreatedEvent, config do producer, publisher.
  3. notification-service — um novo consumer em Python / FastAPI.
  4. Teste — criar um pedido, ver o evento no Kafka UI e nos logs de notificação.
  5. Confiabilidade — dead letter queue e consumers idempotentes.

1 · Docker Compose

Serviço Imagem Porta Papel
kafka apache/kafka:4.3.1 9092 broker + controller KRaft
kafka-ui provectuslabs/kafka-ui:latest 8090 → 8080 tópicos, mensagens, lag dos consumers

Configurações principais do Kafka:

  • KAFKA_PROCESS_ROLES: broker,controller — KRaft: o Kafka guarda os próprios metadados; o ZooKeeper foi removido no Kafka 4.0.
  • KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 — clientes se conectam pelo nome do serviço.
  • KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" — order-events é criado na primeira publicação.
  • KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 — um único broker em desenvolvimento.

2 · order-service: o evento

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
) {}

Um record: imutável, como o fato que ele representa.

2 · Configuração do producer

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 espera todas as réplicas sincronizadas antes de confirmar. Sem efeito com um único broker; em produção, evita perda de dados no failover de um broker.

2 · Publicando

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

No OrderService, depois de salvar: eventPublisher.publishOrderCreated(savedOrder);

Tópico order-events, chave = id do pedido, valor = o evento em 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, roda uvicorn main:app na porta 8080.
  • Na inicialização, uma thread daemon executa o loop do consumer.
  • GET /health → {"status": "up"}.
  • No compose: build: ./notification-service, depends_on: kafka, PYTHONUNBUFFERED=1.

3 · O 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() depois do processamento: uma queda no meio do processamento faz a mensagem ser consumida de novo — at-least-once.

4 · Teste

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: o evento com orderId: ord-7f3a.

5 · Quando o processamento falha: DLQ

Uma mensagem que falha sempre não pode travar o fluxo. Envie-a para order-events-dlq:

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

Nos dois casos o offset avança: o fluxo principal continua, e a DLQ é inspecionada depois.

Armadilhas

At-least-once significa que duplicatas acontecem. O consumer deve ser idempotente: antes de enviar, verifique se um e-mail já foi enviado para esse orderId.

  • Publique depois de salvar o pedido — nunca anuncie um pedido que não existe.
  • Dentro da rede Docker, o broker é kafka:9092, não localhost:9092.
  • acks: all só protege os dados quando há mais de um broker.
  • Use o Kafka UI para conferir tópicos, mensagens e o lag do consumer group ao depurar.

Pontos-chave

  1. Use REST quando quem chama precisa da resposta agora; use eventos para trabalho fire-and-forget.
  2. O order-service publica OrderCreated em order-events e não conhece seus consumers.
  3. O Kafka roda em modo KRaft no compose (sem ZooKeeper); o Kafka UI mostra tópicos, mensagens e lag.
  4. Spring: KafkaTemplate.send(topic, key, event) com serializer JSON e acks: all.
  5. Python: um KafkaConsumer no grupo notification-group com commit manual → at-least-once.
  6. Mensagens que falham vão para uma DLQ; os consumers devem ser idempotentes.

Roteiro: (1) por que não mais uma chamada REST; (2) Kafka (modo KRaft) e Kafka UI no compose; (3) o producer do order-service; (4) o consumer Python do notification-service; (5) teste; (6) DLQ, idempotência.