Two NestJS microservices (Products, Notifications) communicating asynchronously via AWS SQS, plus a React SPA. TypeScript monorepo with shared zod contracts.
Detailed design rationale:
docs/plans/products-mvp/PRD.mdImplementation breakdown:docs/plans/products-mvp/IMPLEMENTATION_PLAN.md
┌──────────────┐
HTTP │ │
┌───────────────►│ Products │──── Postgres (5432)
│ │ :3001 │ ├─ products
│ │ │ └─ outbox_events
│ └──────┬───────┘
│ │
┌──┴───┐ │ @Interval(1s)
│ Web │ │ outbox publisher
│ :5173│ ▼
└──────┘ ┌──────────────┐
│ LocalStack │
│ SQS :4566 │
│ (+ DLQ) │
└──────┬───────┘
│ long-poll
▼
┌──────────────┐
│ Notifications│──── Postgres (5433)
│ :3002 │ └─ notification_log
└──────────────┘
Prometheus :9090 scrapes /metrics on both services
Reliability: transactional outbox in Products + at-least-once delivery via SQS + idempotent consumer (ON CONFLICT DO NOTHING) in Notifications + DLQ for poison messages.
- Node 22.15+ (
.nvmrc) - pnpm 9+
- Docker Desktop
# 1. Install dependencies
pnpm install
# 2. Build contracts (dual CJS+ESM via tsup)
pnpm --filter @app/contracts build
# 3. Start infrastructure (Postgres x2, LocalStack, Prometheus)
cd docker && docker compose up -d && cd ..
# 4. Apply DB migrations to both services
pnpm migrate:all
# 5. Generate OpenAPI YAML + frontend client
pnpm openapi:gen
pnpm --filter @app/web openapi:generate
# 6. Run all services (products :3001, notifications :3002, web :5173)
pnpm dev:all| Service | URL |
|---|---|
| Web (Vite) | http://localhost:5173 |
| Prometheus | http://localhost:9090 |
| Products Swagger UI | http://localhost:3001/api/docs |
| Products API | localhost:3001 |
| Notifications | localhost:3002 |
| LocalStack SQS | localhost:4566 |
| Postgres (products) | localhost:5432 (db products) |
| Postgres (notifications) | localhost:5433 (db notifications) |
POST /products→ Products opens a DB transaction- Within the transaction:
INSERT products+INSERT outbox_events— atomic OutboxPublisherServicepollsoutbox_eventsevery 1s usingSELECT ... FOR UPDATE SKIP LOCKED- For each pending row:
SQSClient.send()→ markpublished_at = NOW() - Notifications service long-polls SQS (
WaitTimeSeconds: 20) - Each message: validate via shared
ProductEventSchema→INSERT notification_logwithON CONFLICT (message_id) DO NOTHING - On success:
DeleteMessageCommand. On error: don't delete → SQS retries (visibility timeout) → DLQ after 3 attempts
Correlation: X-Request-Id header → req.id (pino-http) → correlationId in event payload → logged in Notifications. Trace one HTTP request across all logs.
Logs (pino, structured JSON in production, pretty in dev):
- Auto request-log with
req.id,method,url,statusCode,responseTime /metricsand/health/*ignored from auto-logging to reduce noise
Metrics (Prometheus on /metrics):
products_created_total,products_deleted_totaloutbox_pending_total(gauge),outbox_published_total,outbox_publish_errors_totalnotifications_processed_total{type},notifications_dedup_total,notifications_invalid_payload_total- Plus default Node metrics (CPU, heap, event-loop lag)
Health (terminus, k8s-ready):
GET /health/live— process liveness (always 200)GET /health/ready— readiness incl. DB ping (200 or 503)
products-app/
├── apps/
│ ├── products/ # NestJS — REST + outbox publisher
│ ├── notifications/ # NestJS — SQS consumer + audit log
│ └── web/ # Vite + React + MUI + TanStack Query
├── packages/
│ └── contracts/ # zod schemas (dual CJS+ESM via tsup) + openapi.yaml
├── docker/
│ ├── docker-compose.yml # Postgres x2, LocalStack, Prometheus
│ ├── init-localstack.sh # creates SQS queue + DLQ on container start
│ └── prometheus.yml # scrape configs
└── docs/
└── plans/products-mvp/ # PRD + implementation plan
| Command | What it does |
|---|---|
pnpm dev:all |
Run products + notifications + web in parallel |
pnpm migrate:all |
Apply Drizzle migrations to both DB instances |
pnpm openapi:gen |
Build contracts + emit openapi.yaml from Products |
pnpm test |
Run all unit tests across packages |
pnpm --filter @app/products test:e2e |
Run e2e tests with testcontainers |
pnpm typecheck |
Typecheck across all packages |
pnpm lint |
ESLint across all packages |
pnpm format |
Prettier on the entire repo |
- Single source of truth for validation — zod schemas in
@app/contractsconsumed by NestJS validation pipe AND react-hook-formzodResolver. Backend and frontend impossible to drift. - Transactional outbox —
INSERT products+INSERT outbox_eventsin the same Postgres transaction; eventual SQS publish via background polling. At-least-once + idempotent consumer = no event loss without distributed transactions. SELECT ... FOR UPDATE SKIP LOCKEDfor outbox draining — multi-instance safe.- Defense in depth for HTML — sanitize-html on backend input + DOMPurify on frontend render, both reading the same allow-list from
@app/contracts/sanitize. - OpenAPI as contract — Swagger emitted from NestJS via nestjs-zod, consumed by orval to generate typed React Query hooks. No manual API client.
- Two Postgres instances — independent ownership per microservice (PRD requirement); cross-service comms only through SQS.
Port 5432/5433 already in use — another Postgres container running. Stop it (docker stop <name>) or remap ports in docker/docker-compose.yml.
Cannot find module '@app/contracts/products' — run pnpm --filter @app/contracts build (or pnpm --filter @app/contracts dev for watch mode).
LocalStack queue not created — check docker compose logs localstack | grep init-localstack. Init script lives in docker/init-localstack.sh and runs at startup.
Frontend can't reach backend — Vite proxy expects products on :3001. Check vite.config.ts if you remapped backend port.