Skip to content

Repository files navigation

Products MVP

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.md Implementation breakdown: docs/plans/products-mvp/IMPLEMENTATION_PLAN.md

Architecture

                    ┌──────────────┐
        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.

Prerequisites

  • Node 22.15+ (.nvmrc)
  • pnpm 9+
  • Docker Desktop

Setup

# 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

Services and ports

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)

How events flow

  1. POST /products → Products opens a DB transaction
  2. Within the transaction: INSERT products + INSERT outbox_events — atomic
  3. OutboxPublisherService polls outbox_events every 1s using SELECT ... FOR UPDATE SKIP LOCKED
  4. For each pending row: SQSClient.send() → mark published_at = NOW()
  5. Notifications service long-polls SQS (WaitTimeSeconds: 20)
  6. Each message: validate via shared ProductEventSchemaINSERT notification_log with ON CONFLICT (message_id) DO NOTHING
  7. 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.

Observability

Logs (pino, structured JSON in production, pretty in dev):

  • Auto request-log with req.id, method, url, statusCode, responseTime
  • /metrics and /health/* ignored from auto-logging to reduce noise

Metrics (Prometheus on /metrics):

  • products_created_total, products_deleted_total
  • outbox_pending_total (gauge), outbox_published_total, outbox_publish_errors_total
  • notifications_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)

Project structure

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

Scripts

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

Architectural decisions worth highlighting

  • Single source of truth for validation — zod schemas in @app/contracts consumed by NestJS validation pipe AND react-hook-form zodResolver. Backend and frontend impossible to drift.
  • Transactional outboxINSERT products + INSERT outbox_events in 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 LOCKED for 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.

Troubleshooting

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages