Architecture
Return here first when re-entering the codebase cold. For deep rationale on any decision, see
docs/engineering-charter.mdor the relevant ADR indocs/adr/.
System Components
graph TD
Client["Client (HTTP)"]
subgraph Process["Fastify Process (src/main.ts)"]
Router["Route Handlers"]
AuthMW["Auth Middleware"]
Workers["Email Workers\n— BullMQ Worker\n— in-process today, splitting soon"]
end
PG[("PostgreSQL 18\nKysely — runtime queries\nPrisma — migrations only")]
RedisS[("Redis — session\nREDIS_URL\nLRU evictable")]
RedisQ[("Redis — queue\nQUEUE_REDIS_URL\nAOF-persisted")]
Resend["Resend API\n(transactional email)"]
S3["AWS S3\n(object storage)"]
Client -->|"Bearer token / IP-checked"| Router
Router --> AuthMW
AuthMW -->|"fast path: cache lookup"| RedisS
AuthMW -->|"slow path: session + user + tenant"| PG
Router -->|"Kysely queries"| PG
Router -->|"idempotency keys + enqueue jobs"| RedisQ
Workers -->|"consume jobs"| RedisQ
Workers -->|"send emails"| Resend
Router -->|"presigned URLs / uploads"| S3
Two Redis instances are intentional — session cache (REDIS_URL) can be LRU-evicted without data loss; a lost session is just a logout. Queue Redis (QUEUE_REDIS_URL) must be AOF-persisted because losing it loses enqueued jobs.
Email workers currently start inside the same process as the HTTP server (via startEmailWorkers() in buildApp()). They will move to a dedicated worker process; when that happens, both processes connect to QUEUE_REDIS_URL independently.
Startup Sequence
flowchart TD
A["main.ts"] --> B["validateConfig()"]
B -->|"invalid PORT\nor missing RESEND_API_KEY in prod"| X1["process.exit(1)"]
B --> C["assertInfraReady()"]
C -->|"Postgres or either Redis\nunreachable within 5 s"| X2["process.exit(1)"]
C --> D["buildApp()"]
D --> E["register Fastify plugins\n(swagger, error handler, modules)"]
E --> F["start email workers\n(skipped if RESEND_API_KEY is empty)"]
F --> G["app.listen(PORT)"]
G -->|"bind error"| X3["process.exit(1)"]
G --> H["accepting traffic"]
Nothing degrades silently — any failure before app.listen exits the process immediately.
Request Lifecycle
Tenant route (any route outside /platform/*)
sequenceDiagram
participant C as Client
participant F as Fastify
participant A as authenticate hook
participant RS as Redis (session)
participant PG as Postgres
participant Svc as Service
participant Repo as Repository
C->>F: GET /some/route\nAuthorization: Bearer <token>
F->>A: onRequest
A->>RS: GET session:<token>
alt cache hit
RS-->>A: { userId, tenantId, role }
else cache miss
A->>PG: SELECT Session JOIN User JOIN Tenant
PG-->>A: row
A->>RS: SETEX session:<token> <remainingTtl>
end
A-->>F: request.user = { userId, tenantId, role }
F->>Svc: handler passes tenantId explicitly
Svc->>Repo: query(db | tx, tenantId, ...)
Repo->>PG: SELECT ... WHERE tenant_id = $1
PG-->>Repo: rows
Repo-->>Svc: typed result
Svc-->>F: response payload
F-->>C: 200 JSON
Platform route (/platform/*)
Platform routes go through a separate middleware chain:
- IP allowlist check — request IP must be in
PLATFORM_IP_ALLOWLIST. Non-matching IPs receive404(not403) to avoid confirming the surface exists. authenticate-platformhook — looks upPlatformAdminSessionby SHA-256 token hash. No Redis cache — DB-only, so row deletion revokes instantly.- No
tenantId— platform handlers are cross-tenant by design. All platform queries are unscoped.
API Surfaces
| Surface | Prefix | Auth | Who |
|---|---|---|---|
| Tenant API | /* (everything else) |
Bearer opaque token → Session table |
Tenant users |
| Platform API | /platform/* |
IP allowlist + Bearer → PlatformAdminSession |
Superadmins only |
| Swagger UI | /platform/docs |
IP allowlist (same hook, 404 to non-listed) | Superadmins only |
| Bull Board | /platform/queues |
IP allowlist (same hook, 404 to non-listed) | Superadmins only |
| Health | /health |
None | Load balancer / monitoring |
PLATFORM_IP_ALLOWLIST is a comma-separated list of exact IPs and IPv4 CIDR ranges. Empty = fail-closed (deny all). See ADR 0002.
Domain Model
Five bounded domains. Cross-domain relations flow through stable IDs — no FK constraints across domain boundaries.
graph TD
subgraph Identity
Tenant --> User
User --> Session
User --> PasswordResetToken
PlatformAdmin --> PlatformAdminSession
PlatformAdmin --> PlatformAdminLoginToken
end
subgraph Catalog["Product Catalog"]
Category
Brand
Product --> ProductVariant
UnitOfMeasure
PriceList --> PriceListItem
end
subgraph Inventory
Warehouse --> StockLevel
ProductVariant --> StockLevel
StockMovement["StockMovement (immutable ledger)"]
StockLot
StockSerial
end
subgraph Purchasing
Supplier --> PurchaseOrder
PurchaseOrder --> GoodsReceipt
GoodsReceipt -->|"writes"| StockMovement
end
subgraph Sales
Customer --> SalesOrder
SalesOrder --> Shipment
Shipment -->|"writes"| StockMovement
end
User -.->|"tenantId scopes all business tables"| Tenant
Key invariants:
- StockMovement is an immutable ledger — never updated, never deleted. Corrections are offsetting rows. StockLevel is a materialized snapshot rebuildable by summing movements.
- AuditLog is also immutable — written in the same transaction as the change it records.
- UnitOfMeasure is the only global (non-tenant-scoped) business table. Every other business table carries tenantId.
For full column detail, see prisma/schema.prisma.
Module Map
| Module / Directory | Owns |
|---|---|
src/main.ts |
Process entry point — startup sequence |
src/app.ts |
buildApp() — plugin registration, worker startup, graceful shutdown |
src/modules/auth/ |
Tenant login, logout, password reset, session management |
src/modules/platform/ |
Tenant provisioning, platform admin auth (magic-link), platform-scoped ops |
src/modules/users/ |
Tenant user CRUD, role management, user welcome email |
src/modules/categories/ |
Category tree (recursive), CRUD |
src/modules/products/ |
Product + variant catalog, pricing, verification flow |
src/modules/health/ |
GET /health — liveness probe |
src/shared/auth/ |
Session lookup, IP allowlist, permissions, token hashing |
src/shared/db/ |
Kysely instance (AppDb), Prisma owns migrations only |
src/shared/cache/ |
ioredis client factory |
src/shared/queue/ |
BullMQ connection factory, Bull Board plugin |
src/shared/audit/ |
Tenant AuditRepository, platform PlatformAuditRepository |
src/shared/email/ |
Resend client, HTML email templates |
src/shared/storage/ |
S3 client, presigned URL helpers |
src/shared/idempotency/ |
Idempotency key store (backed by queue Redis) |
src/shared/errors/ |
Error base classes, Fastify error handler |
src/shared/config/ |
Typed config object, validateConfig() |
src/shared/infra/ |
assertInfraReady() — startup probes |
src/shared/logging/ |
Pino logger instance |
src/workers/email.worker.ts |
BullMQ workers for all email queues |
src/scripts/ |
CLI scripts for platform admin lifecycle (run outside HTTP server) |
src/types/ |
Kysely DB type (codegen output), Fastify type augmentations |