Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .claude/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,21 @@ Snyk: 0 issues across every changed package, including `tests/e2e`. Typecheck gr

**Workflow reminder**: every phase gets its own branch off `main`, pushed with a PR via `gh pr create` — never commit straight to `main`. No Claude/Anthropic references in commits, PRs, or files (user's explicit preference). Repo: https://github.com/devinder-dev/quickbite (public).

## Fix-up pass: exam-criteria gaps (branch `fix-exam-criteria`)

After all 6 phases were done, a pass comparing the system against the actual examination brief (single public entry point, README accuracy, pure unit tests for logic) surfaced and fixed:

1. **Single public entry point lockdown** — `docker-compose.yml` no longer publishes host ports for `postgres`, `redis`, `rabbitmq`, or `gateway`; nginx is now `80:80` (was `8080:80`). A new `docker-compose.dev.yml` (NOT auto-merged — must be passed explicitly via `-f`) restores host ports for `postgres`/`redis`/`rabbitmq` for local debugging only. Verified empirically: `docker compose ps` shows only nginx with a port mapping, internal ports closed to the host, full order flow works through nginx alone, dev override restores infra ports without exposing the gateway.
2. **README rewritten** to match the actual built system and state the single entry point (`http://localhost`, port 80, via nginx) explicitly.
3. **Two pure unit tests added**, satisfying the brief's explicit (separate from integration/e2e) unit-testing requirement: `services/order/src/pricing.ts` (`computeTotalCents`, extracted out of `server.ts`'s route handler) and `packages/shared/src/mq.ts`'s `backoffMs` (now exported). Cache-aside and Redis-NX idempotency logic deliberately left to existing integration tests, not forced into mocks.
4. **Real bug found and fixed — gateway proxy was completely broken.** `@fastify/http-proxy` (via `@fastify/reply-from`) forwards through a Node-style undici `Pool` whose `.request()` Bun's runtime doesn't implement compatibly — every proxied call threw `pool.request is not a function`. This had never been caught because nothing had tested the real flow through nginx until this pass. Fixed by hand-rolling the proxy in `services/gateway/src/index.ts` with Bun's native `fetch()`; removed `@fastify/http-proxy` from `services/gateway/package.json`. Verified end-to-end through the real Docker stack (menu read, place order, order status, 404, full kitchen/notification event chain).
5. **Second real bug found and fixed — `RedisIdempotency` read `REDIS_URL` into a module-level constant at import time**, not inside the constructor. Any test file that statically imports `@quickbite/shared` (which re-exports `idempotency.ts`) before setting `process.env.REDIS_URL` in `beforeAll` locks in the fallback `redis://localhost:6379` — a port nothing is listening on — causing every Redis call in that test run to fail with `ioredis Unhandled error event`. This looked exactly like Docker Desktop network flakiness (same symptom, same unchanged-test-also-fails evidence) but survived a full Docker Desktop restart, which is what proved it was a code bug, not an environment issue. Fixed by moving the `process.env.REDIS_URL` read into `RedisIdempotency`'s constructor default parameter. **Lesson: any module-level `const X = process.env.Y ?? fallback` in a file reachable via a package barrel (`packages/shared/src/index.ts`) is unsafe for tests that set env vars in `beforeAll` — only safe if the env read happens inside a function/constructor, not at import time.**

Snyk: 0 issues. Typecheck and full `bun run test` (all 6 packages) green after both bug fixes.

## Next step

None — every phase in the build plan (`~/.claude/plans/lets-read-the-files-unified-galaxy.md`) is done. Possible follow-ups beyond the original plan, if wanted later: a real notification send (email/push, still a `TODO` in `services/notification/src/index.ts`), CI badge/status in `README.md`, or load-testing the outbox poller under concurrent order volume.
This fix-up pass needs to be committed, pushed, and merged via PR (same workflow as every phase — own branch, no Claude/Anthropic references). After that: no more build work planned. The user's next intent is a Q&A/walkthrough session to fully understand the codebase ahead of the oral defense (explain architecture, justify decisions, describe traffic flow, name an error scenario and its handling). Possible further follow-ups if wanted later: a real notification send (email/push, still a `TODO` in `services/notification/src/index.ts`), CI badge/status in `README.md`, or load-testing the outbox poller under concurrent order volume.

## Key files to know

Expand Down
108 changes: 69 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,68 +1,98 @@
# QuickBite

Event-driven food-ordering platform, built as a system-level QA project.
Microservices in a Bun workspace, communicating over RabbitMQ.
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.

## What's here
## Architecture

- `packages/shared` — event contracts (zod schemas) + RabbitMQ publish/consume helpers with a dead-letter queue and an idempotency guard. **This is the core.**
- `services/gateway` — public entry point; proxies reads to menu/order.
- `services/menu` — menu reads (static list for now).
- `services/order` — creates orders, publishes `order.placed`.
- `services/kitchen` — consumes `order.placed`, publishes `order.accepted` then `order.ready`.
- `services/notification` — consumes all order events, notifies the customer.
```
client → nginx (:80) → gateway → menu / order (synchronous reads)
|
v
order.placed (RabbitMQ)
|
+------------+------------+
v v
kitchen notification
(accepts, then ready) (logs every order event)
```

- **nginx is the system's only public entry point.** Every other service —
RabbitMQ, Postgres, Redis, and even the gateway 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.

## Run it

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

```bash
cp .env.example .env
docker compose up --build
```

- Gateway via Nginx: http://localhost:8080
- Gateway direct: http://localhost:3000
- RabbitMQ management UI: http://localhost:15672 (guest / guest)

Place an order and watch the event flow in the logs:
**Public 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.

```bash
curl -X POST http://localhost:8080/api/orders \
# 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/menu
```

You'll see order -> kitchen (accepted, then ready) -> notification in the container logs.
Watch `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.

## Local dev (without Docker)
### Local development

Start RabbitMQ (`docker compose up rabbitmq`), then in separate terminals:
For 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:

```bash
bun install
bun run dev:menu
bun run dev:order
bun run dev:kitchen
bun run dev:notification
bun run dev:gateway
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
```

## Tests

```bash
bun test # unit + (later) integration
bun run test # unit + integration (Testcontainers) + end-to-end
bun run typecheck
```

## Deliberately left as TODOs (your build work)

These are stubbed so the scaffold runs immediately. Flesh them out — ideally
one per Claude Code plan-mode session:

1. Replace in-memory stores with **per-service PostgreSQL** databases.
2. Add the **outbox pattern** so events publish only after the DB commit.
3. Persist the **idempotency** set to a `processed_events` table.
4. Add **retry-with-backoff** before dead-lettering in `mq.ts`.
5. Add **Testcontainers** integration tests (real Postgres + RabbitMQ).
6. Add an **end-to-end** test: place order -> ready -> notified.

See `CLAUDE.md` for the rules these must follow.
Runs 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.

## Resilience features

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.
13 changes: 0 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Local-development override — adds back host ports for direct access to
# infra while developing (e.g. running a service bare with `bun run dev:X`
# against dockerized Postgres/RabbitMQ/Redis, or poking at them with a GUI
# tool). NOT auto-merged by `docker compose up` (only docker-compose.override.yml
# is); use explicitly:
#
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
#
# The gateway is deliberately NOT exposed here — even in dev, the only
# supported way to reach the application is through nginx.
services:
rabbitmq:
ports: ["5672:5672", "15672:15672"] # 15672 = management UI

postgres:
ports: ["5432:5432"]

redis:
ports: ["6379:6379"]
12 changes: 7 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
services:
# No published ports below except nginx — nginx is the system's only
# public entry point. RabbitMQ/Postgres/Redis/the gateway are reachable
# only from other containers on the internal Docker network, by service
# name. For local debugging access to these, use docker-compose.dev.yml.
rabbitmq:
image: rabbitmq:3-management
ports: ["5672:5672", "15672:15672"] # 15672 = management UI
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 10s
Expand All @@ -13,7 +16,6 @@ services:
environment:
POSTGRES_USER: quickbite
POSTGRES_PASSWORD: quickbite
ports: ["5432:5432"]
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
Expand All @@ -25,7 +27,6 @@ services:

redis:
image: redis:7-alpine
ports: ["6379:6379"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
Expand All @@ -39,7 +40,6 @@ services:
MENU_URL: http://menu:3001
ORDER_URL: http://order:3002
depends_on: [menu, order]
ports: ["3000:3000"]

menu:
build: { context: ., dockerfile: services/menu/Dockerfile }
Expand Down Expand Up @@ -87,10 +87,12 @@ services:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }

# The system's single public entry point — every other service above is
# unreachable from outside the Docker network.
nginx:
image: nginx:alpine
volumes: ["./nginx/nginx.conf:/etc/nginx/nginx.conf:ro"]
ports: ["8080:80"]
ports: ["80:80"]
depends_on: [gateway]

volumes:
Expand Down
8 changes: 6 additions & 2 deletions packages/shared/src/idempotency.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import Redis from "ioredis";

const REDIS_URL = process.env.REDIS_URL ?? "redis://localhost:6379";
const EVENT_SEEN_TTL_SECONDS = 60 * 60 * 24; // 24h — comfortably covers any realistic redelivery window

/**
Expand All @@ -22,7 +21,12 @@ export class RedisIdempotency {
private readonly redis: Redis;
private readonly namespace: string;

constructor(namespace: string, redisUrl: string = REDIS_URL) {
// redisUrl is read from process.env here, inside the constructor, rather
// than into a module-level constant at import time — a test (or any
// caller) that sets REDIS_URL before constructing this class must have
// that value actually take effect, even if @quickbite/shared was already
// imported earlier with no REDIS_URL set yet.
constructor(namespace: string, redisUrl: string = process.env.REDIS_URL ?? "redis://localhost:6379") {
this.namespace = namespace;
this.redis = new Redis(redisUrl);
}
Expand Down
5 changes: 3 additions & 2 deletions packages/shared/src/mq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ export type ResilientChannel = ReturnType<typeof createResilientChannel>;

const MAX_RETRIES = 3;

// Exponential backoff: 1s, 2s, 4s for retryCount 0, 1, 2.
function backoffMs(retryCount: number): number {
// Exponential backoff: 1s, 2s, 4s for retryCount 0, 1, 2. Exported so it has
// its own unit test, separate from the integration-level retry behavior.
export function backoffMs(retryCount: number): number {
return 1000 * 2 ** retryCount;
}

Expand Down
Loading
Loading