7. Async Messaging
Goal
Take the slow, fragile work out of BoxOffice's request path. The order service records each order and an OrderPlaced event in the same database transaction (transactional outbox); a relay publishes the event to Kafka; the notification service consumes it idempotently, sends the e-mail and parks poison messages in a dead-letter topic. Finally, a waiting room queues the flash-sale crowd so that only as many fans enter as the system can serve.
It is 10:00 and the stadium show goes on sale. Every POST /orders that the order service accepts must also produce a confirmation e-mail. If order calls notification over HTTP, the fan waits for the e-mail provider, an e-mail outage becomes an ordering outage, and the 2 million fans who arrive at the same second hit every service at once. This lab replaces that coupling with three ideas from the messaging class: events on a log, at-least-once delivery handled with idempotency, and queues that absorb a spike.
It builds chapter 8 of the story of the course. The decisions you implement are argued in the class: synchronous vs asynchronous, the transactional outbox, idempotent consumers, retries and dead letters and the flash-sale waiting room.
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
Before you start
| Time | about 4 hours (two meetings) |
| Tools | Docker with Compose v2 (docker compose version), k6 1.x (k6 version), curl, jq β all from lab 0. The Java services are built inside Docker: no local JDK or Maven needed for this lab |
| Docker resources | at least 4 CPUs and 8 GB of memory (Kafka and two more JVMs join the lab 6 stack) |
| Ports | free on your machine: 8084, 8086, 8089, 8025, 1025, 9094 (plus the lab 6 ones) |
Your repository must be in the state lab 6 left it. Every command of this lab runs from the root of your boxoffice repository.
boxoffice/
βββ docker-compose.yaml # db, db-replica, pgbouncer, redis, catalog-1..3, inventory, auth, gateway, edge
βββ infra/ # nginx/, postgres/, pgbouncer/
βββ load/ # k6 scripts of labs 0-6
βββ scripts/
βββ services/
βββ auth/
βββ catalog/
βββ gateway/
βββ inventory/
Catch up
Skipped or broke lab 6? Download boxoffice-after-lab-6.zip, unzip it, cd boxoffice, and continue from here.
Every file of this lab is shown in full on these pages, with its path as the title, and is also published under https://insper.github.io/platform/2027.1/hands-on/7/code/<path> β for example curl -fsSL -o docker-compose.yaml https://insper.github.io/platform/2027.1/hands-on/7/code/docker-compose.yaml.
Architecture
flowchart LR
fan[Fan] -->|POST /orders| gw[gateway]
gw --> order[order]
order -->|INSERT order| db[(PostgreSQL)]
order -->|"POST /notifications (waits)"| notification[notification]
notification -->|SMTP| mail[mailpit]
notification -->|201 after the e-mail| order
order -->|201| gw flowchart LR
fan[Fan] -->|join| wr[waiting room]
wr -->|admitted| gw[gateway]
gw -->|POST /orders| order[order]
order -->|"INSERT order + outbox (1 tx)"| db[(PostgreSQL)]
order -->|201 right away| gw
relay[outbox relay] -->|poll unpublished| db
relay -->|publish OrderPlaced| kafka[(Kafka: order-events)]
kafka -->|consumer group notification| notification[notification]
notification -->|"processed_events (dedupe)"| ndb[(PostgreSQL)]
notification -->|SMTP| mail[mailpit]
notification -.->|after 3 failed retries| dlt[(order-events-dlt)] The fan's request now ends at the database commit. Everything after the commit β publishing, e-mail, retries β happens asynchronously and can be slow, fail or be repeated without the fan noticing.
BoxOffice components touched
| Palette component | In this lab |
|---|---|
| Kafka | single-node Kafka 4 in KRaft mode, topic order-events with 6 partitions |
| Message Queue | the waiting room: a FIFO of fans in Redis, drained at a fixed admission rate |
| App Server | order (producer, outbox relay, waiting room) |
| Notifications | notification (idempotent consumer, DLT) |
| Mailpit, a local SMTP server with a web inbox |
What you will measure
| Question | Metric | Tool |
|---|---|---|
How much faster is POST /orders without the e-mail inline? | p50 / p95 / p99, error rate | k6 |
What happens to orders while notification is down? | error rate, consumer lag | k6, kafka-consumer-groups.sh |
| Does a burst build a backlog, and how fast does it drain? | lag over time, drain time, e-mail delay p95 | kafka-consumer-groups.sh, SQL |
| Does redelivery send duplicate e-mails? | e-mails per order | Mailpit API |
| Does the waiting room hold the admission rate under a spike? | admitted/s, queue position, order p95 | k6 |
Prerequisites
- Labs 1 to 6 done: your
boxofficerepository runsdb(PostgreSQL),redis,catalog,inventoryand thegatewaywithdocker compose. - About 2 GB of extra memory for Docker (Kafka is a JVM).
- Read the messaging class, especially Kafka, delivery semantics and the outbox pattern.
Steps
- Kafka in Compose β a KRaft broker, the
order-eventstopic, Kafka UI and Mailpit; a tour of the CLI. - Transactional outbox β the
orderservice writes the order and the event in one transaction; a relay publishes it. - Idempotent consumer and DLT β the
notificationservice dedupes byeventId, retries with backoff and dead-letters poison messages. - Waiting room β a Redis FIFO that admits fans at a fixed rate during the on-sale.
- Measure it β sync vs async latency, outage behavior, lag and drain time, duplicates, admission rate.
Try it in the playground
Before writing code, see the two shapes this lab builds. Try it: in the first simulator, run the load profile and watch every consumer service receive its own copy of each event (fan-out); then raise the target RPS until one consumer lags behind while the producer stays fast. In the second, run spike and watch the queue backlog grow and drain while the workers stay at a constant rate.
The full simulator is in the playground.
Measure it
The Measure it step has the k6 scripts and the results table. Fill it in and keep it in your repository: it is part of the deliverable.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
kafka stays starting/unhealthy; kafka-init never runs | not enough memory for Docker, or port 9094 in use | give Docker 8 GB; lsof -i :9094; docker compose logs kafka |
order/notification restart with Connection to node -1 β¦ could not be established | they started before Kafka was ready (only if you removed depends_on) | docker compose up -d order notification again once kafka is healthy |
order fails with Unable to obtain connection from database | db not healthy yet, or wrong DB_* variables | docker compose ps db; the variables are in the order service of docker-compose.yaml |
POST /orders answers 403 on-sale in progress | the waiting room of that show is open (step 4) | use another showId, or curl -X POST localhost:8084/orders/waiting-room/show-42/close |
| No e-mail in Mailpit | notification is down, lagging, or the event went to the DLT | docker compose logs notification; sh scripts/kafka-lag.sh; query notification.dead_letters |
docker compose exec kafka β¦ fails with service "kafka" is not running | the broker was stopped in an experiment | docker compose start kafka |
Build fails with Could not resolve dependencies | no Internet access during docker compose build | retry with network; Maven downloads once and caches (--mount=type=cache) |
Apple Silicon: no matching manifest for linux/arm64 | an image without an arm64 variant | all images of this lab are multi-arch; check you typed the tags exactly |
Best practices in this lab
- Write the event in the same transaction as the business change β
OrderService.javacallsOutbox.append(which refuses to run outside a transaction) andOutboxRelay.javapublishes after the commit. Why: transactional outbox. - Make the consumer idempotent in its own database β
NotificationService.javainserts theeventIdintoprocessed_events(V1__create_notification.sql) in the same transaction as the e-mail row. Why: idempotent consumers. - Bound the retries and keep poison messages for inspection β
KafkaConfig.java(3 retries, thenorder-events-dlt; parse errors not retried) andDeadLetterListener.java(thedead_letterstable). Why: retries and dead letter queues. - Create topics explicitly and produce durably β
docker-compose.yaml(kafka-init, auto-creation off) andservices/order/src/main/resources/application.yaml(acks: all,enable.idempotence: true). Why: replication and durability. - Admit the crowd at a configured, measured rate, in an order the server decides β
WaitingRoom.java(INCRsequence,ADMIT_PER_SECOND). Why: the flash-sale waiting room.
Clean up
When you finish (or before lab 8), stop the stack and keep the data, or remove everything:
docker compose stop # keep containers and volumes for the next lab
docker compose down -v # or: remove containers, networks and volumes (all data)
Lab 8 starts from this state; nothing needs to be kept besides your repository.
End state
boxoffice/
βββ docker-compose.yaml # + kafka, kafka-init, kafka-ui, mailpit, order, notification
βββ load/
β βββ orders-load.js
β βββ waiting-room-spike.js
βββ scripts/
β βββ kafka-lag.sh
βββ services/
βββ notification/ # new: idempotent consumer, DLT, e-mail
βββ order/ # new: orders, outbox relay, waiting room
Commit and tag: git add -A && git commit -m "lab 7: async messaging" && git tag lab7.
Checklist
-
kafka,kafka-init,kafka-uiandmailpitrun indocker-compose.yaml;order-eventsandorder-events-dlthave 6 partitions each -
orderwritesordersandoutboxin one transaction; retryingPOST /orderswith the sameholdIdreturns the same order and creates no new event - the outbox relay publishes with key =
orderIdand marks rows as published; stopping Kafka loses no event -
notificationsends one e-mail per order even after an offset reset (duplicates are skipped) - a poison message lands in
order-events-dltafter the retries, and the partition keeps flowing - the waiting room admits at the configured rate;
POST /orderswithout an admission ticket is refused during the on-sale - results table of the Measure it step filled in
results/lab7.md, with a short explanation of each number - self-check:
docker compose down -v && docker compose up -d --buildfrom a clean clone brings everything up healthy; then pushed to GitHub with the taglab7
Related
- Theory: Messaging & event-driven Β· Queues and pub/sub Β· Kafka Β· Patterns: outbox, saga, CQRS Β· Delivery semantics
- Case study: Ticketing (BoxOffice flash sale)
- Exercise: Event-driven
- Next lab: 8. Observability traces this flow end to end, across the Kafka hop.