Event-driven food-ordering platform: a public API gateway in front of 4 microservices (menu/product, order, kitchen, notification), talking to each other over RabbitMQ and each owning its own PostgreSQL database. Built as a system-level QA project — the event flow, persistence, and failure handling are the actual point, not just the CRUD surface.
flowchart TB
client([Client / Browser])
client -->|HTTP :80| nginx["nginx — sole public entry point"]
nginx -->|/| frontend["frontend · React UI"]
nginx -->|"/api/*, /health"| gateway["API Gateway"]
gateway -->|sync HTTP| menu["menu service"]
gateway -->|sync HTTP| order["order service"]
order -->|"order.placed"| rmq{{RabbitMQ}}
rmq --> kitchen["kitchen service"]
rmq --> notification["notification service"]
kitchen -->|"accepted / ready"| rmq
menu --> pg[("PostgreSQL · db-per-service")]
order --> pg
kitchen --> pg
notification --> pg
order -.->|idempotency / cache| redis[("Redis")]
kitchen -.-> redis
notification -.-> redis
menu -.-> redis
- nginx is the system's only public entry point. Every other service — RabbitMQ, Postgres, Redis, the gateway, and the frontend container itself — is reachable only from other containers on the internal Docker network, never from the host.
- Synchronous HTTP exists only on the path client → nginx → gateway → service, for user-facing reads (menu, order status). The actual order workflow (placed → accepted → ready → notified) happens entirely over RabbitMQ events.
- Each service owns its own Postgres database; none of them ever reads or writes another service's tables.
One command, no manual setup steps — database schemas and seed data are
created automatically on first boot (docker/init.sql creates one database
per service; each service creates its own tables and seeds itself on startup):
docker compose up --buildPublic entry point: http://localhost (port 80, via nginx). This is the
only address the system exposes — RabbitMQ's management UI, Postgres, and
Redis are not reachable from the host with this command.
# Place an order
curl -X POST http://localhost/api/orders \
-H 'content-type: application/json' \
-d '{"customerId":"33333333-3333-3333-3333-333333333333","items":[{"menuItemId":"44444444-4444-4444-4444-444444444444","name":"Margherita","quantity":2,"priceCents":1200}]}'
# Check its status (replace with the orderId from the response above)
curl http://localhost/api/orders/<orderId>
# Read the menu
curl http://localhost/api/menuWatch docker compose logs -f kitchen notification to see the event flow:
kitchen accepts the order immediately, marks it ready ~3s later (simulated
cook time), and notification logs all 3 order events as they happen.
A small React UI lives at http://localhost/ (same nginx, same port 80) —
menu → cart → place order → a status screen that polls
GET /api/orders/:id live as it moves placed → accepted → ready, plus a
localStorage-backed order history. It's same-origin with the API (nginx
serves both under port 80), so there's no CORS configuration anywhere —
that's deliberate, not an oversight.
For local frontend development with hot reload, run the backend stack as usual and run the frontend separately on the host:
docker compose up -d
bun run dev:frontend # Vite dev server; proxies /api to http://localhost:80For direct access to Postgres/RabbitMQ/Redis (e.g. a DB GUI, or running one
service bare with bun run dev:<service> against dockerized infra), use the
dev override, which is not loaded by default:
docker compose -f docker-compose.yml -f docker-compose.dev.yml upbun run test # unit + integration (Testcontainers) + end-to-end
bun run typecheckRuns automatically in CI on every push and pull request (.github/workflows/ci.yml).
No mocks for any infrastructure — integration and e2e tests spin up real
Postgres/RabbitMQ/Redis containers. The frontend's tests are split the same
way: pure logic (frontend/tests/lib/, no DOM) and component-rendering
tests (frontend/tests/components/, real DOM via happy-dom +
Testing Library) — no backend changes needed for either.
A few things worth knowing about for the oral defense, beyond the basic flow:
- Transactional outbox (order service) — events are written to an outbox table in the same DB transaction as the order, then published by a separate poller. A crash between commit and publish can never silently drop an event.
- Automatic RabbitMQ reconnection — a dropped broker connection recovers on its own; consumers and the outbox poller resume without a service restart.
- Redis-backed idempotency — every consumer dedupes redelivered events by
eventId, namespaced per consumer so two services consuming the same event don't interfere with each other. - Retry with exponential backoff — a failing handler gets 3 retries (1s/2s/4s) via a dead-letter-based delay queue before landing in the permanent dead-letter queue.